Или как инициализировать хранилище Redux на сервере, а затем поднять его и обработать на клиенте.

Это вторая часть моей серии статей о CRA + SSR:

  1. Обновление проекта create-react-app до SSR + разделение кода
  2. Добавление управления состоянием с помощью Redux в проект CRA + SSR

Мне лично нравится Redux, потому что он использует один объект для представления всего состояния приложения. Этот единственный объект также можно компоновать, поэтому вы можете разделить его управление на более мелкие, независимые части. Я также считаю полезным иметь односторонний поток данных, поэтому, когда я нажимаю кнопку в одном месте, я запускаю цепочку событий, заканчивающуюся обновлением пользовательского интерфейса на основе нового состояния, возможно, даже в другом месте приложение, без необходимости передавать реквизиты и выполнять обратные вызовы повсюду.

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

О чем мы поговорим в этой статье:

  1. Добавление Redux на клиенте
  2. Добавление Redux на сервер
  3. Регидратация клиентского магазина с сервера

Во-первых: на стороне клиента

Установим Redux и его помощники:

yarn add redux react-redux redux-thunk

Нам нужно создать редуктор в /src/store/appReducer.js, также известный как чистая функция, принимающая два аргумента (предыдущее состояние и объект действия / модификатора) и возвращающая новое состояние как неизменяемое. объект.

const initialState = {
    message: null,
};
export const appReducer = (state = initialState, action) => {
    switch(action.type) {
        case 'SET_MESSAGE':
            return {
                ...state,
                message: action.message,
            };
        default:
            return state;
    }
};

Здесь мы деструктурируем объект состояния, чтобы старые свойства оставались неизменными, и заменяли только то, что нам нужно. В нашем случае это необязательно, поскольку у нас есть только свойство message, но в более крупном приложении это то, что вы обычно делаете.

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

export const setMessage = messageText => ({ type: 'SET_MESSAGE', message: messageText });

Теперь мы создадим инициализатор нашего магазина в /src/store/configureStore.js.

import {
    createStore,
    combineReducers,
    compose,
    applyMiddleware,
} from 'redux';
import ReduxThunk from 'redux-thunk'
import { appReducer } from './appReducer';
// if you're using redux-thunk or other middlewares, add them here
const createStoreWithMiddleware = compose(applyMiddleware(
    ReduxThunk,
))(createStore);
const rootReducer = combineReducers({
    app: appReducer,
});
export default function configureStore(initialState = {}) {
    return createStoreWithMiddleware(rootReducer, initialState);
};

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

Теперь давайте воспользуемся этим в нашем приложении. Оберните основной компонент приложения поставщиком Redux в /src/index.js:

import React from 'react';
import ReactDOM from 'react-dom';
import Loadable from 'react-loadable';
import { Provider as ReduxProvider } from 'react-redux'
import App from './App';
import configureStore from './store/configureStore';
const store = configureStore();
const AppBundle = (
    <ReduxProvider store={store}>
        <App />
    </ReduxProvider>
);
window.onload = () => {
    Loadable.preloadReady().then(() => {
        ReactDOM.hydrate(
            AppBundle,
            document.getElementById('root')
        );
    });
};

Затем мы отобразим сообщение в нашем приложении. Мы также установим для клиента сообщение по умолчанию, если начальное значение пусто.

import { connect } from 'react-redux';
import { setMessage } from './store/appReducer';
class App extends Component {
    componentDidMount() {
        if(!this.props.message) {
            this.props.updateMessage("Hi, I'm from client!");
        }
    }
    render() {
        return (
            <div className="App">
                // ...
                <p>
                    Redux: { this.props.message }
                </p>
            </div>
        );
    }
}
export default connect(
    ({ app }) => ({
        message: app.message,
    }),
    dispatch => ({
        updateMessage: (txt) => dispatch(setMessage(txt)),
    })
)(App);

Вот и все! Теперь запустите приложение с началом пряжи и увидите сообщение «Привет, я от клиента!» сообщение отображается после загрузки приложения.

Далее: Сторона сервера

Помните наше промежуточное ПО serverRenderer, которое преобразует наше приложение в строку? Давайте немного изменим это. Мы обернем его другой функцией, чтобы передать магазин извне. Мы также обернем наш основной компонент приложения в провайдер Redux, как и на клиенте.

export default (store) => (req, res, next) => {
    // ...
    const html = ReactDOMServer.renderToString(
        <ReduxProvider store={store}>
            <App />
        </ReduxProvider>
    );
    // ...
}

Теперь нам нужно инициализировать наше хранилище и передать его как опору при использовании промежуточного программного обеспечения рендеринга в нашем маршрутизаторе (в /server/index.js):

import serverRenderer from './middleware/renderer';
import configureStore from '../src/store/configureStore';
//...
const store = configureStore();
router.use('^/$', serverRenderer(store));
// ...

В реальном приложении вы захотите переместить этот код в контроллер, чтобы отделить логику приложения от инициализации экспресс-сервера. Кроме того, вам понадобятся некоторые действия контроллера, которые содержат более сложную логику, возможно, даже на основе URL-адреса запроса. Собственно, давайте сделаем это сейчас. Мы переместим код для инициализации маршрутизатора и напишем действие индекса, которое обрабатывает инициализацию хранилища Redux в /server/controllers/index.js:

import express from "express";

import serverRenderer from '../middleware/renderer';
import configureStore from '../../src/store/configureStore';

const router = express.Router();
const path = require("path");


const actionIndex = (req, res, next) => {
    const store = configureStore();
    serverRenderer(store)(req, res, next);
};


// root (/) should always serve our server rendered page
router.use('^/$', actionIndex);

// other static resources should just be served as they are
router.use(express.static(
    path.resolve(__dirname, '..', '..', 'build'),
    { maxAge: '30d' },
));

export default router;

Поскольку мы переместили код в подкаталог, убедитесь, что в маршруте для статических файлов вы добавили лишний '..', поэтому path.resolve () будет указывать на нужное место.

Наше действие - это просто еще одно промежуточное ПО, которое будет вызывать промежуточное ПО serverRenderer после инициализации хранилища Redux. Мы даже можем отправить действие до вызова средства визуализации.

import { setMessage } from '../../src/store/appReducer';
// ...
const actionIndex = (req, res, next) => {
    const store = configureStore();
    store.dispatch(setMessage("Hi, I'm from server!"));
    serverRenderer(store)(req, res, next);
};
// ...

Теперь мы можем очистить нашу точку входа /server/index.js:

import express from 'express';
import indexController from './controllers/index';
const app = express();
app.use(indexController);
// start the app
// ...

Соберите приложение и запустите сервер узла. Перед инициализацией клиентского приложения вы должны увидеть сообщение, отображаемое на сервере.

yarn build && node server/bootstrap.js

Наконец: регидрируйте клиентское хранилище с сервера

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

Поскольку мы уже пишем код в нашем HTML на сервере, давайте отправим данные в то же место. Мы добавим заполнитель в /public/index.html:

<div id="root"></div>

<script type="text/javascript" charset="utf-8">
    window.REDUX_STATE = "__SERVER_REDUX_STATE__";
</script>

Теперь давайте заменим это на сервере представлением наших данных в формате JSON. Обновите serverRenderer:

export default (store) => (req, res, next) => {
    // ...
    const html = ReactDOMServer.renderToString(
        <ReduxProvider store={store}>
            <App />
        </ReduxProvider>
    );
    const reduxState = JSON.stringify(store.getState());
    // ...
    return res.send(
        htmlData
            .replace(
                '<div id="root"></div>',
                `<div id="root">${html}</div>`
            )
            .replace(
                '</body>',
                extraChunks.join('') + '</body>'
            )
            .replace('"__SERVER_REDUX_STATE__"', reduxState)
    );
}

Хорошо, теперь мы возьмем это на клиенте и инициализируем магазин перед рендерингом приложения. Обновите /src/index.js:

const store = configureStore( window.REDUX_STATE || {} );
const AppBundle = (
    <ReduxProvider store={store}>
        <App />
    </ReduxProvider>
);

Соберите приложение и запустите сервер узла в последний раз.

Идти дальше

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

// src/store/appReducer.js
export const setAsyncMessage = messageText => dispatch => (
    new Promise((resolve, reject) => {
        setTimeout(() => resolve(), 2000);
    })
        .then(() => dispatch(setMessage(messageText)))
);
// server/controllers/index.js
const actionIndex = (req, res, next) => {
    const store = configureStore();

    store.dispatch(setAsyncMessage("Hi, I'm from server!"))
        .then(() => {
            serverRenderer(store)(req, res, next);
        });
};

Хотя это работает, я лично не рекомендую его использовать. Основная идея SSR - визуализировать приложение с минимальным начальным состоянием, чтобы пользователь что-то видел, пока приложение не загрузится в браузере. Кроме того, клиентские приложения должны содержать только логику (также известную как манипуляции с данными), в то время как фактические данные могут быть извлечены асинхронно из любого удаленного источника backend / API.

Многие производственные приложения предпочитают этот подход: Facebook, Slack и т. Д. Они визуализируют пустую« оболочку приложения, затем асинхронно извлекают данные и показывают их при загрузке.

На Snipit.io я лично тоже использую эту технику. Единственные данные, которые я помещаю в хранилище на сервере, - это то, вошел ли пользователь в систему или нет. Исходя из этого, я загружаю на клиенте отдельный пользовательский интерфейс. Затем, только после инициализации приложения на клиенте, я получаю данные асинхронно из серверного API. Пока данные не будут получены, пользователь будет видеть заполнители для содержимого, которое будет обработано. Таким образом, пользовательский интерфейс загружается почти мгновенно, поэтому общая воспринимаемая производительность лучше.

Что вы думаете о методах, описанных в этих статьях? Считаете ли вы их полезными для вашего проекта? Напишите в комментариях.

Вы также можете подписаться на меня здесь в Medium или в Twitter @andreiduca, чтобы увидеть больше подобных историй.