Из комментариев к статье стало понятно, что очень многие люди склоняются в сторону экосистемы Create React App (он же React Scripts). Это вполне разумно, т.к. это самый популярный и простой в использовании продукт (благодаря отсутствию конфигурации и поддержке ведущих людей React-сообщества), в котором, к тому же, есть почти все необходимое — сборка, режим разработки, тесты, статистика покрытия. Не хватает только серверного рендеринга.
В качестве одного из способов в официальной документации предлагается либо вбивать начальные данные в шаблон либо воспользоваться статическими слепками. Первый подход не позволит поисковикам нормально индексировать статичный HTML, а второй — не поддерживает проброс никаких начальных данных, кроме HTML (фраза из документации: this doesn't pass down any state except what's contained in the markup). Поэтому если используется Redux, то придется для рендеринга использовать что-то другое.
Я адаптировал пример из статьи для использования с Create React App, теперь он называется Create React Server и умеет запускать серверный рендеринг командой:
create-react-server --createRoutes src/routes.js --createStore src/store.js
При таком запуске никакой особой конфигурации не требуется, все делается через параметры командной строки. Если нужно — можно подсунуть так же свои шаблоны и обработчики.
Небольшое лирическое отступление. Как говорят авторы React Router — их сайты индексируются Гуглом без проблем и без всякого серверного рендеринга. Может это и так. Но одной из главных проблем является не только Гугл, но и быстрая доставка контента юзеру, и это может даже поважнее индексации, которую можно обмануть.
Установка
Для начала установим требующийся для этого примера пакеты:
npm install create-react-server --save-dev
Добавим файл .babelrc или секцию babel в файл package.json
{ "presets": [ "react-app" ] }
Пресет babel-preset-react-app ставится вместе с react-scripts, но для серверного рендеринга нам надо явно на него сослаться.
Страница (т.е. конечная точка React Router)
Как и прежде, суть серверного рендеринга довольно проста: на сервере нам нужно определить на основе правил роутера, какой компонент будет показан на странице, выяснить, какие данные ему нужны для работы, запросить эти данные, отрендерить HTML, и выслать этот HTML вместе с данными на клиент.
Сервер берет конечный компонент, вызывает у него getInitialProps, внутри которого можно сделать диспатч экшнов Redux'а и вернуть начальный набор props (на случай, если Redux не используется). Метод вызывается как на клиенте, так и на сервере, что позволяет сильно упростить начальную загрузку данных.
// src/Page.js import React, {Component} from "react"; import {connect} from "react-redux"; import {withWrapper} from "create-react-server/wrapper"; import {withRouter} from "react-router"; export class App extends Component { static async getInitialProps({location, query, params, store}) { await store.dispatch(barAction()); return {custom: 'custom'}; // это станет начальным набором props при рендеринге }; render() { const {foo, bar, custom, initialError} = this.props; if (initialError) return (<pre>Ошибка в функции getInitialProps: {initialError.stack}</pre>); return ( <div>Foo {foo}, Bar {bar}, Custom {custom}</div> ); } } // подключаемся к Redux Provider как обычно App = connect(state => ({foo: state.foo, bar: state.bar})(App); // подключаемся к WrapperProvider, который тянет initialProps с сервера App = withWrapper(App); // до кучи подключаемся к React Router App = withRouter(App); export default App;
Переменная initialError будет иметь значение, если в функции getInitialProps возникла ошибка, причем не важно где — на клиенте или на сервере, поведение одинаково.
Страница, которая будет использоваться как заглушка для 404 ошибок должна иметь статическое свойство notFound:
// src/NotFound.js import React, {Component} from "react"; import {withWrapper} from "create-react-server/wrapper"; class NotFound extends Component { static notFound = true; render() { return ( <div>404 Not Found</div> ); } } export default withWrapper(NotFound);
Router
Функция createRoutes должна возвращать правила роутера, асинхронные роуты тоже поддерживаются, но для простоты это пока опустим:
// src/routes.js import React from "react"; import {IndexRoute, Route} from "react-router"; import NotFound from './NotFound'; import App from './Page'; export default function(history) { return <Route path="/"> <IndexRoute component={App}/> <Route path='*' component={NotFound}/> </Router>; }
Redux
Функция createStore должна принимать начальное состояние в качестве параметра и возвращать новый Store:
// src/store.js import {createStore} from "redux"; function reducer(state, action) { return state; } export default function (initialState, {req, res}) { if (req) initialState = {foo: req.url}; return createStore( reducer, initialState ); }
Когда функция вызывается на сервере, второй параметр будет иметь объекты Request и Response из NodeJS, можно вытащить некую информацию и вложить ее в начальное состояние.
Главная входная точка
Соберем все воедино, а также добавим специальную обертку для получения initialProps с сервера:
// src/index.js import React from "react"; import {render} from "react-dom"; import {Provider} from "react-redux"; import {browserHistory, match, Router} from "react-router"; import {WrapperProvider} from "react-router-redux-middleware/wrapper"; import createRoutes from "./routes"; import createStore from "./store"; const Root = () => ( <Provider store={createStore(window.__INITIAL_STATE__)}> <WrapperProvider initialProps={window.__INITIAL__PROPS__}> <Router history={browserHistory}>{createRoutes()}</Router> </WrapperProvider> </Provider> ); render((<Root/>), document.getElementById('root'));
Запуск простого сервера через консольную утилиту
Добавим скрипты в секцию scripts файла package.json:
{ "build": "react-scripts build", "server": "create-react-server --createRoutes src/routes.js --createStore src/store.js }
И запустим
npm run build npm run server
Теперь если мы откроем http://localhost:3000 в браузере — мы увидим страницу, подготовленную на сервере.
В этом режиме результат сборки сервера нигде не хранится и каждый раз вычисляется на лету.
Запуск сервера через API и сохранение результатов сборки
Если возможностей командной строки стало мало, или требуется хранить результаты сборки сервера, то всегда можно создать сервер не через CLI, а через API.
Установим в дополнение к предыдущим пакетам babel-cli, он понадобится для сборки сервера:
npm install babel-cli --save-dev
Добавим скрипты в секцию scripts файла package.json:
{ "build": "react-scripts build && npm run build-server", "build-server": "NODE_ENV=production babel --source-maps --out-dir build-lib src", "server": "node ./build-lib/server.js" }
Таким образом клиентская часть будет по-прежнему собираться Create React App (React Scripts), а серверная — с помощью Babel, который заберет все и src и положит в build-lib.
// src/server.js import path from "path"; import express from "express"; import {createExpressServer} from "create-react-server"; import createRoutes from "./createRoutes"; import createStore from "./createStore"; createExpressServer({ createRoutes: () => (createRoutes()), createStore: ({req, res}) => (createStore({})), outputPath: path.join(process.cwd(), 'build'), port: process.env.PORT || 3000 }));
Запустим:
npm run build npm run server
Теперь если мы снова откроем http://localhost:3000 в браузере — то мы опять увидим ту же страницу, подготовленную на сервере.
Полный код примера можно посмотреть тут: https://github.com/kirill-konshin/react-router-redux-middleware/tree/master/examples/create-react-app.
