Упрощаем универсальное/изоморфное приложение на React + Router + Redux + Express

    На Хабре уже было предостаточно статей про то, как делать универсальное (изоморфное) приложение на стеке React + Redux + Router + Koa/Express (Google в помощь), однако я заметил, что все они содержат повторяющийся код для серверного рендеринга. Я решил упростить задачу и выделить этот общий код в библиотеку, так и появился на свет Create React Server, работает примерно так:


    import Express from "express";
    import config from "./webpack.config";
    import createRouter from "./src/createRouter";
    import createStore from "./src/createStore";
    import {createExpressServer} from "create-react-server";
    
    createExpressServer({
      createRouter: (history) => (createRouter(history)),
      createStore: ({req, res}) => (createStore()),
      port: 3000
    }));

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


    Суть серверного рендеринга довольно проста: на сервере нам нужно определить на основе правил роутера, какой компонент будет показан на странице, выяснить, какие данные ему нужны для работы, запросить эти данные, отрендерить HTML, и выслать этот HTML вместе с данными на клиент. Если мы хотим быть совсем крутыми, можно еще пробежаться по дереву компонентов и для всех них загрузить данные (а не только для контентной области), но это выходит за рамки статьи, хотя и запланировано для имплементации в библиотеке.


    Если вам лень во всем этом разбираться — присмотритесь к другим кандидатам, например Next.JS и Electrode из моей обзорной статьи Что взять за основу React приложения.


    Клиент


    Подготовительные этапы сводятся к четырем вещам:


    1. Очистить код от всего браузерно-специфичного добра типа window, DOM манипуляций, прямых обращений к location, history, document и т.д., на сервере ничего из этого нет. Да и вообще это плохая практика.
    2. Следующий шаг — осознать, что каждый раз при выполнении кода нужно иметь свежий контекст. В противном случае запросы от разных клиентов будут перекрываться. Крайне желательно данные хранить либо локально, либо в Redux Store, но никак не в общем коде, там — только статичные вещи, не меняющиеся от запроса к запросу.
    3. Крайне желательно проанализировать код на предмет утечек памяти, на сервере это быстро станет критичным.
    4. Проверить и убедиться, что все используемые библиотеки умеют работать из-под сервера.

    Router


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


    import React from "react";
    import {IndexRoute, Route} from "react-router";
    import NotFound from './NotFound';
    
    function def(promise) {
        return promise.then(cmp => cmp.default);
    }
    export default function() {
        return <Route path="/">
            <IndexRoute getComponent={() => def(import('./App'))}/>
            <Route path='*' component={NotFound}/>
        </Router>;
    }

    Redux Store


    Многие экспортируют инстанс Redux Store таким образом, что он становится синглтоном, и даже обращаются к нему не из-под React компонентов, на сервере так делать нельзя. Каждый запрос должен иметь свой собственный Store, поэтому теперь будем экспортировать функцию, которая при каждом вызове создает его на основе переданного начального состояния:


    import {createStore} from "redux";
    import reducers from "./reducers";
    
    export default function configureStore(initialState) {
        return createStore(
            reducers,
            initialState
        );
    }

    Страница (конечная точка)


    Роутер позволяет серверу найти нужную страницу, а сама страница должна дать знать серверу, какие данные ей нужны. Для простоты воспользуемся соглашением, принятым во фреймворке NextJS: статичный метод getInitialProps. В этом методе мы должны сделать dispatch экшнов, которые приведут store в нужное состояние и затем вернуть управление наружу.


    import {withWrapper} from "create-react-server/wrapper";
    
    @connect(state => ({foo: state.foo}))
    @withWrapper()
    export default class Page extends React.Component {
      async getInitialProps({store, history, location, params, query, req, res}) {
        await store.dispatch({type: 'FOO', payload: 'foo'});
      }
      render() {
        return (
          <div>
            <div>{this.props.foo}</div>
          </div>
        )
      }
    }

    Вместо async/await можно просто вернуть Promise или конкретное значение. Вместо аннотации можно использовать так — export default connect(...)(Page).


    Для того, чтобы сервер понял, что страница является заглушкой для 404, нужно ее пометить статичным свойством notFound:


    import {withWrapper} from "create-react-server/wrapper";
    
    @withWrapper
    export default class 404Page extends React.Component {
      static notFound = true;  
      render() {
        return (
          <h1>Not Found</h1>
        )
      }
    }

    Инициализация приложения


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


    import React from "react";
    import {render} from "react-dom";
    import {Provider} from "react-redux";
    import {browserHistory, match, Router} from "react-router";
    import createRoutes from "./routes";
    import createStore from "./reduxStore";
    import {WrapperProvider} from "create-react-server/wrapper";
    
    const mountNode = document.getElementById('app');
    const store = createStore(window.__INITIAL__STATE__); // обращаем внимание на название свойства
    
    function renderRouter(routes, store, mountNode) {
      match({history: browserHistory, routes}, (error, redirect, props) => {
        render((
          <Provider store={store}>
            <WrapperProvider initialProps={window.__INITIAL__PROPS__}>
              <Router {...props}>{routes}</Router>
            </WrapperProvider>
          </Provider>
        ), mountNode);
      });
    }
    
    renderRouter(createRoutes(), store, mountNode);

    Шаблон HTML


    В примере мы используем плагин HtmlWebpackPlugin для удобства и автоматизации. Так делать не обязательно, но index.html (или другой файл, как настроите) обязан участвовать в сборке Webpack (т.е. попасть в output path).


    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8">
      <title>App</title>
    <body>
      <div id="app"></div>
    </body>
    </html>

    Сервер


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


    1. сервер пытается найти статичный файл, если у него это не выходит, сервер через роутер пытается определить конечную страницу, если и это не получается, то роутер отдаст NotFound заглушку
    2. создает новый Redux Store
    3. вызывает getInitialProps найденной страницы, забрасывая туда свежесозданный Store
    4. ждет пока закончится вся асинхронная активность
    5. рендерит приложение в HTML строку
    6. сериализует состояние Store и внедряет его и HTML в шаблон (попутно ждет, когда шаблон станет доступен, в dev-режиме он генерируется плагином)
    7. посылает все клиенту

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


    Подготовка


    npm install babel-cli express webpack webpack-dev-server html-webpack-plugin --save-dev

    Для корректной работы babel-cli нужно либо создать .babelrc, либо секцию babel в package.json. Имейте в виду, что если вы используете babel-plugin-syntax-dynamic-import, то в самом конфиге Webpack нужно будет создать отдельный конфиг для Babelы, в котором не должно быть babel-plugin-syntax-dynamic-import, а вместо этого будут следующие вещи: babel-plugin-dynamic-import-webpack и babel-plugin-transform-ensure-ignore (первый заменит import() на require.ensure, а второй — require.ensure на обычный синхронный require).


    В секцию scripts вашего package.json добавим следующее:


    {
      "scripts": {
        "build": "webpack --progress",
        "start": "webpack-dev-server --progress",
        "dev-server": "NODE_ENV=development babel-node ./index.js",
        "server": "NODE_ENV=production babel-node ./index.js"
      }
    }

    Таким образом у нас будет 3 режима: без серверного рендеринга start, с рендерингом и сборкой на лету dev-server, боевой режим server (который требует предварительной сборки build).


    Для удобства webpack.config.js будет иметь секцию devServer, где как минимум нужно прописать порт и откуда брать файлы, а также в секцию plugins добавим HtmlWebpackPlugin:


    var HtmlWebpackPlugin = require('html-webpack-plugin');
    module.exports = {
    //...
        "output": {
            path: process.cwd() + '/build', // без этого нельзя
            publicPath: '/',
        },
        "plugins": [new HtmlWebpackPlugin({
            filename: 'index.html',
            favicon: './src/favicon.ico', // это опционально
            template: './src/index.html'
        })],
        devServer: {
            port: process.env.PORT || 3000,
            contentBase: './src',
        }
    //...
    }

    Create React Server


    Теперь установим пакет create-react-server, который облегчит процесс рендеринга.


    npm install create-react-server --save-dev

    Мы будем использовать те возможности, которые дает webpack-dev-server, а сам рендериг будет происходить по одному и тому же механизму, но использовать разные файловые системы (реальную для боевого режима, и виртуальную, содержащуюся в памяти, для разработки). Middleware об этом позаботится.


    Сервер для статики (каркас)


    Начнем с создания обычного статичного сервера в файле server.js:


    import Express from "express";
    import webpack from "webpack";
    import Server from "webpack-dev-server";
    import config from "./webpack.config";
    
    const port = process.env.PORT || 3000;
    
    // этот if мы потом заменим целиком
    if (process.env.NODE_ENV !== 'production') {
      const compiler = webpack(config);
      new Server(compiler, config.devServer)
        .listen(port, '0.0.0.0', listen);
    } else {
      const app = Express();
      app.use(Express.static(config.output.path));
      app.listen(port, listen);
    }
    
    function listen(err) {
      if (err) throw err;
      console.log('Listening %s', port);
    }

    Server Side Renderer


    Теперь добавим непосредственно рендеринг, под импорты добавим конфигурацию middleware:


    import path from "path";
    import createRoutes from "./src/routes";
    import createStore from "./src/reduxStore";
    import {
      createExpressMiddleware, 
      createWebpackMiddleware, 
      skipRequireExtensions
    } from "create-react-server";
    
    // это можно пропустить, но нужно заставить NodeJS игнорировать не-JS расширения
    skipRequireExtensions();
    
    const options = {
      createRoutes: () => (createRoutes()),
      createStore: ({req, res}) => (createStore({
        foo: Date.now() // некий начальный state можно добавить прямо здесь
      })),
      templatePath: path.join(config.output.path, 'index.html'),
      outputPath: config.output.path
    };

    Функция template({template, html, store, initialProps, component, req, res}) также может производить любые другие трансформации с шаблоном, а также использовать любой движок шаблонов вместо банального .replace(), на выходе должна быть обычная строка HTML.


    Также можно передать errorTemplate для тех случаев, когда что-то совсем ужасно сломалось и ничего не было отрендерено (фактически, это 500ая ошибка на сервере, совершенно внештатная ситуация).


    Теперь нужно заменить код для раздачи статики на сконфигурированный middleware:


    if (process.env.NODE_ENV !== 'production') {
      const compiler = webpack(config);
      // вот это мы добавляем
      config.devServer.setup = function(app) {
        app.use(createWebpackMiddleware(compiler, config)(options));
      };
      new Server(compiler, config.devServer)
        .listen(port, '0.0.0.0', listen);
    } else {
      const app = Express();
      // вот это мы добавляем, порядок важен!
      app.use(createExpressMiddleware(options));
      app.use(Express.static(config.output.path));
      app.listen(port, listen);
    }

    Полный пример здесь: https://github.com/kirill-konshin/create-react-server/tree/master/examples/webpack-blocks/server.js.


    Теперь осталось все это запустить:


    npm run dev-server

    Что можно улучшить


    Обход всех компонентов в поисках getInitialProps


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


    Сборка сервера


    Для боевого режима можно собирать отдельную версию сервера, чтобы не использовать babel-cli в рантайме, так мы выиграем немного памяти и сократим время запуска. Собирать можно как отдельно стоящим Babel-ем, так и через дополнительный конфиг для Webpack, нужно указать {target: 'node', library: 'commonjs'}, а входная точка должна экспортировать createRouter и createStore. Добавлю это в статью, если будут запросы в комментариях, сейчас в целях наглядности все сделано максимально просто.


    Оптимизация renderToString


    В какой-то момент может оказаться, что узким местом стал метод renderToString, являющийся частью React DOM. С этим можно бороться например так https://github.com/walmartlabs/react-ssr-optimization но это за рамками статьи.

    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 22
    • –5
      Что можно улучшить

      Очевидно, Typescript добавить в первую очередь

      • +3
        не надо называть очевидными решениями вечные темы для холиваров
        • +1
          С моей стороны поддержка TS будет заключаться в удалении этой строки, потому как остальное зависит сугубо от конфигурации Babel и Webpack, а это вне ответственности библиотеки.
        • –3
          Получается такая схема — есть jsx компонент, который сначала отрендерен статически в html код на странице (серверный рендеринг). Потом на этой же странице загружаются js скрипты, которые затирают исходный html код компонента и создают такое же, но своё dom дерево, связанное с virtualdom.
          Но в такой схеме virtual dom кажется лишним. Есть библиотека diffHTML, судя по тестам он нанамного отстает от virtualDOM. Т.е. в идеале хотелось бы, когда js код загрузится на странице — он сравнивал существующее дерево в html коде страницы и код, полученный от JSX, и менял только отличающиеся части dom (изменившиеся аттрибуты, добавленные/удаленные узлы и т.п.).
          Поясню для чего это нужно: на данный момент, если изменить dom дерево react компонента вручную (через jquery или отладочную панель браузера), то этот компонент будет отображаться некорректно, т.к. направление идет от virtualDOM к реальному DOM, а виртуальный DOM не был изменен и приложение думает что всё ок. При сравнении же реального дом и jsx+данные такого не будет — компонент в любом случае будет приведен к надлежащему виду.
          • +3
            При клиентском рендеринге серверный HTML не затирается, а используется как каркас, затрется он только если контрольная сумма не сойдется (React проводит проверку на соответствие). Важна именно связка «серверный HTML + данные», потому как именно она обеспечивает работоспособность.

            Никто в здравом уме не меняет DOM напрямую через jQuery или отладку. React сам прекрасно справляется с мутациями DOM. А для отладки есть Hot Reload, позволяющий сам код менять и видеть изменения сразу.
            • 0
              Нет, как раз таки реакт не использует прежний DOM, всё вычищается под чистую, и заменяется новым содержимым, даже если оно точь-в-точь такое же. Проверено на последней версии react.
          • 0

            POC, для понимания потребностей сгодится, но рассматривать серьезно не подходит. А хочется получить production-ready решение в виде подобной самостоятельной библиотеки, не прибитой насмерть к конкретному фреймворку.

            • 0
              У меня это в продакшене работает. И да, это решение под конкретные составные части, но что в них плохого, если они у всех? Могу добавить поддержку Koa, но Redux и Router то в каждом проекте почти. Хотите совсем production ready — обратите внимание на Next.JS и Electrode от Walmart Labs, подробности тут.
              • 0
                У меня это в продакшене работает.

                Выбирая очередную библиотеку, смотрю на все доступные метрики GitHub-а. Хочется обкатанное решение с историей, с кучей выявленных косяков, звездочками, и контрибьютерами.

                • 0
                  Кто ж будет обкатывать, если все будут ждать
                  обкатанное решение с историей, с кучей выявленных косяков, звездочками, и контрибьютерами.
                  • 0

                    Пока попробую подружиться с Electrod-ом, а вы как хотите :)

                    • 0
                      Именно ))) все проекты с чего-то начинают.
                      • 0

                        Это фактически взять себе на поддержку поделку, когда ресурсы на разработку и так сильно ограничены. Если уже совсем невмоготу без уникального функционала, тогда ещё можно подумать. Но лучше пока забивать гвозди топором. Потому что завтра появится правильный молоток от маленькой конторы Walmart, например. А код ради кода — это я уже наигрался, надеюсь.

                        • 0
                          Ну не хотите — не берите, Вас вроде не заставляют. Есть же альтернативы.
                          • 0

                            Это скорее коммент к статье Что взять за основу React приложения о методе выбора инструментов.


                            Ещё раз спасибо за наводку на Electore, пока вообще всё нравится!


                            Про hapi.js пытаюсь понять, какие есть преимущества перед Express.

                              • +1
                                Никаких нет преимуществ. Он ужасен.
                                • 0

                                  А почему? Поделитесь опытом, пожалуйста. Я пока вижу, что нужно больше лишних слов, чтобы описать роутинг. Предполагаю, что к нему будет меньше сторонних middleware, но все необходимое должен был исполнить WalmartLab. Замеры производительности смущают, но это еще надо проверять. Пугают обещания, что все есть в одной коробке. На монолиты после Meteor-а аллергия.

                                  • +1
                                    Слабо распространен, миддлвары не поддерживает, производительность плохая, плюсов как-то не видно. Я как-то к Hapi прикручивал Webpack Dev Server, в итоге просто забил после двух дней беспросветных поисков и прикрутил через proxy, т.е. стало два сервера на разных портах. Но это давно было, сейчас может быть проще уже.
                              • 0
                                Ну не хотите — не берите, Вас вроде не заставляют. Есть же альтернативы.

                                Хочу остаться вместе с Create React App, отсюда напрашивается вывод — "надо брать"! :)

                      • 0
                        Былобы не плохо добавить поддержку для Koa (в особенности для 2.0)
                        • 0
                          Это ж Open Source, создайте issue на Гитхабе, в идеале — зашлите пулл реквест :)

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.