到目前为止,您在本书中学到的所有内容都是在 web 浏览器中运行的 React 代码。但是,React 并不局限于用于渲染的浏览器,在本章中,您将了解如何从 Node.js 服务器渲染组件。
本章的第一部分简要介绍了高级服务器渲染概念。接下来的四节将深入介绍如何使用 React 实现服务器端渲染的最关键方面。
让我们这样做。
服务器端呈现的另一个术语是同构 JavaScript。这是一种表达 JavaScript 代码的奇特方式,可以在浏览器和 Node.js 中运行,无需修改。在本节中,在深入研究代码之前,我们将回顾同构 JavaScript 的基本概念。
React 的美妙之处在于它是一个位于渲染目标之上的小抽象层。到目前为止,目标一直是浏览器,但也可以是服务器。只要在幕后实现正确的转换调用,渲染目标可以是任何对象。
在服务器上呈现的情况下,我们只是将组件呈现为字符串。服务器无法实际显示呈现的 HTML;它所能做的就是将标记发送到浏览器。下图说明了这一想法:
我们已经确定可以在服务器上渲染 React 组件,并将渲染的输出发送到浏览器。问题是,为什么要在服务器上而不是浏览器上执行此操作?
对我个人来说,服务器端渲染背后的主要动机是提高性能。特别是,最初的渲染对用户来说感觉更快,这转化为总体更好的用户体验。一旦应用加载并准备就绪,它的速度有多快并不重要;初始加载时间会给用户留下持久的印象。
这种方法意味着初始负载的性能更好,原因有三:
- 在服务器上进行的呈现正在生成字符串;不需要计算差异或以任何方式与 DOM 交互。生成一系列呈现的标记本质上比在浏览器中呈现组件快。
- 呈现的 HTML 一到达就显示出来。任何需要在初始加载时运行的 JavaScript 代码都是在用户已经查看内容之后运行的。
- 从 API 获取数据的网络请求较少,因为这些请求已经在后端发生。
下图说明了这些性能理念:
您的应用很可能需要与您无法控制的 API 端点进行通信。例如,由许多不同的微服务端点组成的应用。我们很少可以不经修改就使用这些服务中的数据。相反,我们必须编写转换数据的代码,以便 React 组件可以使用这些数据。
如果我们在 Node.js 服务器中呈现组件,那么客户端和服务器都将使用此数据转换代码,因为在初始加载时,服务器需要与 API 对话,稍后,浏览器中的组件需要与 API 对话。
这不仅仅是转换从这些服务返回的数据。例如,我们还必须考虑向他们提供输入,比如在创建或修改资源时。
作为 React 程序员,我们需要做的基本调整是假设我们实现的任何给定组件都需要在服务器上呈现。这似乎是一个小小的调整,但问题在于细节。说到这里,让我们现在跳到一些代码示例。
js 中呈现组件的目的是呈现字符串,而不是试图找出将它们插入 DOM 的最佳方法。然后,字符串内容返回到浏览器,浏览器会立即将其显示给用户。让我们用一个基本的例子来说明一下。首先,我们要呈现一个简单的组件:
import React, { PropTypes } from 'react';
const App = ({ items }) => (
<ul>
{items.map(i => (
<li key={i}>{i}</li>
))}
</ul>
);
App.propTypes = {
items: PropTypes
.arrayOf(PropTypes.string)
.isRequired,
};
export default App; 接下来,让我们实现在浏览器请求时呈现此组件的服务器:
import React from 'react';
// The "renderToString()" function is like "render()",
// except it returns a rendered HTML string instead of
// manipulating the DOM.
import { renderToString } from 'react-dom/server';
import express from 'express';
// The component that we're going to render as a string.
import App from './App';
// The "doc()" function takes the rendered "content"
// of a React component and inserts it into an
// HTML document skeleton.
const doc = content =>
`
<!doctype html>
<html>
<head>
<title>Rendering to strings</title>
</head>
<body>
<div id="app">${content}</div>
</body>
</html>
`;
const app = express();
// The root URL of the APP, returns the rendered
// React component.
app.get('/', (req, res) => {
// Some properties to render...
const props = {
items: ['One', 'Two', 'Three'],
};
// Render the "App" component using
// "renderToString()"
const rendered = renderToString((
<App {...props} />
));
// Use the "doc()" function to build the final
// HTML that is sent to the browser.
res.send(doc(rendered));
});
app.listen(8080, () => {
console.log('Listening on 127.0.0.1:8080');
}); 现在如果您访问http://127.0.0.1:8080 在浏览器中,您将看到渲染的组件内容:
在这个例子中有两件事需要注意。首先是doc()功能。这将创建基本 HTML 文档模板,其中包含用于呈现内容的占位符。第二个是对renderToString()的呼叫,就像你习惯的render()呼叫一样。这将在服务器的请求处理程序中调用,并将呈现的字符串发送到浏览器。
在前面的示例中,我们在服务器中实现了一个请求处理程序,它响应根 URL(/的请求。显然,您的应用将需要处理多条路由。在上一章中,您学习了如何使用react-router包进行路由。现在,您将看到如何在 Node.js 中使用路由。
首先,让我们看看主要的应用组件:
import React, { PropTypes } from 'react';
import { Link } from 'react-router';
const App = ({ header, content }) => (
<section>
<header>
{header}
</header>
<main>
{content}
</main>
</section>
);
App.propTypes = {
header: PropTypes.node.isRequired,
content: PropTypes.node.isRequired,
};
App.defaultProps = {
header: (<h1>App</h1>),
content: (
<ul>
<li><Link to="first">First</Link></li>
<li><Link to="second">Second</Link></li>
</ul>
),
};
export default App; 它定义了一个简单的结构,可以说其他组件可以在其中填充空白。默认情况下,它将呈现指向应用其他两个页面的链接。现在让我们看一下 ToY0T0.模块:
import React from 'react';
import {
Router,
Route,
browserHistory,
} from 'react-router';
import App from './App';
import FirstHeader from './first/FirstHeader';
import FirstContent from './first/FirstContent';
import SecondHeader from './second/SecondHeader';
import SecondContent from './second/SecondContent';
const first = {
header: FirstHeader,
content: FirstContent,
};
const second = {
header: SecondHeader,
content: SecondContent,
};
export default (
<Router history={browserHistory}>
<Route path="/" component={App}>
<Route path="first" components={first} />
<Route path="second" components={second} />
</Route>
</Router>
); 再一次,这看起来应该很熟悉。我们正在从功能目录导入组件,并将它们分配给应用的子路由。这种配置在客户机上可以正常工作,但在服务器上可以吗?现在让我们来实现这一点:
import React from 'react';
import { renderToString } from 'react-dom/server';
import { match, RouterContext } from 'react-router';
import express from 'express';
// We need the main "routes" module in the
// server...
import routes from './routes';
const doc = content =>
`
<!doctype html>
<html>
<head>
<title>Backend Routing</title>
</head>
<body>
<div id="app">${content}</div>
</body>
</html>
`;
const app = express();
// Servers all paths, because the URL pattern matching
// is handled by react-router instead of Express.
app.get('/*', (req, res) => {
// The "match()" function from react-router
// will generate properties that we can pass
// to "<RouterContext>". We can then use
// "renderToString()" to generate our static
// markup.
match({
routes,
location: req.url,
}, (err, redirect, props) => {
if (err) {
res.status(500).send(err.message);
} else if (redirect) {
res.redirect(
302,
`${redirect.pathname}${redirect.search}`
);
} else if (props) {
const rendered = renderToString((
<RouterContext {...props} />
));
res.send(doc(rendered));
} else {
res.status(404).send('Not Found');
}
});
});
app.listen(8080, () => {
console.log('Listening on 127.0.0.1:8080');
}); 很好,我们现在有前端和后端路由!这到底是怎么回事?让我们从请求处理程序路径开始。我们已经更改了它,因此它现在是一个通配符(/*。现在,每个请求都会调用这个处理程序。
对于每个请求,处理程序都从react-router调用match()函数。这是一种针对当前 URL 测试路由配置的低级方法。传递给此函数的回调将使用错误、重定向和属性值进行调用。这是由你来处理这些相应的。
例如,如果出现错误,我们不希望渲染组件。相反,我们只是用一个500状态和一条错误消息来响应客户机。属性被传递给RouterContext组件,该组件基本上根据路由为我们呈现正确的组件。
现在,我们的应用开始看起来像一个真正的端到端 React 渲染解决方案。这是当您点击根 URL/时服务器呈现的内容:
如果点击/secondURL,Node.js 服务器将呈现正确的组件:
如果从主页导航到第一页,请求将返回到服务器。我们需要弄清楚如何将前端代码放到浏览器中,以便它可以在初始渲染后接管。
上一个示例中唯一缺少的是客户端 JavaScript 代码。没什么大不了的,对吧?用户实际上想要使用应用,而服务器需要交付客户机代码包。这是怎么回事?我们希望路由在前端和后端都能工作,而不需要修改路由本身。换句话说,服务器在初始请求中处理路由,然后当用户开始在应用中单击并移动时,浏览器接管。
这很容易做到。让我们创建一个主模块(从上一章的示例来看,它可能很熟悉):
import React from 'react';
import { render } from 'react-dom';
import routes from './routes';
// Nothing special here. React sees the checksum on the
// root element, and determines that there's no need
// to render data yet.
render(
routes,
document.getElementById('app')
); 客户端就是这样。我们没有像在 Express 请求处理程序中那样调用match()和renderToString(),而是使用render()呈现路由。React 了解后端呈现,并将查找已由 React 组件呈现的内容。在根元素上有一个校验和。这将与实际渲染组件的校验和进行比较。如果它们不匹配,将通过重新呈现组件来替换服务器内容。
换句话说,当一个组件在客户机上呈现的内容与在服务器上呈现的内容不同时,就会出现问题,因为没有任何好处;React 将强制重新渲染组件。
例如,尝试这样呈现 JSX 从来都不是一个好主意:
<strong>{typeof window}</strong> 这保证在客户端和服务器上有不同的 HTML 输出。在后端呈现不同内容的另一个问题是涉及 API 数据。接下来我们将讨论这个问题,但首先我们要在服务器代码中做一个调整,以使对账工作正常;我们需要插入到主网页包包的链接:
const doc = content =>
`
<!doctype html>
<html>
<head>
<title>Frontend Reconciliation</title>
<script src="/static/main-bundle.js" defer></script>
</head>
<body>
<div id="app">${content}</div>
</body>
</html>
`; 我们即将为 React 应用提供功能齐全的端到端渲染解决方案。剩下的最后一个问题是状态,更具体地说,是来自某个 API 端点的数据。我们的组件需要能够像在客户机上一样在服务器上获取这些数据,以便生成适当的内容。我们还必须将初始状态连同初始渲染内容一起传递给浏览器。否则,我们的代码将不知道在第一次渲染后什么时候发生了更改。
为了实现这一点,我将介绍保持状态的通量概念。流量是一个巨大的主题,远远超出了本书的范围。要知道:存储是保存应用状态的东西,当它发生更改时,会通知 React 组件。在执行其他操作之前,让我们先实现一个基本存储:
import EventEmitter from 'events';
import { fromJS } from 'immutable';
// A store is a simple state container that
// emits change events when the state is updated.
class Store extends EventEmitter {
// If "window" is defined,
// it means we're on the client and that we can
// expect "INITIAL_STATE" to be there. Otherwise,
// we're on the server and we need to set the initial
// state that's sent to the client.
data = fromJS(
typeof window !== 'undefined' ?
window.INITIAL_STATE :
{ firstContent: { items: [] } }
)
// Getter for "Immutable.js" state data...
get state() {
return this.data;
}
// Setter for "Immutable.js" state data...
set state(data) {
this.data = data;
this.emit('change', data);
}
}
export default new Store(); 当状态改变时,会发出一个change事件。根据我们所处的环境设置存储的初始状态。如果我们在客户机上,我们正在寻找一个INITIAL_STATE对象。这是从服务器发送的初始状态,因此此存储将在浏览器和 Node.js 中使用。
现在,让我们来看一个需要 API 数据的组件,以便呈现。它将使用存储来协调其后端渲染和前端渲染:
import React, { Component } from 'react';
import store from '../store';
import FirstContent from './FirstContent';
class FirstContentContainer extends Component {
// Static method that fetches data from an API
// endpoint for instances of this component.
static fetchData = () =>
new Promise(
resolve =>
setTimeout(() => {
resolve(['One', 'Two', 'Three']);
}, 1000)
).then((result) => {
// We have to make sure that the data is set properly
// in the store before returning the promise.
store.state = store.state
.updateIn(
['firstContent', 'items'],
items => items
.clear()
.push(...result)
);
return result;
});
// The default state of this component comes
// from the "store".
state = {
data: store.state.get('firstContent'),
}
// Getter for "Immutable.js" state data...
get data() {
return this.state.data;
}
// Setter for "Immutable.js" state data...
set data(data) {
this.setState({ data });
}
componentDidMount() {
// When the component mounts, we want to listen
// changes in store state and re-render when
// they happen.
store.on('change', () => {
this.data = store.state.get('firstContent');
});
const items = this.data.get('items');
// If the state hasn't been fetched yet, fetch it.
if (items.isEmpty()) {
FirstContentContainer.fetchData();
}
}
render() {
return (
<FirstContent {...this.data.toJS()} />
);
}
}
export default FirstContentContainer; 如您所见,组件的初始状态来自存储。然后,FirstContent组件就可以呈现它的列表,即使它一开始是空的。安装组件时,它会为存储设置一个侦听器。当存储更改状态时,它会导致此组件重新呈现,因为它正在调用setState()。
这个组件上还定义了一个fetchData()静态方法,它声明了这个组件具有的 API 依赖关系。它的任务是返回一个承诺,该承诺在 API 调用返回且存储状态已更新时得到解决。如果还没有数据,这个组件在 DOM 中挂载时会使用fetchData()方法。否则,这意味着服务器在呈现状态之前使用此方法获取状态。现在让我们把注意力转向服务器,看看这是如何做到的。
首先,我们有一个 helper 函数,用于获取给定请求所需的组件数据:
// Given a list of components returned from react-router
// "match()", find their data dependencies and return a
// promise that's resolved when all component data has
// been fetched.
const fetchComponentData = (components) =>
Promise.all(
components
.reduce((result, i) => {
// If the component is an object, it's
// the "components" property of a route. In this
// example, it's the "header" and "content"
// components. So, we need to iterate over the
// the object values to see if any of the components
// has a "fetchData()" method.
if (typeof i === 'object') {
for (const k of Object.keys(i)) {
if (i[k].hasOwnProperty('fetchData')) {
result.push(i[k]);
}
}
// Otherwise, we assume that the item is a component,
// and simply check if it has a "fetchData()" method.
} else if (i && i.fetchData) {
result.push(i);
}
return result;
}, [])
// Call "fetchData()" on all the components, mapping
// the promises to "Promise.all()".
.map(i => i.fetchData())
); components参数来自match()调用。这些都是需要渲染的组件,所以这个函数对它们进行迭代,并检查每个组件是否有一个fetchData()方法。如果是,则将其返回的承诺添加到结果中。
现在,让我们来看看使用这个函数的请求处理程序:
app.get('/*', (req, res) => {
match({
routes,
location: req.url,
}, (err, redirect, props) => {
if (err) {
res.status(500).send(err.message);
} else if (redirect) {
res.redirect(
302,
`${redirect.pathname}${redirect.search}`
);
} else if (props) {
// If a route match is found, we pass
// "props.components" to "fetchComponentData()".
// Only when this resolves do we render the
// components because we know the store has all
// the necessary component data now.
fetchComponentData(props.components).then(() => {
const rendered = renderToString((
<RouterContext {...props} />
));
res.send(doc(rendered, store.state.toJS()));
});
} else {
res.status(404).send('Not Found');
}
});
}); 此代码与本章中的代码基本相同,但有一个重要的更改。它现在将等待fetchComponentData()解决,然后再渲染任何内容。此时,如果有任何具有fetchData()方法的组件,则存储区将填充它们的数据。
例如,点击/firstURL 将导致 Node.js 获取FirstContentContainer依赖的数据,并设置初始存储状态。以下是此页面的外观:
剩下要做的唯一一件事就是确保这个初始存储状态被序列化并以某种方式传递给浏览器。
// In addition to the rendered component "content",
// this function now accepts the initial "state".
// This is set on "window.INITIAL_STATE" so that
// React can determine when the first change after
// the initial render happens.
const doc = (content, state) =>
`
<!doctype html>
<html>
<head>
<title>Fetching Data</title>
<script>
window.INITIAL_STATE = ${JSON.stringify(state)};
</script>
<script src="/static/main-bundle.js" defer></script>
</head>
<body>
<div id="app">${content}</div>
</body>
</html>
`; 正如你所看到的,窗户。INITIAL_STATE值通过存储状态的序列化版本传递。然后,客户端将重建此状态。这就是为什么我们能够避免这么多的网络呼叫,因为我们已经在商店里有了我们需要的东西。
如果打开/secondURL,您将看到如下内容:
毫不奇怪,单击此链接会将您带到第一页。这将导致一个新的网络调用(本例中模拟),因为该页面上的组件尚未加载。
在本章中,您了解了 React 可以在服务器上呈现,也可以在客户端呈现。这样做有很多原因,比如在前端和后端之间共享公共代码。服务器端呈现的主要优点是在初始页面加载时获得的性能提升。这将转化为更好的用户体验,从而获得更好的产品。
然后,从单页呈现开始,逐步改进服务器端 React 应用。然后介绍了路由、客户端协调和组件数据获取,以生成完整的后端渲染解决方案。
在下一章中,您将学习如何实现 React 引导组件以实现 mobile first 设计。






