Pull to refresh

Готовим плацдарм для react-приложения

Reading time 14 min
Views 61K


Я хочу рассказать о процессе создание платформы для react приложения, которая использует mobx в качестве Model-и. Пройти путь от пустой директории проекта до рабочего примера. Рассмотреть основные моменты, на которые я обращал внимание в процессе разработки. Постараюсь насытить текст уточняющими ссылками, дополнительные заметки будут выделены курсивом с пометкой «Note:».

Рассказ будет состоять из двух частей:

  1. Готовим плацдарм для react приложения
  2. Mobx + react, взгляд со стороны

Буду писать «как я вижу», поэтому предложения и замечания по улучшению приветствуются. Надеюсь, читатель знает, что такое npm, node.js и react.js, имеет базовые знания о props и state. На момент написания статьи, у меня стоит windows и нестабильная node.js 7.3.0 версии.

Готовим плацдарм для react приложения


Существуют тысячи react skeleton-ов и boilerplate-ов, та что уж тут говорить, даже «fb» выпустил свой с блекджеком и hotreload-ом. Мы же не будем использовать готовый, а соберем все своими руками и увидим, как это работает. Самостоятельно пройдем этот путь и заглянем во все темные углы, чтобы понять всю механику процесса, так же разобраться в тех деталях, которые были непонятны ранее. Я не претендую на очередной велосипед, скорее разработка ради просвещения. Переполняемые энтузиазмом, открываем консоль в любимой IDE, создаем новую директорию для проекта и переходим внутрь. Погнали!

npm init

Тут все просто, вам предложат несколько общих вопросов, после чего npm создаст для нас package.json файл управления зависимостями.

Note: Чтобы каждый раз не заполнять информацию о себе, можно прописать

npm set init.author.name "your name"
npm set init.author.email "your email"
npm set init.author.url "your site url"

Далее установим необходимые для работы react-а пакеты и сделаем о них запись в package.json в секции dependencies. Мы будем использовать react-router, поэтому сразу поставим и его:

npm i --save react react-dom react-router

Заложим структуру проекта и создадим пару директорий. В большинстве случаев требуется клиент-серверное приложение. Мне нравится иметь две отдельные директории для клиентской и серверной части, тут можно холиварить бесконечно долго, для примера я выбрал следующую структуру:



Для скелета нам понадобится:

  • index.js – точка входа в клиентское приложение. Это первый файл в приложении, в который я смотрю, если вижу чужой проект в первый раз, это ниточка с которой начинаешь распутывать весь клубок;
  • routes.js – настройка роутера. Для начала хватит одного роута, чтобы показать
  • home.js – home page;
  • index.html – мы будем делать SPA, index.html — это та единственная страничка;

index.html
<!doctype html>
<html>
<head>
</head>
<body>
    <div id="app"></div>
</body>
</html>


Тут стоит обратить внимание на div#app, это контейнер для нашего будущего react приложения. Чуть позже мы добавим сюда скрипт.

index.js
  import React from 'react';
  import ReactDOM from 'react-dom';

  import AppRouter from './routes';

  ReactDOM.render(<AppRouter />, document.getElementById("app"));


Рендерим <AppRouter /> в тот самый div#app.

views/home.js es6
import React from 'react';

export default class Home extends React.Component {
  render() {
    return (
      <h1>Hello Kitty!</h1>
    );
  }
}


Перед роутом давайте посмотрим на домашнюю (и пока единственную) вьюшку. Это react компонент, который просто выводит надпись приветствия.

Мы будем использовать ES6 way при создании react компонент. Как подружить react с ES6, можно почитать в документе или тут на русском. Рекомендую сразу пытаться писать на ES6, вы тут же почувствуете выгоду, тем более тема легкая для понимания.

Конечно же для удобства мы будем использовать jsx нотацию. Для того, чтобы браузер понял наш код, мы будем использовать babel транслятор, кроме того хочется идти в ногу со временем и использовать ES6/ES2015 фичи, но не все браузеры поддерживают этот стандарт, поэтому я опять обращусь к babel за помощью. Получается, babel – транспайлер, переписывающий код, написанный в новых стандартах, в код стандарта es5, который понимаю почти все браузеры, а также может транслировать react jsx код, в код понятный браузеру. А еще, он поддерживает кучу плагинов. Это очень круто!

Note: Всю эту магию преобразований можно пощупать даже онлайн.
Попробуйте вставить любой react или es6 код и увидите во что он трансформируется, например, код из home.js


Если вы проделали эту процедуру, то у вы могли заметить, что 9 строк react ES6 кода (~400 байт) превратились в 44! строки ES5 (~2200 байт)



Это расплата за синтаксический сахар, ведь, class-ов в javascript-e нет. Можно наблюдать как babel с легкой руки сделал из class-a функцию.

Наверно, на этом этапе нужно сказать пару строк о stateless компонентах. Грубо говоря, такими называются компоненты, у которых нет состояния. Наш Home компонент как раз не имеет состояния, поэтому мы можем переписать его как:

stateless home.js
import React from 'react';

const Home = (props) => {
  return (
    <h1>Hello Kitty</h1>
  );
};

export default App;


Мы избавились от class-а, поэтому этот код будет гораздо короче в конечном ES5 синтаксисе, а его объем уменьшится более чем в 5 раз. Кроме того, мы можем сделать исходный код еще лаконичнее:

stateless home.js
import React from 'react';

const Home = () => (
  <h1>Hello Kitty</h1>
);

export default App;


Note: Мне нравится эта статья на тему stateless components, рекомендую.

routes.js
import React from 'react';
import {
  Router,
  Route,
  browserHistory
} from 'react-router';

import Home from './views/home';

export default () => (
  <Router history={browserHistory}>
    <Route path='/' component={Home} />
  </Router>
);


Наконец, в роутах пропишем только один path к Home компоненту. Тут вопросов не должно возникнуть, библиотека проста, но в тоже время обладает мощным функционалом.

Подсунуть браузеру, читаемый код – только пол дела, так как проект состоит из множества файлов, а в конечном результате нам нужен только один минимизированный js файл(который мы подключим к index.html), то нам понадобится еще и сборщик модулей. Собирать мы будем с помощью webpack.

Ставим его:

npm i --save-dev webpack

Note: Обратите внимание, что webpack ставится в devDependencies секцию.
Все, что связано с разработкой и не будет использоваться в продукционной среде ставится с флагом --save-dev, зачастую это: сборщики и плагины к ним, тесты, линтеры, лоадеры, пост/препроцессоры и прочее.

Как было описано выше для всех преобразований кода нам нужен babel и необходимые preset-ы(наборы плагинов) к нему:

npm i --save-dev babel-loader babel-core babel-preset-es2015 babel-preset-react

Webpack-у потребуется файл конфигурации, создадим webpack.config.js в корне директории проекта.

webpack.config.js
var webpack = require('webpack');

module.exports = {
  entry: './client/index.js',
  output: {
    path: __dirname + '/public',
    filename: 'bundle.js'
  },
  module: {
    loaders: [
      {
        test: /.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/
      }
    ]
  }
};


Также babel рекомендует использовать .babelrc файл, тут опишем какие preset-ы мы хотим использовать.

{
  "presets": ["es2015", "react"]
}

Note: Пара полезных ссылок: 6 вещей, которые необходимо знать о babel 6 и какая разница в порядке объявления preset-ов.

Тут мы говорим сборщику, что точка входа в наше приложение – client/index.js файл, webpack начнет свою работу с этого файла, нам не нужно указывать ему какие файлы должны войти в сборку, все это он сделает за нас сам. На выходе должен получиться один bundle.js файл в директории public. Грубо говоря, этим конфигом мы говорим babel-ю: «Эй, склей все необходимые файлы в один, начиная с index.js и позаботься о том, чтобы babel преобразовал все .js и .jsx файлы в код понятный браузерам.» Разве это не здорово? Настройка webpack-а готова, идем в консоль и запускаем сборщик:

webpack

В директории public должен появиться файл bundle.js. Public — это наша публичная директория, после билда все «готовые» файлы(для нас это index.html + bundle.js) должны попасть сюда. Бандл готов, пришло время заняться html. Тут стоит понимать, что текущий index.html — это только заготовка, в дальнейшем нам, например, нужно прицепить CSS или js файлы, минимизировать или добавить какое-то содержимое, при этом для разных сборок производить разные операции. Для этих целей нам потребуется HtmlWebpackPlugin. Ставим его:

npm i --save-dev html-webpack-plugin

После идем в файл конфигурации и настраиваем плагин:

webpack.config.js
var webpack = require('webpack');
var HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './client/index.js',
  output: {
    path: __dirname + '/public',
    filename: 'bundle.js'
  },
  module: {
    loaders: [
      {
        test: /.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './client/index.html',
      inject: "body"
    })
  ]
};


Этим мы говорим webpack-у, чтобы он вставил 'script' тег со ссылкой на сбилденый им bundle.js в нашу заготовку index.html. При этом «готовый» index.html будет лежать рядом с бандлом, т.е в public. Запустим webpack еще раз и убедимся в этом, проверив публичную директорию.

Давайте вернемся к нашему барандлу, который собрал нам webpack. Внимательный читатель заметит, что ~710KB многовато для 'Hello Kitty!'. Согласен, но у нас пока девелоп версия, которая предоставляет дополнительный функционал в помощь разработчику, например, показывает различные варнинги в консоли. Давайте попробуем намекнуть react-у, что мы хотим собрать проект под продукцию. Для этого нужно минимизицировать конечный bundle.js и задать NODE_ENV переменной окружения значение «production». В конфиге добавляем плагины, при этом ничего дополнительного качать и устанавливать не нужно.

webpack.config.js
  plugins: [
    new webpack.DefinePlugin({
      "process.env": {
        NODE_ENV: JSON.stringify("production")
      }
    }),
    new webpack.optimize.UglifyJsPlugin({
      compress: {
        warnings: true
      }
    }),
    new HtmlWebpackPlugin({
      template: './client/index.html',
      inject: "body"
    })]


Ознакомиться с полным списком плагинов можно тут.

Note: Если не задать NODE_ENV=production, а просто сжать файл, то react покажет предупреждение в консоли:



Пересоберем проект с использованием плагинов и вновь посмотрим на наш свежий бандл.



С этим уже можно работать, но это еще не предел, давайте посмотрим на еще одну настройку конфигурации webpack-а — "devtool". Эта опция также влияет на размер конечного файла и скорость сборки. Поэтому мы будем использовать разные значения для продукции и девелопа. Тут можно почитать как работает каждая опция. Для себя я выбрал «source-map» для продакшена и «inline-source-map» для девелопа, хотя, наверно, для разных проектов эти значения могут варьироваться. Тут нужно поиграть и выбрать оптимальное для себя.

Настало время изменить файл конфигурации, ведь теперь мы с лёгкостью можем собирать наш проект под разные нужды, мне не нравится решение, когда в одном конфиге через условия регулируются настройки. При увеличении настроек и/или типов сборок, конфиг становится тяжело читаем, поэтому будем использовать webpack-config.

npm install --save-dev webpack-config

Как видно из описания — это помощник для загрузки, расширения и мерджа конфигурационных файлов. В данном примере я бы хотел иметь возможность делать две сборки: development и production. Добавим директорию conf и три конфига, как показано на рисунке:


webpack.base.config.js
import Config from 'webpack-config';
import HtmlWebpackPlugin from 'html-webpack-plugin';

export default new Config().merge({
  entry: './client/index.js',
  output: {
    path: __dirname + '/../public',
  },
  module: {
    loaders: [
      {
        test: /.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './client/index.html',
      inject: "body"
    })]
});


В базовом конфиге находятся общие настройки, которые справедливы для двух сборок.

webpack.development.config.js
import Config from 'webpack-config';

export default new Config().extend('conf/webpack.base.config.js').merge({
  output: {
    filename: 'bundle.js'
  }
});


В development конфиге мы указываем лишь имя конечного бандла — 'bundle.js'

webpack.production.config.js
import webpack from 'webpack';
import Config from 'webpack-config';

export default new Config().extend('conf/webpack.base.config.js').merge({
  output: {
    filename: 'bundle.min.js'
  },
  plugins: [
    new webpack.optimize.UglifyJsPlugin({
      compress: {
        warnings: true
      }
    })]
});


Для production, мы добавляем плагин для минимизации, а так же меняем имя бандла. Как видно оба конфига расширяются от базового.

webpack.config.js
import Config, { environment } from 'webpack-config';

environment.setAll({
  env: () => process.env.NODE_ENV
});

export default new Config().extend('conf/webpack.[env].config.js');


Теперь мы можем управлять сборками с помощью переменной среды NODE_ENV, в зависимости от её значения, webpack-config будет автоматически подтягивать нужный файл.

Note: webpack.config.js использует ES6 синтаксис, поэтому при попытке запустить webpack, вы увидите ошибку «SyntaxError: Unexpected token import». Для решения проблемы достаточно переименовать данный файл в webpack.config.babel.js. Этим мы пропускаем конфиг через babel-loader.

Добавим необходимые скрипты запуска webpack-а в package.json в секцию scripts:

  "scripts": {
    "build-dev": "set NODE_ENV=development&& webpack --progress",
    "build-prod": "set NODE_ENV=production&& webpack --progress"
  },

С флагом --progress можно видеть прогресс выполнения и отчет по бандлам. Теперь мы можем собирать две разные сборки; для продукции:

npm run build-prod

и для разработки:

npm run build-dev

Note: Я работаю в windows, поэтому присвоение выглядит так «set NODE_ENV=production». Для других ОС присвоение выглядит по-другому.

Остался последний штрих — hot loader. Эта штука позволяет пересобирать проект на лету при изменении в исходных файлах. При этом страница не будет перезагружена и состояния не буду потеряны. Это в разы ускоряет разработку, а процесс девелопинга превращается в наслаждение. Подробнее можно послушать в этом подкасте, так же там есть ссылки на интересные ресурсы по данной теме.

Для этого нам понадобятся: react-hot-loader, webpack-dev-middleware и webpack-hot-middleware и, конечно же, сам сервер, будем использовать express.

npm i --save express

npm i --save-dev react-hot-loader@next webpack-dev-middleware webpack-hot-middleware

Note: Обратите внимание, что необходимо установить react-hot-loader next версии.

Добавим в корень проекта файл

server.js
import express from 'express';
import path from 'path';

const PORT = 7700;
const PUBLIC_PATH = __dirname + '/public';
const app = express();

const isDevelopment = process.env.NODE_ENV === 'development';

if (isDevelopment) {
  const webpack = require('webpack');
  const webpackConfig = require('./webpack.config.babel').default;
  const compiler = webpack(webpackConfig);
  app.use(require('webpack-dev-middleware')(compiler, {
    hot: true,
    stats: {
      colors: true
    }
  }));
  app.use(require('webpack-hot-middleware')(compiler));
} else {
  app.use(express.static(PUBLIC_PATH));
}

app.all("*", function(req, res) {
  res.sendFile(path.resolve(PUBLIC_PATH, 'index.html'));
});


app.listen(PORT, function() {
  console.log('Listening on port ' + PORT + '...');
});

Минимальный express сервер, единственный нюанс — это настройка middleware для development сборки. Как видно данные для middleware берутся из webpack.config.babel

Следующим шагом добавляем в .babelrc секцию plugins

  "plugins": [
    "react-hot-loader/babel"
  ]

Конфигурационный файл для development теперь выглядит так:

webpack.development.config.js
import webpack from 'webpack';
import Config from 'webpack-config';

export default new Config().extend('conf/webpack.base.config.js').merge({
  entry: [
    'webpack-hot-middleware/client?reload=true',
    'react-hot-loader/patch',
    __dirname + '/../client/index.js'
  ],
  devtool: 'inline-source-map',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
});


Также изменения претерпел

index.js
import React from 'react';
import { AppContainer } from 'react-hot-loader';
import ReactDOM  from 'react-dom';
import AppRouter from './routes';

const render = (Component) =>
  ReactDOM.render(
    <AppContainer>
      <Component />
    </AppContainer>,
    document.getElementById('app')
  );

render(AppRouter);
if (module.hot) {
  module.hot.accept('./routes', () => {
    require('./routes');
    render(AppRouter);
  });
}


И, наконец, скрипты в package.json должны выглядеть так

  "scripts": {
    "build-dev": "set NODE_ENV=development&& node server.js",
    "build-prod": "set NODE_ENV=production&& webpack && node server.js"
  },

Note: если вы попробуете запустить скрипт, то опять увидите ошибку «SyntaxError: Unexpected token import». Потому что, server.js использует ES6 import-ы и пытается прочитать webpack.config.babel.js, в котором тоже используются import-ы. А поддержку обещают только в 8й версии. Потребуется babel для командной строки babel-cli:

npm i --save-dev babel-cli 

Будем использовать babel-node, вместо node, всё должно работать:

  "scripts": {
    "build-dev": "set NODE_ENV=development&& babel-node server.js",
    "build-prod": "set NODE_ENV=production&& webpack && babel-node server.js"
  },

Пробуем собрать обе сборки, при этом для production, соберется минимизированный bundle.min.js и запустится сервер на 7700 порте, а для development будет работать горячая перезагрузка, при этом вы не увидите никаких файлов в public директории, весь процесс будет проходить in memory. Для теста усложним код home.js

home.js
import React from 'react';

export default class Home extends React.Component {

  constructor() {
    super();
    this.state = { name: "Kitty" };
    this.clickHandler = this.clickHandler.bind(this);
  }

  clickHandler() {
    this.setState({ name: "Bunny" });
  }

  render() {
    return (
      <h1 onClick={this.clickHandler}>
        {`Hello ${this.state.name}!`}
      </h1>
    );
  }
}


Кстати, если вы запустили development сборку, то все изменения должны подтянуться сразу. Давайте кликнем по заголовку, тем самым изменим состояние name с «Kitty» на «Bunny», далее в коде заменим текст в заголовке с «Hello» на «Bye». Перейдем в браузер и увидим надпись «Bye Bunny», т.е. горячая перезагрузка сработала, при этом измененное состояние не сбросилось.

Сначала не хотел добавлять работу с CSS, но в процессе написания статьи понял, что все таки для полного комплекта нужно добавить процесс сборки стилей.

Наверно, у каждого был случай, когда правишь верстку в одном месте и незаметно для себя создаешь новую проблему в другом, стили перезатирают друг друга или используется одинаковые классы, которые были описаны выше. Мы будем писать react компоненты, так почему бы нам сразу и не использовать CSS для компонентов, а не глобально? Будем использовать CSS модули! Нам понадобится post-css и его плагины. Для начала нам будут интересны autoprefixer и precss для ускорения разработки, устанавливаем:

npm i --save-dev css-loader style-loader postcss-loader autoprefixer precss

Делаем изменения в конфигах

webpack.base.config.js
import webpack from 'webpack';
import Config from 'webpack-config';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import autoprefixer from 'autoprefixer';
import precss from 'precss';

export default new Config().merge({
  entry: './client/index.js',
  output: {
    path: __dirname + '/../public',
  },
  module: {
    loaders: [
      {
        test: /.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './client/index.html',
      inject: "body"
    }),
    new webpack.LoaderOptionsPlugin({ options: { postcss: [precss, autoprefixer] } })
  ]
});


webpack.development.config.js
import webpack from 'webpack';
import Config from 'webpack-config';

export default new Config().extend('conf/webpack.base.config.js').merge({
  entry: [
    'webpack-hot-middleware/client?reload=true',
    'react-hot-loader/patch',
    __dirname + '/../client/index.js'
  ],
  devtool: 'inline-source-map',
  output: {
    filename: 'bundle.js'
  },
  module: {
    loaders: [{
      test: /\.css$/,
      use: [
        'style-loader',
        {
          loader: 'css-loader',
          options: {
            modules: true,
            importLoaders: 1,
            localIdentName: "[local]__[hash:base64:5]",
            minimize: false
          }
        },
        { loader: 'postcss-loader' },
      ]
    }]
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
});


webpack.production.config.js
import webpack from 'webpack';
import Config from 'webpack-config';

export default new Config().extend('conf/webpack.base.config.js').merge({
  output: {
    filename: 'bundle.min.js'
  },
  devtool: 'source-map',
  module: {
    loaders: [{
      test: /\.css$/,
      use: [
        'style-loader',
        {
          loader: 'css-loader',
          options: {
            modules: true,
            importLoaders: 1,
            localIdentName: "[hash:base64:10]",
            minimize: true
          }
        },
        { loader: 'postcss-loader' },
      ]
    }]
  },
  plugins: [
    new webpack.optimize.UglifyJsPlugin({
      compress: {
        warnings: true
      }
    })]
});


В базовый конфиг добавляем плагин, для остальных добавляем лоадеры, отличаются только настройки. Тут буден интересна localIdentName опция, она позволяет задавать имена CSS классам, для продукционной версии будем использовать хэш из 10 символов, для девелоп — названия классов + хэш из 5 символов. Это очень удобно, т.к при дебаге ты всегда знаешь какой класс тебе нужно поправить. Для примера давайте добавим Menu компонент:

Структура проекта


menu/index.js
import React from 'react';

import styles from './style.css';

const Menu = () => (
  <nav className={styles.menu}>
    <div className={styles['toggle-btn']}>☰</div>
  </nav>
);

export default Menu;


Обратите внимание, как используется css модуль. Это локальные стили, т.е для другого меню, мы так же можем использовать класс .menu с другими стилями, и они не пересекутся.

menu/style.css
.menu {
  position: fixed;
  top: 0;
  left: 0;
  bottom: 0;
  width: 40px;
  background-color: tomato;
  & .toggle-btn {
    position: absolute;
    top: 5px;
    right: 10px;
    font-size: 26px;
    font-weight: 500;
    color: white;
    cursor: pointer;
  }
}


app.js
import React from 'react';

import Menu from '../components/menu';

// Global CSS styles
import './global.css';

const App = () => (
  <div className="app-container">
    <Menu />
    <div className="page-container"></div>
  </div>
);

export default App;


Но мы так же можем использовать и «глобальные» стили, например, для html и body. Достаточно подключить их app.js.

routes.js
import React from 'react';
import {
  Router,
  Route,
  browserHistory
} from 'react-router';

import App from './views/app';
import Home from './views/home';

export default () => (
  <Router history={browserHistory}>
    <Route path='/' component={App}>
      <Route path='home' component={Home} />
    </Route>
  </Router>
);


Добавим немного вложенности, теперь у нас есть App контейнер со вложенной Home страницей.

Настройки webpack-а также позволят использовать горячую перезагрузку для стилей, просто попробуйте сделать некоторые изменения в стилях.

На этом первая часть закончилась. Я не претендую на самый-самый правильный вариант, но я прошёл этот путь и почерпнуть несколько интересных вещей, пока писал эту статью, надеюсь она окажется чем-то полезным и другим.

Ссылка на то, что получилось: github.com/AlexeyRyashencev/react-hot-mobx-es6
Tags:
Hubs:
+19
Comments 18
Comments Comments 18

Articles