Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →
Серверный рендеринг
Наиболее распространённый сценарий использования серверного рендеринга — обработка первоначального рендеринга при первом запросе пользователя (или поискового робота) к нашему приложению. Когда сервер получает запрос, он преобразует необходимые компоненты в HTML-строку и отправляет её клиенту в качестве ответа. С этого момента обязанности по рендерингу берёт на себя клиент.
В примерах ниже мы будем использовать React, но те же методы применимы и к другим фреймворкам для представления данных, поддерживающим рендеринг на сервере.
Redux на сервере
При использовании Redux с серверным рендерингом мы также должны передать состояние приложения в ответе, чтобы клиент мог использовать его в качестве начального состояния. Это важно, потому что если мы предварительно загружаем какие-либо данные перед генерацией HTML, мы хотим, чтобы клиент также имел доступ к этим данным. В противном случае разметка, сгенерированная на клиенте, не будет соответствовать серверной разметке, и клиенту придётся загружать данные повторно.
Для передачи данных клиенту нам необходимо:
-
создавать новое, чистое хранилище Redux при каждом запросе;
-
опционально вызывать некоторые действия (dispatch actions);
-
извлечь состояние из хранилища;
-
и передать это состояние клиенту.
На стороне клиента будет создано новое хранилище Redux, инициализированное состоянием, полученным от сервера. Единственная задача Redux на сервере — предоставить начальное состояние нашего приложения.
Настройка
В следующем руководстве мы рассмотрим, как настроить серверный рендеринг. В качестве примера мы возьмём простое приложение-счётчик и покажем, как сервер может выполнить рендеринг состояния заранее на основе запроса.
Установка пакетов
Для этого примера мы будем использовать Express в качестве простого веб-сервера. Также нам необходимо установить привязки React для Redux, так как они не входят в стандартную поставку Redux.
npm install express react-redux
Серверная часть
Вот общий план нашей серверной части. Мы настроим промежуточное ПО Express с помощью app.use для обработки всех входящих запросов. Если вы не знакомы с Express или промежуточным ПО, просто учтите, что наша функция handleRender будет вызываться при каждом получении запроса сервером.
Кроме того, поскольку мы используем современный синтаксис JS и JSX, нам потребуется компиляция с помощью Babel (см. пример Node-сервера с Babel) и пресета React.
server.js
import path from 'path'
import Express from 'express'
import React from 'react'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import counterApp from './reducers'
import App from './containers/App'
const app = Express()
const port = 3000
// Serve static files
app.use('/static', Express.static('static'))
// This is fired every time the server side receives a request
app.use(handleRender)
// We are going to fill these out in the sections to follow
function handleRender(req, res) {
/* ... */
}
function renderFullPage(html, preloadedState) {
/* ... */
}
app.listen(port)
Обработка запроса
Первое, что нам нужно сделать при каждом запросе — создать новый экземпляр хранилища Redux. Единственная цель этого экземпляра хранилища — предоставить начальное состояние нашего приложения.
При рендеринге мы обернём корневой компонент <App /> в <Provider>, чтобы сделать хранилище доступным для всех компонентов в дереве, как мы видели в "Основы Redux", часть 5: UI и React.
Ключевой шаг в серверном рендеринге — отрисовка начального HTML нашего компонента до отправки его клиенту. Для этого мы используем ReactDOMServer.renderToString().
Затем мы получаем начальное состояние из хранилища Redux с помощью store.getState(). Мы увидим, как это передаётся в функции renderFullPage.
import { renderToString } from 'react-dom/server'
function handleRender(req, res) {
// Create a new Redux store instance
const store = createStore(counterApp)
// Render the component to a string
const html = renderToString(
<Provider store={store}>
<App />
</Provider>
)
// Grab the initial state from our Redux store
const preloadedState = store.getState()
// Send the rendered page back to the client
res.send(renderFullPage(html, preloadedState))
}
Внедрение начального HTML компонента и состояния
Финальный шаг на стороне сервера — внедрение начального HTML компонента и начального состояния в шаблон для последующего рендеринга на стороне клиента. Чтобы передать состояние, мы добавляем тег <script>, который присвоит preloadedState свойству window.__PRELOADED_STATE__.
Затем preloadedState станет доступен на стороне клиента через обращение к window.__PRELOADED_STATE__.
Мы также подключаем через тег script файл бандла для клиентской части приложения. Это может быть статичный файл или URL сервера разработки с горячей перезагрузкой — в зависимости от того, что предоставляет ваш инструмент сборки для точки входа клиента.
function renderFullPage(html, preloadedState) {
return `
<!doctype html>
<html>
<head>
<title>Redux Universal Example</title>
</head>
<body>
<div id="root">${html}</div>
<script>
// WARNING: See the following for security issues around embedding JSON in HTML:
// https://redux.js.org/usage/server-rendering#security-considerations
window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(
/</g,
'\\u003c'
)}
</script>
<script src="/static/bundle.js"></script>
</body>
</html>
`
}
Сторона клиента
Клиентская часть максимально проста. Нам нужно получить начальное состояние из window.__PRELOADED_STATE__ и передать его в функцию createStore() в качестве начального состояния.
Рассмотрим наш новый клиентский файл:
client.js
import React from 'react'
import { hydrate } from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import App from './containers/App'
import counterApp from './reducers'
// Create Redux store with state injected by the server
const store = createStore(counterApp, window.__PRELOADED_STATE__)
// Allow the passed state to be garbage-collected
delete window.__PRELOADED_STATE__
hydrate(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
Настройте ваш инструмент сборки (Webpack, Browserify и т.д.) для компиляции бандла в static/bundle.js.
При загрузке страницы бандл запустится, а ReactDOM.hydrate() повторно использует HTML, сгенерированный на сервере. Это свяжет новый экземпляр React с виртуальным DOM, использованным на сервере. Поскольку у нас одинаковое начальное состояние хранилища Redux и идентичный код компонентов, результирующий DOM будет идентичен.
Вот и всё! Это всё, что требуется для реализации серверного рендеринга.
Однако результат довольно прост: по сути, динамический код рендерит статичное представление. Следующий шаг — динамическое формирование начального состояния, чтобы отрендеренное представление стало интерактивным.
Рекомендуем передавать window.__PRELOADED_STATE__ напрямую в createStore, избегая создания дополнительных ссылок на предзагруженное состояние (например, const preloadedState = window.__PRELOADED_STATE__). Это позволит сборщику мусора освободить память.
Подготовка начального состояния
Клиентская часть может стартовать с пустого начального состояния и получать необходимое состояние по мере необходимости. Но серверный рендеринг синхронен — у нас есть только одна попытка для формирования представления. Нам нужно уметь собирать начальное состояние в процессе запроса, реагируя на входные данные и получая внешнее состояние (например, из API или БД).
Обработка параметров запроса
Единственные входные данные для серверного кода — запрос, отправляемый при загрузке страницы приложения в браузере. Конфигурация сервера при запуске (например, для режимов разработки и продакшена) статична.
Запрос содержит информацию о URL, включая параметры строки запроса (полезно при использовании React Router), а также заголовки (куки, авторизация) или данные POST-запроса. Покажем, как задать начальное состояние счётчика на основе параметра запроса.
server.js
import qs from 'qs' // Add this at the top of the file
import { renderToString } from 'react-dom/server'
function handleRender(req, res) {
// Read the counter from the request, if provided
const params = qs.parse(req.query)
const counter = parseInt(params.counter, 10) || 0
// Compile an initial state
let preloadedState = { counter }
// Create a new Redux store instance
const store = createStore(counterApp, preloadedState)
// Render the component to a string
const html = renderToString(
<Provider store={store}>
<App />
</Provider>
)
// Grab the initial state from our Redux store
const finalState = store.getState()
// Send the rendered page back to the client
res.send(renderFullPage(html, finalState))
}
Код читает параметры из объекта Express Request, переданного в middleware сервера. Параметр преобразуется в число и записывается в начальное состояние. Если перейти по адресу http://localhost:3000/?counter=100, счётчик начнётся со 100. В сгенерированном HTML значение счётчика будет 100, а переменная __PRELOADED_STATE__ будет содержать это значение.
Асинхронное получение состояния
Основная сложность серверного рендеринга — работа с асинхронно получаемым состоянием. Рендеринг на сервере синхронен по своей природе, поэтому необходимо преобразовывать асинхронные операции в синхронные.
Самый простой способ сделать это — передать колбэк обратно в ваш синхронный код. В данном случае это будет функция, которая ссылается на объект ответа и отправляет отрендеренный HTML клиенту. Не волнуйтесь, это не так сложно, как может показаться.
Для нашего примера представим, что существует внешнее хранилище данных с начальным значением счётчика (Counter As A Service, CaaS). Мы создадим моковый вызов к этому сервису и построим начальное состояние на основе результата. Начнём с создания API-вызова:
api/counter.js
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min)) + min
}
export function fetchCounter(callback) {
setTimeout(() => {
callback(getRandomInt(1, 100))
}, 500)
}
Это всего лишь моковый API, поэтому мы используем setTimeout для имитации сетевого запроса, занимающего 500 миллисекунд (в реальном API это должно быть значительно быстрее). Мы передаём колбэк, который асинхронно возвращает случайное число. Если вы используете Promise-based клиент, то вызовете этот колбэк в обработчике then.
На стороне сервера мы просто оборачиваем существующий код в fetchCounter и получаем результат в колбэке:
server.js
// Add this to our imports
import { fetchCounter } from './api/counter'
import { renderToString } from 'react-dom/server'
function handleRender(req, res) {
// Query our mock API asynchronously
fetchCounter(apiResult => {
// Read the counter from the request, if provided
const params = qs.parse(req.query)
const counter = parseInt(params.counter, 10) || apiResult || 0
// Compile an initial state
let preloadedState = { counter }
// Create a new Redux store instance
const store = createStore(counterApp, preloadedState)
// Render the component to a string
const html = renderToString(
<Provider store={store}>
<App />
</Provider>
)
// Grab the initial state from our Redux store
const finalState = store.getState()
// Send the rendered page back to the client
res.send(renderFullPage(html, finalState))
})
}
Поскольку мы вызываем res.send() внутри колбэка, сервер будет держать соединение открытым и не отправит никаких данных до выполнения колбэка. Вы заметите, что теперь каждый запрос к серверу задерживается на 500 мс из-за нашего нового API-вызова. В более продвинутых реализациях следует корректно обрабатывать ошибки API, такие как неверный ответ или таймаут.
Вопросы безопасности
Поскольку мы добавили код, который зависит от пользовательского контента (UGC) и ввода, мы увеличили поверхность атак для нашего приложения. Крайне важно для любого приложения обеспечить правильную санитизацию ввода, чтобы предотвратить такие угрозы, как межсайтовый скриптинг (XSS) или инъекции кода.
В нашем примере мы используем базовый подход к безопасности. При получении параметров из запроса мы применяем parseInt к параметру counter, чтобы убедиться, что это число. Без этой проверки можно легко внедрить опасные данные в отрендеренный HTML, передав тег скрипта в запросе. Это может выглядеть так: ?counter=</script><script>doSomethingBad();</script>
Для нашего простого примера преобразование ввода в число обеспечивает достаточную безопасность. Если вы работаете с более сложными данными, например свободным текстом, то следует обработать ввод с помощью соответствующей функции санитизации, такой как xss-filters.
Кроме того, вы можете добавить дополнительные уровни защиты, санитизируя вывод состояния. JSON.stringify может быть уязвим к инъекциям скриптов. Чтобы предотвратить это, можно очистить JSON-строку от HTML-тегов и опасных символов. Это делается либо простой заменой текста в строке, например JSON.stringify(state).replace(/</g, '\\u003c'), либо с помощью специализированных библиотек вроде serialize-javascript.
Следующие шаги
Рекомендуем прочитать Redux Fundamentals Part 6: Async Logic and Data Fetching, чтобы узнать больше об асинхронных потоках в Redux с использованием примитивов вроде Promise и thunk. Помните, что всё изученное там применимо и для универсального рендеринга.
Если вы используете что-то вроде React Router, вы также можете выражать зависимости получения данных через статические методы fetchData() в компонентах-обработчиках маршрутов. Они могут возвращать thunks, чтобы ваша функция handleRender могла сопоставить маршрут с классом компонентов, диспатчить результат fetchData() для каждого из них и рендерить только после разрешения Promise. Таким образом, специфичные для разных маршрутов API-вызовы будут находиться рядом с определениями компонентов-обработчиков. Эту же технику можно использовать на клиенте, чтобы предотвратить переход на страницу до загрузки её данных.