javascript

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

  • пятница, 10 марта 2017 г. в 03:14:55
https://habrahabr.ru/post/323500/
  • Клиентская оптимизация
  • ReactJS
  • Node.JS
  • JavaScript


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


import Express from "express";
import config from "./webpack.config";
import createRouter from "./src/createRouter";
import createStore from "./src/createStore";
import {createExpressMiddleware} from "react-router-redux-middleware";

const app = Express();
app.use(createExpressMiddleware({
  createRouter: (history) => (createRouter(history)),
  createStore: ({req, res}) => (createStore())
}));
app.use(Express.static(config.output.path));
app.listen(port);

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


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


Клиент


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


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

Router


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


import React from "react";
import {Router, Route} from "react-router";
import NotFound from './NotFound';
// this is needed to extract default export of async route
function def(promise) {
    return promise.then(cmp => cmp.default);
}
export default function(history) {
    return <Router history={history}>
        <Route path='/' getComponent={() => def(import('./App'))}/>
        <Route path='*' component={NotFound}/>
    </Router>;
}

Функция должна принимать history объект, т.к. в зависимости от места вызова он будет разный.


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 в нужное состояние и затем вернуть управление наружу.


@connect(state => ({foo: state.foo}))
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:


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 createRouter from "./router";
import createStore from "./reduxStore";

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}>
        <Router {...props} />
      </Provider>
    ), mountNode);
  });
}

renderRouter(createRouter(browserHistory), 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',
    }
//...
}

React Router Redux Middleware


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


npm install react-router-redux-middleware --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 createRouter from "./src/router";
import createStore from "./src/reduxStore";
import {
  createExpressMiddleware, 
  createWebpackMiddleware, 
  skipRequireExtensions
} from "react-router-redux-middleware";

// это можно пропустить, но нужно заставить NodeJS игнорировать не-JS расширения
skipRequireExtensions();

const options = {
  createRouter: (history) => (createRouter(history)),
  createStore: ({req, res}) => (createStore({
    foo: Date.now() // некий начальный state можно добавить прямо здесь
  })),
  initialStateKey: '__INITIAL_STATE__', 
  templatePath: path.join(config.output.path, 'index.html'),
  template: ({template, html}) => (template.replace(
    `<div id="app"></div>`,
    `<div id="app">${html}</div>`
  )),
  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);
  // вот это мы добавляем
  const middleware = createWebpackMiddleware(compiler, config);
  config.devServer.setup = function(app) {
    app.use(middleware(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/react-router-redux-middleware/blob/master/server.js.


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


npm run dev-server

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


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


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


Обертка для вызова getInitialProps на клиенте


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


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


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


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


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