跳至主要内容

服务器渲染

服务器端渲染最常见的用例是处理用户(或搜索引擎爬虫)首次请求我们的应用程序时的初始渲染。当服务器接收到请求时,它会将所需的组件渲染成 HTML 字符串,然后将其作为响应发送给客户端。从那时起,客户端将接管渲染职责。

以下示例将使用 React,但相同的技术可以应用于其他可以在服务器上渲染的视图框架。

服务器上的 Redux

在使用 Redux 进行服务器渲染时,我们还需要将应用程序的状态一起发送到响应中,以便客户端可以使用它作为初始状态。这很重要,因为如果我们在生成 HTML 之前预加载任何数据,我们希望客户端也能访问这些数据。否则,客户端生成的标记将与服务器标记不匹配,客户端将不得不再次加载数据。

为了将数据发送到客户端,我们需要

  • 在每次请求时创建一个新的 Redux store 实例;
  • 可选地调度一些操作;
  • 从 store 中提取状态;
  • 然后将状态传递给客户端。

在客户端,将创建一个新的 Redux store,并使用从服务器提供的状态进行初始化。Redux 在服务器端的唯一任务是提供应用程序的初始状态

设置

在下面的示例中,我们将介绍如何设置服务器端渲染。我们将使用简单的 计数器应用程序 作为指南,并展示服务器如何根据请求提前渲染状态。

安装包

在本示例中,我们将使用 Express 作为简单的 Web 服务器。我们还需要安装 Redux 的 React 绑定,因为它们默认情况下不包含在 Redux 中。

npm install express react-redux

服务器端

以下是服务器端将要呈现的概述。我们将使用 Express 中间件,使用 app.use 处理所有传入服务器的请求。如果您不熟悉 Express 或中间件,只需知道我们的 handleRender 函数将在每次服务器收到请求时被调用。

此外,由于我们使用的是现代 JS 和 JSX 语法,我们将需要使用 Babel 进行编译(参见 带有 Babel 的 Node 服务器示例)以及 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 实例。此 store 实例的唯一目的是提供应用程序的初始状态。

在渲染时,我们将把 <App />(我们的根组件)包装在 <Provider> 中,以使 store 可供组件树中的所有组件使用,正如我们在 "Redux 基础" 第 5 部分:UI 和 React 中所见。

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

然后,我们使用 store.getState() 从 Redux 存储中获取初始状态。我们将在 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

我们还通过脚本标签包含了客户端应用程序的捆绑文件。这是你的捆绑工具为你的客户端入口点提供的任何输出。它可能是一个静态文件或一个指向热重载开发服务器的 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.cn/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。

就是这样!这就是我们实现服务器端渲染所需做的全部工作。

但结果相当普通。它本质上是从动态代码渲染一个静态视图。接下来我们需要做的是动态构建一个初始状态,以使渲染的视图成为动态的。

info

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

准备初始状态

由于客户端执行的是持续运行的代码,因此它可以从一个空的初始状态开始,并根据需要和时间推移获取任何必要的状态。在服务器端,渲染是同步的,我们只有一次机会渲染视图。我们需要能够在请求期间编译我们的初始状态,该状态将必须对输入做出反应并获取外部状态(例如来自 API 或数据库的状态)。

处理请求参数

服务器端代码的唯一输入是在浏览器中加载应用程序页面时发出的请求。您可以选择在服务器启动时配置服务器(例如,在开发环境与生产环境中运行时),但该配置是静态的。

请求包含有关请求的 URL 的信息,包括任何查询参数,这在使用类似 React Router 的工具时将很有用。它还可以包含带有输入的标头,例如 cookie 或授权,或 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 发送回客户端的函数。别担心,它没有听起来那么难。

在我们的示例中,我们将假设存在一个外部数据存储,其中包含计数器的初始值(计数器即服务,或 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(),所以服务器将保持连接打开,并且在回调函数执行之前不会发送任何数据。您会注意到,由于我们新的 API 调用,每个服务器请求现在都增加了 500 毫秒的延迟。更高级的用法将优雅地处理 API 中的错误,例如错误的响应或超时。

安全注意事项

由于我们引入了更多依赖于用户生成内容 (UGC) 和输入的代码,因此我们增加了应用程序的攻击面。对于任何应用程序,您都必须确保您的输入已正确清理,以防止诸如跨站点脚本 (XSS) 攻击或代码注入之类的攻击。

在我们的示例中,我们采取了一种基本的安全性方法。当我们从请求中获取参数时,我们对 counter 参数使用 parseInt 来确保此值是一个数字。如果我们没有这样做,您就可以轻松地通过在请求中提供脚本标签将危险数据放入渲染的 HTML 中。这可能看起来像这样:?counter=</script><script>doSomethingBad();</script>

对于我们简单的示例,将输入强制转换为数字足够安全。如果您处理更复杂的输入,例如自由格式文本,那么您应该通过适当的清理函数运行该输入,例如 xss-filters.

此外,您可以通过清理状态输出添加额外的安全层。JSON.stringify 可能容易受到脚本注入的影响。为了解决这个问题,您可以从 JSON 字符串中清除 HTML 标签和其他危险字符。这可以通过对字符串进行简单的文本替换来完成,例如 JSON.stringify(state).replace(/</g, '\\u003c'),或者通过更复杂的库来完成,例如 serialize-javascript.

下一步

您可能想阅读 Redux 基础知识第 6 部分:异步逻辑和数据获取,以了解有关使用异步原语(如 Promise 和 thunk)在 Redux 中表达异步流程的更多信息。请记住,您在那里学到的任何东西也可以应用于通用渲染。

如果您使用类似 React Router 的工具,您可能也希望将数据获取依赖项表示为路由处理程序组件上的静态 fetchData() 方法。它们可以返回 thunk,以便您的 handleRender 函数可以将路由与路由处理程序组件类匹配,为每个类分派 fetchData() 结果,并在 Promise 解析后才进行渲染。这样,不同路由所需的特定 API 调用将与路由处理程序组件定义放在一起。您也可以在客户端使用相同的技术,以防止路由器在数据加载完成之前切换页面。