Рендеринг на стороне сервера
Наиболее распространенный случай использования рендеринга на стороне сервера — обработка начального рендеринга, когда пользователь (или бот поисковой системы) впервые запрашивает наше приложение. Когда сервер получает запрос, он рендерит необходимые компоненты в строку HTML и отправляет её в качестве ответа клиенту. После этого рендеринг выполняется на стороне клиента.
В примерах ниже мы будем использовать React, но те же методы можно применять и с другими фреймворками, способными выполнять рендеринг на сервере.
Redux на сервере
При использовании Redux с рендерингом на сервере нам необходимо также передать состояние нашего приложения в ответе, чтобы клиент мог использовать его как начальное состояние. Это важно, потому что если мы предварительно загружаем какие-либо данные перед генерацией HTML, мы хотим, чтобы клиент также имел доступ к этим данным. В противном случае разметка, сгенерированная на клиенте, не будет соответствовать разметке на сервере, и клиенту придётся загружать данные заново.
Для передачи данных клиенту необходимо:
- создавать новый экземпляр хранилища Redux при каждом запросе;
- необязательно отправлять некоторые действия;
- извлекать состояние из хранилища;
- и затем передавать состояние клиенту.
На стороне клиента будет создано и инициализировано новое хранилище Redux с состоянием, предоставленным сервером.
Единственная задача Redux на стороне сервера — предоставление начального состояния нашего приложения.
Настройка
В следующем рецепте мы рассмотрим, как настроить рендеринг на стороне сервера. Мы будем использовать упрощённое приложение счётчика в качестве руководства и покажем, как сервер может рендерить состояние заранее на основе запроса.
Установка пакетов
В этом примере мы будем использовать Express в качестве простого веб-сервера. Нам также необходимо установить React-связки для Redux, так как они по умолчанию не включены в Redux.
npm install --save express react-redux
Сторона сервера
Ниже приведён план того, как будет выглядеть наша сторона сервера. Мы настраиваем middleware Express с помощью Express middleware app.use для обработки всех входящих в сервер запросов. Если вы не знакомы с Express или middleware, знайте, что функция handleRender будет вызываться каждый раз, когда сервер получает запрос.
Кроме того, поскольку мы используем синтаксис ES6 и JSX, нам потребуется компиляция с помощью Babel (см. этот пример Node Server с 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>, чтобы хранилище было доступно всем компонентам в дереве компонентов, как мы видели в Использование с 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:
// http://redux.js.org/docs/recipes/ServerRendering.html#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 { render } from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import App from './containers/App'
import counterApp from './reducers'
// Grab the state from a global variable injected into the server-generated HTML
const preloadedState = window.__PRELOADED_STATE__
// Allow the passed state to be garbage-collected
delete window.__PRELOADED_STATE__
// Create Redux store with initial state
const store = createStore(counterApp, preloadedState)
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
Вы можете настроить инструмент сборки по своему выбору (Webpack, Browserify и т.д.) для компиляции файла сборки в static/bundle.js.
При загрузке страницы файл сборки будет запущен, и ReactDOM.render() подключится к атрибутам data-react-id из HTML-кода, сгенерированного на сервере. Это соединит наш недавно запущенный экземпляр React с виртуальным DOM, используемым на сервере. Поскольку у нас одинаковое начальное состояние для нашего хранилища Redux и мы использовали один и тот же код для всех наших компонентов представления, результатом будет тот же реальный DOM.
И всё! Это всё, что нам нужно сделать для реализации рендеринга на стороне сервера.
Но результат довольно прост. По сути, он рендерит статическое представление из динамического кода. Что нам нужно сделать дальше, это построить динамическое начальное состояние, чтобы позволить этому рендеру быть динамическим.
Подготовка начального состояния
Поскольку клиентская сторона выполняет непрерывный код, она может начать с пустого начального состояния и получать необходимые состояния по мере необходимости и со временем. На стороне сервера рендеринг синхронный, и у нас есть только один шанс выполнить рендеринг нашего представления. Нам нужно уметь компилировать наше начальное состояние во время запроса, что потребует реагирования на ввод и получения внешнего состояния (например, из 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). Мы передаём обратный вызов, который асинхронно возвращает случайное число. Если вы используете API-клиент на основе Promises, то вы вызовете этот обратный вызов в обработчике 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, предоставив тег script в запросе. Это могло бы выглядеть так: ?counter=</script><script>doSomethingBad();</script>.
Для нашего упрощённого примера принудительное преобразование ввода в число достаточно безопасно. Если вы обрабатываете более сложный ввод, например, свободный текст, то вы должны пропустить этот ввод через соответствующую функцию очистки, такую как validator.js.
Кроме того, вы можете добавить дополнительные слои безопасности, очистив выходное состояние. JSON.stringify может быть подвержен инъекциям сценария. Чтобы противостоять этому, вы можете очистить строку JSON от HTML-тегов и других опасных символов. Это можно сделать с помощью простого текстового преобразования строки, например, JSON.stringify(state).replace(/</g, '\\u003c'), или с помощью более сложных библиотек, таких как serialize-javascript.
Следующие шаги
Вы можете прочитать Асинхронные действия, чтобы узнать больше об выражении асинхронного потока в Redux с помощью асинхронных примитивов, таких как Promises и thunks. Имейте в виду, что всё, чему вы научитесь там, можно также применить к универсальному рендерингу.
Если вы используете что-то вроде React Router, вам также может потребоваться выразить зависимости загрузки данных как статические fetchData() методы в ваших компонентах обработчиков маршрутов. Они могут возвращать асинхронные действия, чтобы ваша handleRender функция могла сопоставить маршрут с классами компонентов обработчиков маршрутов, выполнить диспетчеризацию fetchData() результата для каждого из них и отобразить только после разрешения обещаний. Таким образом, конкретные вызовы API, необходимые для различных маршрутов, размещаются совместно с определениями компонентов обработчиков маршрутов. Вы также можете использовать ту же технику на стороне клиента, чтобы предотвратить переключение маршрутизатором страницы до загрузки данных.
© 2015–2017 Dan Abramov
Licensed under the MIT License.
http://redux.js.org/docs/recipes/ServerRendering.html