跳至主内容
非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

服务端渲染

服务端渲染最常见的应用场景是处理用户(或搜索引擎爬虫)首次请求应用时的_初始渲染_。服务器收到请求后,将所需组件渲染为 HTML 字符串,然后作为响应发送给客户端。此后客户端将接管渲染任务。

下文示例将使用 React,但相同技术也适用于其他支持服务端渲染的视图框架。

服务端中的 Redux

在服务端渲染中使用 Redux 时,我们必须在响应中附带应用状态,以便客户端将其作为初始状态使用。这很关键——如果我们在生成 HTML 前预加载了任何数据,就需要确保客户端也能访问这些数据。否则客户端生成的标记将与服务端标记不匹配,客户端不得不重新加载数据。

向客户端传递数据需要完成以下步骤:

  • 为每个请求创建全新的 Redux store 实例;

  • 可选地派发部分 action;

  • 从 store 中提取状态;

  • 将状态传递给客户端。

客户端将创建新的 Redux store 并用服务端提供的状态初始化。 Redux 在服务端的唯一任务就是提供应用的初始状态

环境配置

在接下来的方案中,我们将演示如何配置服务端渲染。以简易的计数器应用为例,展示服务端如何根据请求预先渲染状态。

安装依赖包

本示例将使用 Express 作为简易 Web 服务器。由于 Redux 默认不包含 React 绑定,我们还需安装相应包。

npm install express react-redux

服务端实现

以下是服务端的基本架构。我们将通过 app.use 设置 Express 中间件来处理所有到达服务器的请求。若不熟悉 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 store 实例。该实例的唯一目的是提供应用的初始状态。

渲染时,我们会将根组件 <App /> 包裹在 <Provider> 中,使 store 可用于组件树的所有组件(如 "Redux 基础" 第 5 篇:UI 与 React 所述)。

服务端渲染的关键步骤是在发送到客户端_之前_渲染组件的初始 HTML。为此我们使用 ReactDOMServer.renderToString()

随后通过 store.getState() 从 Redux store 获取初始状态。我们将在 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__ 上。

这样客户端就能通过访问 window.__PRELOADED_STATE__ 来获取 preloadedState

同时通过 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 store 初始状态相同且视图组件代码一致,最终将生成相同的真实 DOM。

至此,我们已完整实现服务器端渲染!

但当前实现较为基础,本质上是将动态代码渲染为静态视图。接下来需要动态构建初始状态,使渲染视图具备动态性。

信息

建议直接将 window.__PRELOADED_STATE__ 传递给 createStore,避免创建对预加载状态的额外引用(例如 const preloadedState = window.__PRELOADED_STATE__),以便该状态能被垃圾回收。

准备初始状态

客户端执行的是持续运行的代码,可以从空初始状态开始,按需随时间获取必要状态。服务器端渲染本质是同步的,我们只有一次渲染机会。必须在请求期间编译初始状态,该状态需响应输入并获取外部状态(如来自 API 或数据库的状态)。

处理请求参数

服务器端代码的唯一输入是浏览器加载应用页面时发出的请求。虽然可以在服务器启动时配置(例如区分开发环境和生产环境),但这些配置是静态的。

请求包含 URL 信息(含查询参数),这在配合 React Router 等工具时非常有用。请求还可能包含 headers(如 cookies 或认证信息)或 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 对象中读取参数。参数被解析为数字后设置到初始状态中。在浏览器访问 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 的 API 客户端,则应在 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(),服务器将保持连接打开状态,直到回调执行完成才会发送任何数据。您会注意到每个服务器请求因此新增了 500ms 延迟。更高级的实现应该优雅处理 API 错误,例如错误响应或超时。

安全注意事项

由于我们引入了更多依赖用户生成内容(UGC)和输入的代码,应用程序的攻击面也随之扩大。任何应用都必须确保正确清理输入,以防止跨站脚本(XSS)攻击或代码注入等风险。

在本例中,我们采用基础的安全措施。从请求中获取参数时,使用 parseInt 处理 counter 参数确保其为数字。若不这样做,通过在请求中注入脚本标签(如 ?counter=</script><script>doSomethingBad();</script>)就可能将危险数据植入渲染的 HTML。

对于我们这个简单示例,将输入强制转换为数字已足够安全。若处理更复杂的输入(如自由格式文本),则应通过适当的清理函数(例如 xss-filters)处理该输入。

此外,可以通过清理状态输出来增加安全层级。JSON.stringify 可能遭受脚本注入攻击。应对策是清除 JSON 字符串中的 HTML 标签和其他危险字符。可通过简单文本替换实现(如 JSON.stringify(state).replace(/</g, '\\u003c')),或使用更复杂的库(如 serialize-javascript)。

下一步

建议阅读 Redux 基础第六节:异步逻辑与数据获取,了解如何使用 Promise 和 thunk 等异步原语在 Redux 中表达异步流程。请注意,这些知识同样适用于通用渲染场景。

若使用 React Router 等路由库,您可能还需要在路由处理组件上定义静态 fetchData() 方法表达数据依赖关系。这些方法可能返回 thunk,这样您的 handleRender 函数就能将路由与路由处理组件类匹配,为每个组件派发 fetchData() 结果,并在所有 Promise 解析完成后再进行渲染。通过这种方式,不同路由所需的 API 调用与路由处理组件定义放在一起。您也可以在客户端使用相同技术,在数据加载完成前阻止路由切换页面。