Разработка → Create React App (aka React Scripts) и серверный рендеринг с Redux и Router

dfuse 16 марта в 09:34 10k

Из комментариев к статье стало понятно, что очень многие люди склоняются в сторону экосистемы 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.

Проголосовать:
+7
Сохранить: