Упрощаем универсальное/изоморфное приложение на React + Router + Redux + Express
- пятница, 10 марта 2017 г. в 03:14:55
На Хабре уже было предостаточно статей про то, как делать универсальное (изоморфное) приложение на стеке 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 вместе с данными на клиент. Если мы хотим быть совсем крутыми, можно еще пробежаться по дереву компонентов и для всех них загрузить данные (а не только для контентной области), но это выходит за рамки статьи, хотя и запланировано для имплементации в библиотеке.
Подготовительные этапы сводятся к четырем вещам:
window
, DOM манипуляций, прямых обращений к location
, history
, document
и т.д., на сервере ничего из этого нет. Да и вообще это плохая практика.Это, пожалуй, самая простая часть. Нужно только создать функцию, которая каждый раз будет возвращать новый инстанс роутера.
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 таким образом, что он становится синглтоном, и даже обращаются к нему не из-под 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);
В примере мы используем плагин HtmlWebpackPlugin для удобства и автоматизации. Так делать не обязательно, но index.html
(или другой файл, как настроите) обязан участвовать в сборке Webpack (т.е. попасть в output path).
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>App</title>
<body>
<div id="app"></div>
</body>
</html>
Вот мы и добрались до непосредственно серверной части. Когда запрос приходит на сервер, происходит следующая цепочка событий:
getInitialProps
найденной страницы, забрасывая туда свежесозданный StoreШаг 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, который облегчит процесс рендеринга.
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);
}
Теперь добавим непосредственно рендеринг, под импорты добавим конфигурацию 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 но это за рамками статьи.