Pull to refresh

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

Reading time 8 min
Views 19K

На Хабре уже было предостаточно статей про то, как делать универсальное (изоморфное) приложение на стеке 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 но это за рамками статьи.

Tags:
Hubs:
+10
Comments 22
Comments Comments 22

Articles