Pull to refresh

React.js: собираем с нуля изоморфное / универсальное приложение. Часть 1: собираем стек

Reading time 22 min
Views 195K
image

Лицо моей жены, когда она вычитывала эту статью


Я решил написать цикл статей, который и сам был бы счастлив найти где-то полгода назад. Он будет интересен в первую очередь тем, кто хотел бы начать разрабатывать классные приложения на React.js, но не знает, как подступиться к зоопарку разных технологий и инструментов, которые необходимо знать для полноценной front-end разработки в наши дни.


Я хочу с нуля реализовать, пожалуй, наиболее востребованный сценарий: у нас есть серверная часть, которая предоставляет REST API. Часть его методов требует, чтобы пользователь веб-приложения был авторизован.


Оглавление


1) Собираем базовый стек изоморфного приложения
2) Делаем простое приложение с роутингом и bootstrap
3) Реализуем взаимодействие с API и авторизацию


За скобками останутся такие вопросы как интернационализация, написание тестов, деплой и варианты работы с CSS, так как эти вопросы гораздо более вариативны и каждый из них тянет на отдельный блок статей. Возможно, я вернусь к ним позже, если будет спрос.


Примечание: на github есть множество замечательных boilerplate или уже собранных стеков, которые можно использовать в качестве фундамента для своего приложения, но мне кажется, гораздо правильнее собирать свой стек самостоятельно, понимая, что делает каждый пакет и каждая строка вашего кода. Научившись делать это один раз, в следующий раз сбор стека займет едва ли более 5 минут.


Примечание 2: я предполагаю, что у разных читателей разный уровень подготовки, поэтому длинное описание инструментов я буду прятать под кат, чтобы статья не казалась бесконечно длинной.


Итак, поехали!


1. Мы будем разрабатывать изоморфное веб-приложение.


Изоморфное или универсальное приложение означает, что JavaScript код приложения может быть выполнен как на сервере, так и на клиенте. Этот механизм является одной из сильных сторон React и позволяет пользователю получить доступ к контенту существенно быстрее. Ниже я буду использовать термин "изоморфное", так как он пока еще встречается чаще, но важно понимать, что "изоморфное" и "универсальное" — это одно и то же.


Подробнее о изоморфных приложениях
image

С точки зрения пользователя взаимодействие с веб-приложением выглядит следующим образом


1) Браузер выполняет запрос к нашему веб-приложению.
2) Серверная часть Node.js выполняет JavaScript. Если необходимо, в процессе также выполняет запросы к API. В результате получается готовая HTML-страница, которая отправляется клиенту.
3) Пользователь получает контент страницы почти мгновенно. В это время в фоне скачивается и инициализируется клиентский JavaScript, и приложение "оживает". Самое главное, что пользователь имеет доступ к контенту почти сразу, а не спустя две и более секунды, как это бывает в случае традиционных client-sideJavaScript приложений.
4а) Если
JavaScript не успел загрузиться или выполнился на клиенте с ошибкой, то при переходе по ссылке выполнится обычный запрос к серверу, и мы вернемся на первый шаг процесса.
4б) Если все в порядке, то переход по ссылке будет перехвачен нашим приложением. Если необходимо, выполнится запрос к
API и клиентский JavaScript* сформирует и отрендерит запрошенную страницу. Такой подход уменьшает трафик и делает приложение более производительным.


Почему это круто?
1) Пользователь получает контент быстрее на две и более секунды. Особенно это актуально, если у вас не очень хороший мобильный интернет или вы в условном Китае. Выигрыш получается за счет того, что не надо дожидаться скачивания клиентского JavaScript, а это 200кб и более с учетом минификации и сжатия. Также инициализация JavaScript может занимать определенное время. Если сюда добавить необходимость делать клиентские API запросы после инициализации и вспомнить, что на мобильном интернете часто можно столкнуться с весьма ощутимыми задержками, то становится очевидно, что изоморфный подход делает ваше приложение гораздо приятнее для пользователя.
2) Если ваше клиентское JavaScript приложение перестало работать из-за ошибки, то ваш сайт скорее всего станет бесполезным для пользователя. В изоморфном же случае есть хороший шанс, что пользователь все же сможет сделать то, что он хочет.


С точки зрения реализации


У нас есть две точки входа: server.js и client.js.
Server.js будет использован сервером node. В нем мы запустим express или другой веб-сервер, в него же поместим обработку запросов и другую специфичную для сервера бизнес-логику.
Client.js — точка входа для браузера. Сюда мы поместим бизнес-логику, специфичную для клиента.
React-приложение будет общим как для клиента, так и для сервера. Оно составляет более 90-95% исходного кода всего приложения — в этом и заключается вся суть изоморфного / универсального подхода. В процессе реализации мы увидим, как это работает на практике.


Создаем новый проект


Установка Node.js и менеджера пакетов npm

На первый взгляд версионность ноды может показаться немного странной. Чтобы не запутаться, достаточно знать, что v4.x — это LTS ветка, v5.x — экспериментальная, а v6.x — будущая LTS, начиная с 1 октября 2016 года. Я рекомендую устанавливать последнюю LTS версию, то есть на день публикации статьи — это 4ая, так как это убережет от хоть и маловероятного, но крайне неприятного столкновения с багами самой платформы. Для наших целей особой разницы между ними все равно нет.


Перейдя по ссылке https://nodejs.org/en/download/ можно скачать и установить node.js и пакетный менеджер npm для вашей платформы.


mkdir habr-app && cd habr-app
npm init

На все вопросы npm можно смело нажимать кнопку enter, чтобы выбирать значения по умолчанию. В результате в корневой директории проекта появится файл package.json.


2. Процесс разработки


JavaScript в последние годы развивается семимильными шагами, что нельзя сказать о пользователях. К сожалению, мы не можем поддерживать только последние версии браузеров, так как мало кто среди заказчиков готов пожертвовать существенной частью аудитории в угоду "классным техническим фичам". К счастью, придуманы инструменты, которые позволяют программисту использовать наиболее современные конструкции языка и писать так, как ему удобно, а в результате будет получаться код, который будет работать правильно даже в очень древних браузерах.


Babel


Babel — это компилятор, который транслирует любой диалект JavaScript, включая CoffeeScript, TypeScript и другие надстройки над языком в JavaScript ES5, который поддерживается почти всеми браузерами, включая IE8, если добавить babel-polyfill. Сила Babel в его модульности и расширяемости за счет плагинов. Например, уже сейчас можно использовать самые последние фишки JavaScript, не переживая, что они не будут работать в старых браузерах.


Для трансляции компонентов реакта мы будем использовать пресет babel-preset-react. Мне очень нравятся декораторы JavaScript, поэтому нам также понадобится пакет babel-plugin-transform-decorators-legacy. Чтобы наш код корректно работал в старых браузерах, мы установим пакет babel-polyfill, а babel-preset-es2015 и babel-preset-stage-0 нам нужны, чтобы писать на ES6/ES7 диалектах соответственно.


npm i --save babel-core babel-plugin-transform-decorators-legacy babel-polyfill babel-preset-es2015 babel-preset-react babel-preset-stage-0

Эти зависимости надо устанавливать как зависимости проекта, так как серверной части приложения тоже нужен babel.


Теперь установим пакеты, которые нужны для сборки клиентского JavaScript. Их мы установим в качестве зависимостей разработки, так как в продакшене они не нужны.


.babelrc


При запуске babel будет обращаться к файлу .babelrc в корне проекта, в котором хранится конфигурация и список используемых preset'ов и плагинов.


Создадим этот файл


{
  "presets": [
    "es2015",
    "react",
    "stage-0"
  ],
  "plugins": [
    "transform-decorators-legacy"
  ]
}

3. Сборка


Мы установили Babel с нужными нам плагинами, но кто и когда должен его запускать? Настало время перейти к этому вопросу.


Примечание: проекты веб-приложений в наши дни с виду мало отличаются от проектов десктопных или мобильных приложений: они будут содержать внешние библиотеки, файлы, скорее всего соответствующие парадигме MVC, ресурсы, файлы стилей и многое другое. Такое представление будет очень удобно для программиста, но не для пользователя. Если взять весь исходный код JavaScript проекта, а также используемых библиотек, выкинуть все лишнее, объединить в один большой файл и применить минификацию, то полученный на выходе один файл может занимать в 10 и более раз меньше, чем изначальный набор. Также потребуется всего лишь один, а не сотни, запрос браузера, чтобы скачать всю логику нашего приложения. И то, и другое очень важно для производительности. К слову, та же логика применима и для CSS-ресурсов, включая разные диалекты (LESS, SASS и пр.).


Эту полезную работу будет выполнять webpack.


Примечание: для этой же цели можно применять сборщики: grunt, gulp, bower, browserify и другие, но исторически для React чаще всего используется именно webpack.


Подробнее о webpack и webpack-dev-server

webpack


image

Алгоритм работы webpack


Проще всего представить работу webpack как конвейер. Webpack возьмет предоставленные точки входа и последовательно обойдет все зависимости, которые встретит на своем пути. Весь код, написанный на JavaScript или его диалектах, он пропустит через babel и слепит в один большой JavaScript ES5 файл. Здесь стоит более подробно остановиться на том, как это работает. Каждый require или import в вашем коде и коде используемых node_modules webpack выделит в свой отдельный небольшой модуль в итоговой сборке. Если ваш код или код библиотек, которые вы используете, зависят от одной и той же функции, то в итоговую сборку она попадет только один раз в виде модуля Webpack, а все куски кода, которые от него зависят, будут ссылаться на один и тот же модуль в итоговой сборке. Еще одна крутая особенность процесса сборки webpack заключается в том, что если вы используете огромную библиотеку, например lodash, но явно указываете, что вам нужна только определенная функция, например


import assign from 'lodash/assign';

то в итоговую сборку войдет лишь используемая часть библиотеки, а не вся она целиком, что может существенно уменьшить размер собранного файла.


Примечание: это будет работать, только если используемая библиотека поддерживает модульность. По этой причине автор отказался от использования в своих проектах библиотек Moment.js, XRegExp и ряда других.


Для разного типа файлов нашего проекта в конфигурации webpack мы определим свой loader или цепочку loader'ов, которые будут его обрабатывать.


webpack-dev-server


Каждый раз пересобирать весь проект может быть весьма накладно: для проекта среднего размера сборка легко может достигать 30 и более секунд. Чтобы решить эту проблему, во время разработки очень удобно использовать webpack-dev-server. Это стороннее серверное приложение, которое при запуске произведет полную сборку ресурсов и при обращении к ним будет отдавать последнюю их версию из оперативной памяти. В процессе разработки при изменении отдельных файлов webpack-dev-server оно на лету будет перекомпилировать только тот файл, который изменился, и подменять старый модуль на новый в итоговой сборке. Так как пересобирать требуется не весь проект, а только один файл, то это редко занимает более секунды.


Webpack и webpack-dev-server мы установим в качестве зависимостей разработки, так как мы, разумеется, не будем заниматься сборкой на продакшене.


npm i --save-dev webpack@1.13.2 webpack-dev-server

Примечание: на момент написания и публикации статьи актуальна была версия webpack 1. С 22 сентября 2016 года по умолчанию устанавливается webpack 2 beta.


Хорошо, теперь нам необходимо написать файл конфигурации для сборки. Создаем файл в корне проекта


webpack.config.js


global.Promise         = require('bluebird');

var webpack            = require('webpack');
var path               = require('path');
var ExtractTextPlugin  = require('extract-text-webpack-plugin');
var CleanWebpackPlugin = require('clean-webpack-plugin');

var publicPath         = 'http://localhost:8050/public/assets';
var cssName            = process.env.NODE_ENV === 'production' ? 'styles-[hash].css' : 'styles.css';
var jsName             = process.env.NODE_ENV === 'production' ? 'bundle-[hash].js' : 'bundle.js';

var plugins = [
  new webpack.DefinePlugin({
    'process.env': {
      BROWSER:  JSON.stringify(true),
      NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'development')
    }
  }),
  new ExtractTextPlugin(cssName)
];

if (process.env.NODE_ENV === 'production') {
  plugins.push(
    new CleanWebpackPlugin([ 'public/assets/' ], {
      root: __dirname,
      verbose: true,
      dry: false
    })
  );
  plugins.push(new webpack.optimize.DedupePlugin());
  plugins.push(new webpack.optimize.OccurenceOrderPlugin());
}

module.exports = {
  entry: ['babel-polyfill', './src/client.js'],
  debug: process.env.NODE_ENV !== 'production',
  resolve: {
    root:               path.join(__dirname, 'src'),
    modulesDirectories: ['node_modules'],
    extensions:         ['', '.js', '.jsx']
  },
  plugins,
  output: {
    path: `${__dirname}/public/assets/`,
    filename: jsName,
    publicPath
  },
  module: {
    loaders: [
      {
        test: /\.css$/,
        loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss-loader')
      },
      {
        test: /\.less$/,
        loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss-loader!less-loader')
      },
      { test: /\.gif$/, loader: 'url-loader?limit=10000&mimetype=image/gif' },
      { test: /\.jpg$/, loader: 'url-loader?limit=10000&mimetype=image/jpg' },
      { test: /\.png$/, loader: 'url-loader?limit=10000&mimetype=image/png' },
      { test: /\.svg/, loader: 'url-loader?limit=26000&mimetype=image/svg+xml' },
      { test: /\.(woff|woff2|ttf|eot)/, loader: 'url-loader?limit=1' },
      { test: /\.jsx?$/, loader: 'babel', exclude: [/node_modules/, /public/] },
      { test: /\.json$/, loader: 'json-loader' },
    ]
  },
  devtool: process.env.NODE_ENV !== 'production' ? 'source-map' : null,
  devServer: {
    headers: { 'Access-Control-Allow-Origin': '*' }
  }
};

Примечание: это пример конфига для продуктивного проекта, поэтому он выглядит немного сложнее, чем мог бы.


Описание конфига

Итак,
1) Мы объявляем, что реализацию промисов мы будем использовать из проекта bluebird. Де-факто стандарт.
2) Для продакшена мы хотим, чтобы у каждого файла был хеш сборки, чтобы эффективно управлять кешированием ресурсов. Чтобы старые версии собранных ресурсов нам не мешали, мы будем использовать clean-webpack-plugin, который будет очищать соответствующие директории до осуществления очередной сборки.
3) extract-text-webpack-plugin в процессе сборки будет выискивать все css/less/sass/whatever зависимости и в конце оформит их в виде одного CSS файла.
4) Мы используем DefinePlugin, чтобы задать глобальные переменные сборки, DedupePlugin и OccurenceOrderPlugin — оптимизационные плагины. Более подробно с этими плагинами можно ознакомиться в документации.
5) В качестве входной точки мы укажем babel-polyfill и client.js. Первый позволит нашему JavaScript коду выполняться в старых браузерах, второй — наша точки входа клиентского веб-приложения, мы напишем его позже.
6) resolve означает, что когда мы пишем в коде нашего приложения


import SomeClass from './SomeClass';

webpack будет искать SomeClass в файлах SomeClass.js или SomeClass.jsx прежде, чем сообщит, что не может найти указанный файл.
7) Далее мы передаем список плагинов и указываем output — директорию, в которую webpack положит файлы после сборки.
8) Самое интересное — список loaders. Здесь мы определяем конвейеры, о которых говорилось выше. Они будут применены для файлов с соответствующими расширениями. Более подробно с форматом определения лоадеров и их параметрами лучше ознакомиться в документации, так как этот вопрос тянет на отдельную статью. Чтобы не быть уж совсем голословным, остановлюсь на конструкции лоадера.


test: /\.less$/, loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss-loader!less-loader')

Здесь мы говорим, что если встретится файл с расширением less, то его нужно передать ExtractTextPlugin, который будет использовать цепочку css-loader!postcss-loader!less-loader. Читать следует справа налево, то есть сначала less-loader обработает .less файл, результат передаст postcss-loader, который в свою очередь передаст обработанное содержимое css-loader.
9) О devtool также рекомендую прочитать в документации вебпака.
10) В конце мы дополнительно укажем параметры webpack-dev-server. В данном случае нам важно указать Access-Control-Allow-Origin, так как webpack-dev-server и наше приложение будут работать на разных портах, а значит нужно решить проблему CORS.


Библиотеки, на которые мы ссылаемся в конфигурации, сами себя не установят. В зависимости проекта у нас пойдет только bluebird, все остальное нужно исключительно для сборки.


npm i --save bluebird 
npm i --save-dev babel-loader clean-webpack-plugin css-loader extract-text-webpack-plugin file-loader html-loader json-loader less less-loader postcss-loader style-loader url-loader

Также настало время добавить несколько новых скриптов package.json: для запуска сборки и вебпак-дев-сервера.


  "scripts": {
    "build": "NODE_ENV='production' webpack -p",
    "webpack-devserver": "webpack-dev-server --debug --hot --devtool eval-source-map --output-pathinfo --watch --colors --inline --content-base public --port 8050 --host 0.0.0.0"
  }

Update: пользователи wrewolf и Nerop в личке и комментариях соответственно сообщили, что в Windows скрипты должны выглядеть иначе.


  "scripts": {
    "build": "set NODE_ENV='production' && webpack -p",
    "webpack-devserver": "webpack-dev-server --debug --hot --devtool eval-source-map --output-pathinfo --watch --colors --inline --content-base public --port 8050 --host 0.0.0.0"
  }

Создадим в корне проекта папку src, а в ней — пустой файл client.js.


Протестируем наши скрипты: введем в консоли npm run build, а в другом окне консоли — npm run webpack-devserver. Если нет ошибок — двигаемся дальше.


4. ESLint


Это необязательный пункт, но я нахожу его очень полезным. ESLint — это совокупность правил, которые предъявляются к исходному коду. Если одно или несколько из этих правил нарушаются программистом в процессе написания кода, во время сборки webpack'ом мы увидим ошибки. Таким образом весь код веб-приложения будет написан в едином стиле, включая именование переменных, отступы, запрет использования определенных конструкций и так далее.


Список правил я положу в файл .eslintrc в корне проекта. Более подробно о ESLint и правилах можно прочитать на сайте проекта.


.eslintrc
{
    "parser": "babel-eslint",

    "plugins": [
        "react"
    ],

    "env": {
        "browser": true,
        "node": true,
        "mocha": true,
        "es6": true
    },

    "ecmaFeatures": {
        "arrowFunctions": true,
        "blockBindings": true,
        "classes": true,
        "defaultParams": true,
        "destructuring": true,
        "forOf": true,
        "generators": false,
        "modules": true,
        "objectLiteralComputedProperties": true,
        "objectLiteralDuplicateProperties": false,
        "objectLiteralShorthandMethods": true,
        "objectLiteralShorthandProperties": true,
        "restParams": true,
        "spread": true,
        "superInFunctions": true,
        "templateStrings": true,
        "jsx": true
    },

    "rules":{
        // Possible errors
        "comma-dangle": [2, "never"],
        "no-cond-assign": [2, "always"],
        "no-constant-condition": 2,
        "no-control-regex": 2,
        "no-dupe-args": 2,
        "no-dupe-keys": 2,
        "no-duplicate-case": 2,
        "no-empty-character-class": 2,
        "no-empty": 2,
        "no-extra-boolean-cast": 0,
        "no-extra-parens": [2, "functions"],
        "no-extra-semi": 2,
        "no-func-assign": 2,
        "no-inner-declarations": 2,
        "no-invalid-regexp": 2,
        "no-irregular-whitespace": 2,
        "no-negated-in-lhs": 2,
        "no-obj-calls": 2,
        "no-regex-spaces": 2,
        "no-sparse-arrays": 2,
        "no-unreachable": 2,
        "use-isnan": 2,
        "valid-typeof": 2,
        "no-unexpected-multiline": 0,

        // Best Practices
        "block-scoped-var": 2,
        "complexity": [2, 40],
        "curly": [2, "multi-line"],
        "default-case": 2,
        "dot-notation": [2, { "allowKeywords": true }],
        "eqeqeq": 2,
        "guard-for-in": 2,
        "no-alert": 1,
        "no-caller": 2,
        "no-case-declarations": 2,
        "no-div-regex": 0,
        "no-else-return": 2,
        "no-eq-null": 2,
        "no-eval": 2,
        "no-extend-native": 2,
        "no-extra-bind": 2,
        "no-fallthrough": 2,
        "no-floating-decimal": 2,
        "no-implied-eval": 2,
        "no-iterator": 2,
        "no-labels": 2,
        "no-lone-blocks": 2,
        "no-loop-func": 2,
        "no-multi-str": 2,
        "no-native-reassign": 2,
        "no-new": 2,
        "no-new-func": 2,
        "no-new-wrappers": 2,
        "no-octal": 2,
        "no-octal-escape": 2,
        "no-param-reassign": [2, { "props": true }],
        "no-proto": 2,
        "no-redeclare": 2,
        "no-script-url": 2,
        "no-self-compare": 2,
        "no-sequences": 2,
        "no-unused-expressions": 2,
        "no-useless-call": 2,
        "no-with": 2,
        "radix": 2,
        "wrap-iife": [2, "outside"],
        "yoda": 2,

        // ES2015
        "arrow-parens": 0,
        "arrow-spacing": [2, { "before": true, "after": true }],
        "constructor-super": 2,
        "no-class-assign": 2,
        "no-const-assign": 2,
        "no-this-before-super": 0,
        "no-var": 2,
        "object-shorthand": [2, "always"],
        "prefer-arrow-callback": 2,
        "prefer-const": 2,
        "prefer-spread": 2,
        "prefer-template": 2,

        // Strict Mode
        "strict": [2, "never"],

        // Variables
        "no-catch-shadow": 2,
        "no-delete-var": 2,
        "no-label-var": 2,
        "no-shadow-restricted-names": 2,
        "no-shadow": 2,
        "no-undef-init": 2,
        "no-undef": 2,
        "no-unused-vars": 2,

        // Node.js
        "callback-return": 2,
        "no-mixed-requires": 2,
        "no-path-concat": 2,
        "no-sync": 2,
        "handle-callback-err": 1,
        "no-new-require": 2,

        // Stylistic
        "array-bracket-spacing": [2, "never", {
            "singleValue": true,
            "objectsInArrays": true,
            "arraysInArrays": true
        }],
        "newline-after-var": [1, "always"],
        "brace-style": [2, "1tbs"],
        "camelcase": [2, { "properties": "always" }],
        "comma-spacing": [2, { "before": false, "after": true }],
        "comma-style": [2, "last"],
        "computed-property-spacing": [2, "never"],
        "eol-last": 2,
        "func-names": 1,
        "func-style": [2, "declaration"],
        "indent": [2, 2, { "SwitchCase": 1 }],
        "jsx-quotes": [2, "prefer-single"],
        "linebreak-style": [2, "unix"],
        "max-len": [2, 128, 4, {
            "ignoreUrls": true,
            "ignoreComments": false,
            "ignorePattern": "^\\s*(const|let|var)\\s+\\w+\\s+\\=\\s+\\/.*\\/(|i|g|m|ig|im|gm|igm);?$"
        }],
        "max-nested-callbacks": [2, 4],
        "new-parens": 2,
        "no-array-constructor": 2,
        "no-lonely-if": 2,
        "no-mixed-spaces-and-tabs": 2,
        "no-multiple-empty-lines": [2, { "max": 2, "maxEOF": 1 }],
        "no-nested-ternary": 2,
        "no-new-object": 2,
        "no-spaced-func": 2,
        "no-trailing-spaces": 2,
        "no-unneeded-ternary": 2,
        "object-curly-spacing": [2, "always"],
        "one-var": [2, "never"],
        "padded-blocks": [2, "never"],
        "quotes": [1, "single", "avoid-escape"],
        "semi-spacing": [2, { "before": false, "after": true }],
        "semi": [2, "always"],
        "keyword-spacing": 2,
        "space-before-blocks": 2,
        "space-before-function-paren": [2, { "anonymous": "always", "named": "never" }],
        "space-in-parens": [2, "never"],
        "space-infix-ops": 2,
        "space-unary-ops": [2, { "words": true, "nonwords": false }],
        "spaced-comment": [2, "always", {
            "exceptions": ["-", "+"],
            "markers": ["=", "!"]
        }],

        // React
        "react/jsx-boolean-value": 2,
        "react/jsx-closing-bracket-location": 2,
        "react/jsx-curly-spacing":  [2, "never"],
        "react/jsx-handler-names": 2,
        "react/jsx-indent-props": [2, 2],
        "react/jsx-indent": [2, 2],
        "react/jsx-key": 2,
        "react/jsx-max-props-per-line": [2, {maximum: 3}],
        "react/jsx-no-bind": [2, {
            "ignoreRefs": true,
            "allowBind": true,
            "allowArrowFunctions": true
        }],
        "react/jsx-no-duplicate-props": 2,
        "react/jsx-no-undef": 2,
        "react/jsx-pascal-case": 2,
        "react/jsx-uses-react": 2,
        "react/jsx-uses-vars": 2,
        "react/no-danger": 2,
        "react/no-deprecated": 2,
        "react/no-did-mount-set-state": 0,
        "react/no-did-update-set-state": 0,
        "react/no-direct-mutation-state": 2,
        "react/no-is-mounted": 2,
        "react/no-multi-comp": 2,
        "react/no-string-refs": 2,
        "react/no-unknown-property": 2,
        "react/prefer-es6-class": 2,
        "react/prop-types": 2,
        "react/react-in-jsx-scope": 2,
        "react/self-closing-comp": 2,
        "react/sort-comp": [2, {
            "order": [
                "lifecycle",
                "/^handle.+$/",
                "/^(get|set)(?!(InitialState$|DefaultProps$|ChildContext$)).+$/",
                "everything-else",
                "/^render.+$/",
                "render"
            ]
        }],
        "react/jsx-wrap-multilines": 2,

        // Legacy
        "max-depth": [0, 4],
        "max-params": [2, 4],
        "no-bitwise": 2
    },

     "globals":{
        "$": true,
        "ga": true
    }
}

Примечание: в Windows правило


"linebreak-style": [2, "unix"],

надо заменить на


"linebreak-style": [2, "windows"],

npm i --save-dev babel-eslint eslint eslint-loader eslint-plugin-react

Мы добавим eslint-loader в конфигурацию webpack, таким образом перед тем, как babel транслирует наш код в ES5, весь наш код будет проверен на соответствие заданным правилам.


webpack.config.js


В module.exports.module.loaders:


---   { test: /\.jsx?$/, loader: 'babel', exclude: [/node_modules/, /public/] }, 
+++   { test: /\.jsx?$/, loader: 'babel!eslint-loader', exclude: [/node_modules/, /public/] }, 

В module.exports:


+++   eslint: { configFile: '.eslintrc' },

5. Express и Server.js


К текущему моменту мы настроили сборку проекта, трансляцию кода в JS ES5, описали и реализовали проверку исходного кода "на вшивость". Пришло время перейти к написанию самого приложения, а точнее — его серверной части.


Примечание: я использую Express, и меня он полностью устраивает, но, разумеется, есть множество других аналогичных пакетов (это же Node.js).


Устанавливаем express


npm i --save express

Создаем в корне файл server.js со следующим содержимым


server.js


require('babel-core/register');
['.css', '.less', '.sass', '.ttf', '.woff', '.woff2'].forEach((ext) => require.extensions[ext] = () => {});
require('babel-polyfill');
require('server.js');

Здесь мы указываем, что нам нужен babel для поддержки ES6/ES7, а также что если node встретит конструкции вида


import 'awesome.css';

то эту строчку нужно просто проигнорировать, так как это не JavaScript или один из его диалектов.


Сам код серверной части будет в файле src/server.js, в котором мы теперь можем свободно использовать ES6/ES7 синтаксис.


src/server.js


import express  from 'express';

const app = express();

app.use((req, res) => {
  res.end('<p>Hello World!</p>');
});

const PORT = process.env.PORT || 3001;

app.listen(PORT, () => {
  console.log(`Server listening on: ${PORT}`);
});

Здесь все достаточно просто: мы импортируем веб-сервер express, запускаем его на порту, который был передан в переменной окружения PORT или 3001. Сам сервер на любой запрос будет отдавать ответ: "Hello World"


Мы установим пакет nodemon в зависимости разработки, чтобы запустить серверный JavaScript код. Он удобен тем, что выводит любые ошибки с подробными Stack Traces сразу в консоль по мере их возникновения.


npm i --save-dev nodemon

Добавим еще один скрипт в package.json


+++ "nodemon": "NODE_PATH=./src nodemon server.js",

Для Windows:


+++ "nodemon": "set NODE_PATH=./src; && nodemon server.js",

И запустим в консоли


npm run nodemon

Откроем браузер и попробуем открыть страницу http://localhost:3001. Если все хорошо, то мы увидим Hello World.


6. React и ReactDOM


Поздравляю! Почти все приготовления завершены, и мы можем наконец-то переходить к реакту.


Установим соответствующие библиотеки:


npm i --save react react-dom

Также установим react-hot-loader: при изменении исходного кода компонентов в процессе разработки, браузер будет перезагружать страницу автоматически. Это очень удобная фишка, особенно если у вас несколько мониторов.


npm i --save-dev react-hot-loader@1.3.0

Примечание: за время пребывания статьи в песочнице в npm изменилась версия пакета react-hot-loader с 1.3.x на 3.x.x-beta. На текущий момент третья версия плохо документирована, поэтому мы здесь и далее будем использовать первую.


webpack.config.js


---     { test: /\.jsx?$/, loader: 'babel!eslint-loader', exclude: [/node_modules/, /public/] }, 
+++  { test: /\.jsx?$/, loader: process.env.NODE_ENV !== 'production' ? 'react-hot!babel!eslint-loader' : 'babel', exclude: [/node_modules/, /public/] },

Теперь перейдем к написанию кода нашего первого компонента App.jsx, — точки входа в изоморфную часть нашего веб-приложения.


src/components/App.jsx


import React, { PropTypes, Component } from 'react';

import './App.css';

const propTypes = {
  initialName: PropTypes.string
};

const defaultProps = {
  initialName: 'Аноним'
};

class App extends Component {
  constructor(props) {
    super(props);

    this.handleNameChange = this.handleNameChange.bind(this);
    this.renderGreetingWidget = this.renderGreetingWidget.bind(this);

    this.state = {
      name:            this.props.initialName,
      touched:         false,
      greetingWidget:  () => null
    };
  }

  handleNameChange(val) {
    const name = val.target.value;

    this.setState({ touched: true });

    if (name.length === 0) {
      this.setState({ name: this.props.initialName });
    } else {
      this.setState({ name });
    }
  }

  renderGreetingWidget() {
    if (!this.state.touched) {
      return null;
    }

    return (
      <div>
        <hr />
        <p>Здравствуйте, {this.state.name}!</p>
      </div>
    );
  }

  render() {
    return (
      <div className='App'>
        <h1>Hello World!</h1>
        <div>
          <p>Введите Ваше имя:</p>
          <div><input onChange={this.handleNameChange} /></div>
          {this.renderGreetingWidget()}
        </div>
      </div>
    );
  }
}

App.propTypes = propTypes;
App.defaultProps = defaultProps;

export default App;

src/components/App.css


.App {
  padding: 20px;
}

.App h1 {
  font-size: 26px;
}

.App input {
  padding: 10px;
}

.App hr {
  margin-top: 20px;
}

Здесь все достаточно просто: просим пользователя ввести свое имя и здороваемся с ним.


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


src/client.js


import React      from 'react';
import ReactDOM   from 'react-dom';
import App        from 'components/App';

ReactDOM.render(<App />, document.getElementById('react-view'));

После инициализации JavaScript реакт найдет основной контейнер приложения react-view и "оживит" его.


src/server.js


import express  from 'express';
import React    from 'react';
import ReactDom from 'react-dom/server';
import App      from 'components/App';

const app = express();

app.use((req, res) => {
  const componentHTML = ReactDom.renderToString(<App />);

  return res.end(renderHTML(componentHTML));
});

const assetUrl = process.env.NODE_ENV !== 'production' ? 'http://localhost:8050' : '/';

function renderHTML(componentHTML) {
  return `
    <!DOCTYPE html>
      <html>
      <head>
          <meta charset="utf-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>Hello React</title>
          <link rel="stylesheet" href="${assetUrl}/public/assets/styles.css">
      </head>
      <body>
        <div id="react-view">${componentHTML}</div>
        <script type="application/javascript" src="${assetUrl}/public/assets/bundle.js"></script>
      </body>
    </html>
  `;
}

const PORT = process.env.PORT || 3001;

app.listen(PORT, () => {
  console.log(`Server listening on: ${PORT}`);
});

Серверный код изменился сильнее: результатом выполнения JavaScript функции RenderDom.renderToString(<App />) будет HTML-код, который мы вставим в шаблон, формируемый функцией renderHTML. Обратите внимание на константу assetUrl: для ландшафта разработки приложение будет запрашивать ресурсы, обращаясь к серверу webpack-dev-server.


Чтобы наше приложение заработало, необходимо запустить одновременно в двух табах консоли следующие команды:


npm run nodemon
npm run webpack-devserver

Теперь откроем ссылку в браузере: http://localhost:3001 и… наше первое приложение наконец-то готово!


Убедимся, что оно изоморфно.


1) Сначала проверим работу server-side rendering. Для этого остановим webpack-dev-server и перезагрузим страницу в браузере. Наше приложение загрузилось без стилей и ничего не происходит при вводе данных в форму, но сам интерфейс приложения отрендерился сервером, как мы и ожидали.


2) Теперь проверим работу client-side rendering. Для этого внесем изменения в файл src/server.js, убрав код, который рендерит компонент на сервере.


---         <div id="react-view">${componentHTML}</div>
+++         <div id="react-view"></div>

Еще раз обновим страницу с нашим приложением в браузере. Оно снова отрендерилось, хоть и с едва заметной задержкой. Все работает!


Примечание: если этого не случилось, убедитесь, что вы не забыли запустить npm run webpack-devserver, который был остановлен на первом шаге.


GitHub


Репозиторий проекта: https://github.com/yury-dymov/habr-app
https://github.com/yury-dymov/habr-app/tree/v1 — ветка v1 соответствует приложению первой статьи
https://github.com/yury-dymov/habr-app/tree/v2 — ветка v2 соответствует приложению второй статьи
ветка v3 соответствует приложению третьей статьи [To be done]


Что дальше?


Не слишком ли сложно для простого Hello World? Что ж, мы долго запрягали, но зато дальше быстро поедем!
В следующей части мы добавим react-bootstrap, роутинг, несколько страниц, а также узнаем, что такое концепция flux и почему все так любят redux.


7. Полезные ресурсы и материалы


  1. Основы JavaScript ES2015 — https://learn.javascript.ru/es-modern
  2. Документация webpack — http://webpack.github.io/docs/
  3. Шикарный скринкаст по изучению webpack на русском языке — https://learn.javascript.ru/screencast/webpack
  4. Документация Babel — https://babeljs.io/
  5. Документация ESLint — http://eslint.org/
  6. Документация Express — https://expressjs.com/
  7. Документация Express на русском — http://jsman.ru/express/
  8. Документация React — https://facebook.github.io/react/

P.s. Если в тексте присутствуют ошибки или неточности, пожалуйста, напишите мне сначала в личные сообщения. Заранее спасибо!

Tags:
Hubs:
+41
Comments 78
Comments Comments 78

Articles