Skip to main content

服务器渲染

¥Server Rendering

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

¥The most common use case for server-side rendering is to handle the initial render when a user (or search engine crawler) first requests our app. When the server receives the request, it renders the required component(s) into an HTML string, and then sends it as a response to the client. From that point on, the client takes over rendering duties.

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

¥We will use React in the examples below, but the same techniques can be used with other view frameworks that can render on the server.

服务器上的 Redux

¥Redux on the Server

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

¥When using Redux with server rendering, we must also send the state of our app along in our response, so the client can use it as the initial state. This is important because, if we preload any data before generating the HTML, we want the client to also have access to this data. Otherwise, the markup generated on the client won't match the server markup, and the client would have to load the data again.

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

¥To send the data down to the client, we need to:

  • 根据每个请求创建一个全新的 Redux 存储实例;

    ¥create a fresh, new Redux store instance on every request;

  • 可选地调度一些操作;

    ¥optionally dispatch some actions;

  • 将状态从存储中拉出;

    ¥pull the state out of store;

  • 然后将状态传递给客户端。

    ¥and then pass the state along to the client.

在客户端,将创建一个新的 Redux 存储,并使用服务器提供的状态进行初始化。Redux 在服务器端的唯一工作是提供应用的初始状态。

¥On the client side, a new Redux store will be created and initialized with the state provided from the server. Redux's only job on the server side is to provide the initial state of our app.

配置

¥Setting Up

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

¥In the following recipe, we are going to look at how to set up server-side rendering. We'll use the simplistic Counter app as a guide and show how the server can render state ahead of time based on the request.

安装包

¥Install Packages

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

¥For this example, we'll be using Express as a simple web server. We also need to install the React bindings for Redux, since they are not included in Redux by default.

npm install express react-redux

服务器端

¥The Server Side

以下是我们的服务器端的概要。我们将使用 app.use 设置 Express 中间件 来处理进入我们服务器的所有请求。如果你不熟悉 Express 或中间件,只需知道每次服务器收到请求时都会调用我们的 handleRender 函数。

¥The following is the outline for what our server side is going to look like. We are going to set up an Express middleware using app.use to handle all requests that come in to our server. If you're unfamiliar with Express or middleware, just know that our handleRender function will be called every time the server receives a request.

此外,由于我们使用现代 JS 和 JSX 语法,因此我们需要使用 Babel(参见 这个带有 Babel 的节点服务器示例)和 React 预设 进行编译。

¥Additionally, as we are using modern JS and JSX syntax, we will need to compile with Babel (see this example of a Node Server with Babel) and the React preset.

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)

处理请求

¥Handling the Request

对于每个请求,我们需要做的第一件事是创建一个新的 Redux 存储实例。该存储实例的唯一目的是提供应用的初始状态。

¥The first thing that we need to do on every request is to create a new Redux store instance. The only purpose of this store instance is to provide the initial state of our application.

渲染时,我们将把根组件 <App /> 封装在 <Provider> 中,以使存储可用于组件树中的所有组件,正如我们在 "Redux 基础知识" 第 5 部分:UI 和 React 中看到的那样。

¥When rendering, we will wrap <App />, our root component, inside a <Provider> to make the store available to all components in the component tree, as we saw in "Redux Fundamentals" Part 5: UI and React.

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

¥The key step in server side rendering is to render the initial HTML of our component before we send it to the client side. To do this, we use ReactDOMServer.renderToString().

然后,我们使用 store.getState() 从 Redux 存储中获取初始状态。我们将看到它是如何在 renderFullPage 函数中传递的。

¥We then get the initial state from our Redux store using store.getState(). We will see how this is passed along in our renderFullPage function.

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 和状态

¥Inject Initial Component HTML and State

服务器端的最后一步是将我们的初始组件 HTML 和初始状态注入到要在客户端渲染的模板中。为了传递状态,我们添加一个 <script> 标签,将 preloadedState 附加到 window.__PRELOADED_STATE__

¥The final step on the server side is to inject our initial component HTML and initial state into a template to be rendered on the client side. To pass along the state, we add a <script> tag that will attach preloadedState to window.__PRELOADED_STATE__.

然后,preloadedState 将在客户端通过访问 window.__PRELOADED_STATE__ 可用。

¥The preloadedState will then be available on the client side by accessing window.__PRELOADED_STATE__.

我们还通过脚本标签包含客户端应用的打包文件。这是打包工具为客户端入口点提供的任何输出。它可能是静态文件或热重载开发服务器的 URL。

¥We also include our bundle file for the client-side application via a script tag. This is whatever output your bundling tool provides for your client entry point. It may be a static file or a URL to a hot reloading development server.

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.nodejs.cn/usage/server-rendering#security-considerations
window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(
/</g,
'\\u003c'
)}
</script>
<script src="/static/bundle.js"></script>
</body>
</html>
`
}

客户端

¥The Client Side

客户端非常简单。我们需要做的就是从 window.__PRELOADED_STATE__ 获取初始状态,并将其传递给我们的 createStore() 函数作为初始状态。

¥The client side is very straightforward. All we need to do is grab the initial state from window.__PRELOADED_STATE__, and pass it to our createStore() function as the initial state.

让我们看一下我们的新客户文件:

¥Let's take a look at our new client file:

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

¥You can set up your build tool of choice (Webpack, Browserify, etc.) to compile a bundle file into static/bundle.js.

当页面加载时,打包文件将启动,ReactDOM.hydrate() 将重用服务器渲染的 HTML。这会将我们新启动的 React 实例连接到服务器上使用的虚拟 DOM。由于我们的 Redux 存储具有相同的初始状态,并且对所有视图组件使用相同的代码,因此结果将是相同的真实 DOM。

¥When the page loads, the bundle file will be started up and ReactDOM.hydrate() will reuse the server-rendered HTML. This will connect our newly-started React instance to the virtual DOM used on the server. Since we have the same initial state for our Redux store and used the same code for all our view components, the result will be the same real DOM.

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

¥And that's it! That is all we need to do to implement server side rendering.

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

¥But the result is pretty vanilla. It essentially renders a static view from dynamic code. What we need to do next is build an initial state dynamically to allow that rendered view to be dynamic.

信息

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

¥We recommend passing window.__PRELOADED_STATE__ directly to createStore and avoid creating additional references to the preloaded state (e.g. const preloadedState = window.__PRELOADED_STATE__) so that it can be garbage collected.

准备初始状态

¥Preparing the Initial State

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

¥Because the client side executes ongoing code, it can start with an empty initial state and obtain any necessary state on demand and over time. On the server side, rendering is synchronous and we only get one shot to render our view. We need to be able to compile our initial state during the request, which will have to react to input and obtain external state (such as that from an API or database).

处理请求参数

¥Processing Request Parameters

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

¥The only input for server side code is the request made when loading up a page in your app in your browser. You may choose to configure the server during its boot (such as when you are running in a development vs. production environment), but that configuration is static.

该请求包含有关所请求 URL 的信息,包括任何查询参数,这在使用 React 路由 之类的内容时非常有用。它还可以包含带有 cookie 或授权等输入的标头,或 POST 正文数据。让我们看看如何根据查询参数设置初始计数器状态。

¥The request contains information about the URL requested, including any query parameters, which will be useful when using something like React Router. It can also contain headers with inputs like cookies or authorization, or POST body data. Let's see how we can set the initial counter state based on a query parameter.

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__ 变量中设置了计数器。

¥The code reads from the Express Request object passed into our server middleware. The parameter is parsed into a number and then set in the initial state. If you visit http://localhost:3000/?counter=100 in your browser, you'll see the counter starts at 100. In the rendered HTML, you'll see the counter output as 100 and the __PRELOADED_STATE__ variable has the counter set in it.

异步状态获取

¥Async State Fetching

服务器端渲染最常见的问题是处理异步进入的状态。服务器上的渲染本质上是同步的,因此有必要将任何异步获取映射到同步操作。

¥The most common issue with server side rendering is dealing with state that comes in asynchronously. Rendering on the server is synchronous by nature, so it's necessary to map any asynchronous fetches into a synchronous operation.

最简单的方法是将一些回调传递回同步代码。在本例中,该函数将引用响应对象并将渲染的 HTML 发送回客户端。别担心,这并不像听起来那么难。

¥The easiest way to do this is to pass through some callback back to your synchronous code. In this case, that will be a function that will reference the response object and send back our rendered HTML to the client. Don't worry, it's not as hard as it may sound.

对于我们的示例,我们假设有一个外部数据存储,其中包含计数器的初始值(计数器即服务或 CaaS)。我们将对它们进行模拟调用,并根据结果构建我们的初始状态。我们将从构建 API 调用开始:

¥For our example, we'll imagine there is an external datastore that contains the counter's initial value (Counter As A Service, or CaaS). We'll make a mock call over to them and build our initial state from the result. We'll start by building out our API call:

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 处理程序中发出此回调。

¥Again, this is just a mock API, so we use setTimeout to simulate a network request that takes 500 milliseconds to respond (this should be much faster with a real world API). We pass in a callback that returns a random number asynchronously. If you're using a Promise-based API client, then you would issue this callback in your then handler.

在服务器端,我们只需将现有代码封装在 fetchCounter 中并在回调中接收结果:

¥On the server side, we simply wrap our existing code in the fetchCounter and receive the result in the callback:

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 中的错误,例如错误响应或超时。

¥Because we call res.send() inside of the callback, the server will hold open the connection and won't send any data until that callback executes. You'll notice a 500ms delay is now added to each server request as a result of our new API call. A more advanced usage would handle errors in the API gracefully, such as a bad response or timeout.

安全考虑

¥Security Considerations

由于我们引入了更多依赖于用户生成内容 (UGC) 和输入的代码,因此我们增加了应用的攻击面。对于任何应用来说,确保正确清理输入以防止跨站点脚本 (XSS) 攻击或代码注入等情况都很重要。

¥Because we have introduced more code that relies on user generated content (UGC) and input, we have increased our attack surface area for our application. It is important for any application that you ensure your input is properly sanitized to prevent things like cross-site scripting (XSS) attacks or code injections.

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

¥In our example, we take a rudimentary approach to security. When we obtain the parameters from the request, we use parseInt on the counter parameter to ensure this value is a number. If we did not do this, you could easily get dangerous data into the rendered HTML by providing a script tag in the request. That might look like this: ?counter=</script><script>doSomethingBad();</script>

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

¥For our simplistic example, coercing our input into a number is sufficiently secure. If you're handling more complex input, such as freeform text, then you should run that input through an appropriate sanitization function, such as xss-filters.

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

¥Furthermore, you can add additional layers of security by sanitizing your state output. JSON.stringify can be subject to script injections. To counter this, you can scrub the JSON string of HTML tags and other dangerous characters. This can be done with either a simple text replacement on the string, e.g. JSON.stringify(state).replace(/</g, '\\u003c'), or via more sophisticated libraries such as serialize-javascript.

下一步

¥Next Steps

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

¥You may want to read Redux Fundamentals Part 6: Async Logic and Data Fetching to learn more about expressing asynchronous flow in Redux with async primitives such as Promises and thunks. Keep in mind that anything you learn there can also be applied to universal rendering.

如果你使用类似 React 路由 的内容,你可能还希望将数据获取依赖表示为路由处理程序组件上的静态 fetchData() 方法。它们可能会返回 thunks,以便你的 handleRender 函数可以将路由与路由处理程序组件类相匹配,为每个类分派 fetchData() 结果,并仅在 Promises 解析后才进行渲染。这样,不同路由所需的特定 API 调用就与路由处理程序组件定义位于同一位置。你还可以在客户端使用相同的技术来防止路由在加载数据之前切换页面。

¥If you use something like React Router, you might also want to express your data fetching dependencies as static fetchData() methods on your route handler components. They may return thunks, so that your handleRender function can match the route to the route handler component classes, dispatch fetchData() result for each of them, and render only after the Promises have resolved. This way the specific API calls required for different routes are colocated with the route handler component definitions. You can also use the same technique on the client side to prevent the router from switching the page until its data has been loaded.