使用 React(和 Redux)进行服务器端渲染
简而言之,服务器端渲染
准备工作
让我们开始编码
我的 JavaScript 去哪儿了?!?
路由器,到处都是路由器!
启动 Redux
刚开始接触 React 的时候,我被它的强大所震撼。我一个接一个地构建组件,最后遇到一个 Facebook 的分享组件时,我突然发现:“哦,页面上根本没有内容!”
这可不太好,不是吗?那又是怎么回事?
大多数自动化网站爬虫在读取网站内容之前不会执行 JavaScript。
(谷歌可能会,但我无法确认)。而使用 React 时,在 JavaScript 执行之前页面上没有任何内容。这意味着爬虫无法读取你网站上的任何内容。
此外,React 并非将内容传递给访问者的最快方式。如果你查看浏览器开发者工具中的“网络”选项卡,你会注意到它首先请求页面,然后执行 JavaScript 包——在大多数情况下,之后你的 React 应用才会请求真正的内容。
在访问者看到任何内容之前,会发生很多次调用。
那么,面对这两个问题,我们能做些什么呢?
简而言之,服务器端渲染
有一种非常酷的技术叫做服务器端渲染。它的作用非常简单:在服务器上执行你的 React 应用,并将 HTML 结果发送到访客的客户端。客户端渲染 HTML,然后请求你的 JavaScript。它不需要再次渲染应用,而是将已经发送的 HTML 与 React 应用中的所有功能进行整合。
采用这种方法,内容无需客户端执行任何 JavaScript 即可显示。此外,您无需发送三个请求即可获取内容。访问者应该看到的所有内容都会在初始调用时发送。
准备工作
上面的解释听起来很简单。可惜,事实并非如此。实现过程中有一些复杂的部分需要我们理解,但我尽量保持简单易懂。
在开始之前,你需要安装webpack, webpack-cli, webpack-merge, webpack-node-external, express, babel, babel-preset-env, babel-preset-react, babel-loader
,当然react
还有react-dom
。现在应该可以了。
现在我们需要设置 Webpack。Webpack 是一个模块打包工具。我想你们大多数人都用过它,或者至少听说过它。我不想深入讲解 Webpack,因为它的内容足够写一篇(或者五篇)文章了。
为了实现服务器端渲染,我们将对 webpack 进行更复杂的设置。我们将把 webpack 配置拆分成多个文件,这样就不需要重复编写太多代码了。
最后,我们需要两个 webpack 配置。一个用于服务器端代码,一个用于客户端。这两个文件有很多共同点,所以我们创建一个名为 的文件,webpack.base.js
其中包含这两个配置共享的大部分配置。
// webpack.base.js
module.exports = {
stats: {
colors: true,
reasons: true,
chunks: false
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/
},
]
}
}
这里看到的是 webpack 的标准配置。导出一个 JavaScript 对象,其中包含一些 webpack 的配置信息。stats 部分用于修复 webpack 输出中的一些问题,module 部分则告诉 webpack 在遇到满足“test”正则表达式的文件时应该使用哪种类型的加载器。加载器是一个函数,它以某种方式转换文件的内容。
好的,我刚才说了不止一个设置。我们先来处理服务器端,因为我们很快就要开始处理服务器端了。
// webpack.server.js
const path = require('path');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base.js');
const config = {
target: 'node',
entry: ['./index.js'],
output: {
filename: 'server.js',
path: path.resolve(__dirname, 'build'),
},
resolve: {
extensions: ['.js', '.json']
},
}
module.exports = merge(baseConfig, config);
这是我们的服务器端配置,您可以在文件的末尾看到,我们将webpack.base.js
文件中的 baseConfig 对象与该文件中的内容合并,以获取服务器端的最终 webpack 配置。
输入键告诉 webpack 应该从哪里开始查找代码。输出键告诉 webpack 打包后的文件应该保存到哪里。
在我们进入服务器应用程序之前,让我们快速设置一下 Babel:
// .babelrc
{
"presets": [
"react",
["env", {
"targets": {
"browsers": "last 2 versions"
},
"useBuiltIns": "usage",
"loose": true,
"modules": false
}]
],
}
我无法详细解释它到底做了什么。那样的话文章就太长了。简而言之,它设置了 Babel 来理解常见的 React 内容。
让我们告诉 npm 关于 webpack 和我们刚刚创建的设置。在package.json
add 的脚本部分中:
// package.json add to scripts
{
scripts: {
...
"server": "webpack --watch --config webpack.server.js --mode development"
}
}
让我们开始编码
下一步是设置某种服务器来响应访问者的请求。本文将使用 Express,但您可以使用任何您想要的服务器。
让我们使用来安装 Express npm install express
。
之后我们设置一个服务器:
// index.js
import express from 'express';
const app = express();
app.use(express.static('public'));
app.get('*', (req, res) => {
// Here we are going to handle that react rendering and stuff.
});
app.listen(3000, () => {
console.log('Server is listenting to port 3000');
});
我们做了什么?我们设置了一个 Express 服务器,监听 3000 端口。我们添加了public
一个用于存放静态资源的文件夹。我们还创建了一个带通配符的 GET 路由。这意味着每次调用都会与此路由匹配。
你可能注意到了,我引入的是 Express viaimport
而不是require
。这是因为我们想要编写同构 JavaScript。也就是无需修改即可在服务器和客户端上运行的 JavaScript。
现在我们想创建一些可以在服务器上渲染的内容。我们来创建一个主页视图组件,用户访问我们的应用时应该看到它。
// client/Homepage.js
import React from 'react';
class Homepage extends React.Component {
render() {
return (
<div class="wrapper">
<h1>Welcome, this is the homepage</h1>
<button onClick={() => console.log("I', clicked!"}>
Click me!
</button>
</div>
)
}
}
export default Homepage;
很简单,不是吗?这里我唯一想说的是,我把所有客户端代码都放到了一个文件夹中client
。各位,一定要保持代码的整洁有序!
我们有服务器,有 React 组件,让我们将它们整合在一起。
// renderer.js
import { renderToString } from 'react-dom/server';
import Homepage from './client/Homepage';
function renderer() {
const content = renderToString(
<Homepage />
);
return content;
}
export default renderer;
为了让一切看起来简洁明了,我们将渲染代码放入了一个名为 renderer 的函数中。如你所见,它从 react-dom/server 导入了一个名为 的特殊函数renderToString()
。渲染到字符串与你可能知道的类似render()
。它只渲染一次组件,并将其转换为 HTML 字符串。
之后,我们调用renderer()
我们的 Express 服务器并将结果发送回给访问者。
// index.js
import express from 'express';
import renderer from './renderer';
const app = express();
app.get('*', (req, res) => {
const result = renderer();
res.send(result);
});
app.listen(3000, () => {
console.log('Server is listenting to port 3000');
});
瞧,第一个在服务器上渲染的 React 组件。你会注意到,无论你调用哪个路由,Homepage 组件每次都会被渲染。现在没关系,我们一会儿就解决这个问题。现在,请放心,我们已经启动并运行了服务器端渲染。
我的 JavaScript 去哪儿了?!?
您可能尝试在主页上使用,button
并且可能已经注意到“哦,没有控制台日志......我的 JavaScript 去哪儿了!?!”。
它消失了,是因为renderToString()
。它渲染了一个 HTML 字符串,但却删除了所有 JavaScript 代码。为了解决这个问题,我们需要为应用创建一个客户端包,并在访客客户端收到 HTML 代码后立即请求它。
为此,让我们回到 Webpack。我们需要前面提到的客户端设置。我们这样做是因为有些服务器代码在客户端上是不必要的,或者我们可能有一些极其机密的业务逻辑不想分享,或者可能是 API 机密……你懂的。
// webpack.client.js
const webpack = require('webpack');
const path = require('path');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base.js');
const config = {
context: __dirname,
entry: ['./client/index.js'],
output: {
path: path.join(__dirname, 'public'),
filename: 'bundle.js',
publicPath: '/',
pathinfo: false,
},
resolve: {
extensions: ['.js', '.json', '.scss']
},
optimization: {
minimize: true
},
};
module.exports = merge(baseConfig, config);
与之前的服务器端配置一样,我们构建一个客户端配置,然后将其与基础配置合并。需要注意的是,我们使用了另一个入口点,并创建了另一个输出文件。因此,在两种配置下运行 webpack 后,我们最终会得到两个文件server.js
:bundle.js
要启动 webpack,我们需要向 npm 添加一个新脚本:
// package.json add to scripts
{
scripts: {
...
"client": "webpack --watch --config webpack.client.js --mode development"
}
}
为了让 webpack 真正构建我们的代码,bundle.js
我们需要先编写它。所以我们开始吧:
// client/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import Homepage from './homepage';
const jsx = <Homepage />
ReactDOM.hydrate(jsx, document.getElementById('app'));
对于 React 应用来说,这是一个相当标准的设置。我们使用 Homepage 组件,并将其渲染到一个 ID 为“app”的 div 中。因为这里讨论的是客户端,所以当这段代码到达时,页面已经渲染好了。为了让 React 理解,我们不希望它重新渲染整个页面,我们需要使用hydrate
而不是render
来告诉 React 只在 HTML 代码中添加一些内容。
现在我们有了 JavaScript 文件来提供给客户端,但是刷新页面时仍然没有显示。这是因为我们需要告诉客户端真正请求该client.js
文件。为此,我们在函数中添加了一些代码renderer()
:
// renderer.js
import { renderToString } from 'react-dom/server';
import Homepage from './client/Homepage';
function renderer() {
const content = renderToString(
<Homepage />
);
const jsx = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="app">${content}</div>
<script src="/bundle.js"></script>
</body>
</html>
`;
return jsx;
}
export default renderer;
我们的渲染器返回的不再是简单的 React 组件,而是一个包含bundle.js
所有内容的完整网站。客户端渲染完 HTML 代码后,会立即请求包含完整代码的 JavaScript,这样你想要创建的按钮就能正常工作了。或者至少应该如此 ;)
路由器,到处都是路由器!
现在我们在服务器渲染的前端已经有了一个真正的 React 应用,我们可以讨论路由问题了。还记得吗?无论我们使用什么路由,最终都会转到主页。为了解决这个问题,我们安装了一个名为 的工具react-router
。我想你们都知道它的BrowserRouter
工作原理。问题是……我们不能使用 BrowserRouter,因为它专门用于根据浏览器 URL 渲染路由。但是我们的服务器上没有浏览器。所以我们现在有点棘手。
我们要做的是,使用 StaticRouter 作为服务器部分,并使用老式的浏览器路由器作为前端。
但是,每当我们向软件添加新路线时,我们不想编写两个单独的路线声明,对吗?
因此,让我们将所有路由放在一个文件中,并使用它来配置两个路由器。为此,有一个名为 的小工具react-router-config
。它允许我们编写一个配置数组,并提供了一个渲染路由的函数。
// routes.js
import Homepage from './client/Homepage';
import AboutUs from './client/AboutUs';
const routes = [
{
component: Homepage,
path: "/",
exact: true
},
{
component: AboutUs,
path: "/",
exact: true
}
]
export default routes;
数组中的每个对象都采用与Route
组件相同的参数react-router
。
在我们的客户端代码中,我们现在可以执行以下操作来获取我们的 BrowserRouter:
// client/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import routes from '../routes.js';
const jsx = (
<Router>
<div class="wrapper">{renderRoutes(routes)}</div>
</Router>
);
ReactDOM.hydrate(jsx, document.getElementById('app'));
我们使用renderRoutes
fromreact-router-config
并传递我们的路由数组。结果将是一个完整渲染的Route
组件包。
服务器端稍微复杂一些。这里我们使用StaticRouter
。静态路由器没有浏览器 URL,所以我们需要向它传递一个请求,以便它能够获取我们想要渲染哪个路由的必要信息。
请求从我们的快速应用程序传递到renderer
我们之前编写的用于呈现网站的函数中。
// renderer.js
import { renderToString } from 'react-dom/server';
import { StaticRouter as Router } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import routes from './routes.js';
function renderer(req) {
const context = {};
const content = renderToString(
<Router context={context} location={req.path} query={req.query}>
<div>{renderRoutes(routes)}</div>
</Router>
);
const jsx = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="app">${content}</div>
<script src="/bundle.js"></script>
</body>
</html>
`;
return {jsx, context};
}
export default renderer;
如您所见,我们的操作与客户端基本相同,但我们使用了StaticRouter
。我们将请求中的位置传递给它,以便它确定要执行的操作。此外,我们还向它发送了一个查询,以便我们能够对请求中的参数做出反应。最后一件事是上下文。上下文可以在路由器渲染的组件中访问。您可以使用它进行错误处理或设置渲染页面的状态。
例如,假设您想通过 API 加载产品,但请求的产品不存在。您希望服务器的初始调用返回 404 状态码而不是 200。这时就可以使用上下文。在组件中,只需在context.status = 404
渲染后设置 ,res.status(context.status)
如下所示:
// index.js
import express from 'express';
import renderer from './renderer';
const app = express();
app.get('*', (req, res) => {
const result = renderer(req);
const context = result.context;
if(context && context.status !== undefined) {
res.status(context.status);
}
res.send(result.jsx);
});
app.listen(3000, () => {
console.log('Server is listenting to port 3000');
});
好了,现在你的应用中的路由已经正常工作了。客户端和服务器端都一样。创建新路由时,你只需要将它们添加到 routes 数组中,两个路由器就知道该做什么了。
启动 Redux
你现在脑子疼吗?不疼?很好。因为我把最烦人(是的,很烦人!)的部分留到了最后:Redux。
事实上,我最后想谈的不仅仅是 Redux,我还想谈一下在服务器端渲染 React 应用时各种异步数据获取。问题在于,正如你记得的,我们习惯于renderToString
预渲染页面,然后将其发送到客户端。但renderToString
它只会渲染所有内容一次,因此并不关心渲染前可能需要从 API 获取的数据。但我们希望搜索引擎和其他工具能够获取我们遗漏的那些重要数据。
所以我们需要做的是在实际调用 之前获取渲染完整视图所需的所有数据renderToString
。因此,对于我们需要组合的每个视图,都需要某种“内容获取”函数来加载请求视图所需的每一条内容。
然后,在内容可用后,我们需要将数据传递给我们的 React 应用程序,然后在所有内容可用的情况下调用renderToString
。
首先,我们将 Redux 融入其中:npm install redux react-redux redux-thunk
现在我们已经安装了 Redux,让我们首先做简单的部分,即前端。
我们将在这里使用一种非标准的方法,但您最终会明白我们为什么这样做。
// createStore.js
import { applyMiddleware, createStore } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './rootReducer';
const middleware = applyMiddleware(thunk);
export default initialState => createStore(rootReducer, initialState, middleware);
我们编写了一个函数,它接受一个 initialState 参数并返回已创建的 store。我们将在客户端和服务端都使用它。
// client/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import {Provider} from 'react-redux';
import createStore from '../createStore';
import routes from '../routes';
import rootReducer from '../rootReducer';
const store = createStore({});
const jsx = (
<Provider store={store}>
<Router>
<div class="wrapper">{renderRoutes(routes)}</div>
</Router>
</Provider>
);
ReactDOM.hydrate(jsx, document.getElementById('app'));
我们用我们的新功能创建了一个商店createStore()
,并将应用程序包装在其中Provider
。
为了确保一切按计划进行,我们需要在数据加载完成后,获得视图渲染所需的所有数据。只要你有一个视图组件,所有数据都会加载到其中,然后传递给子组件,那么就很容易确定需要加载哪些数据。如果视图中有许多组件自行加载数据,那么很容易就会忘记一些数据获取操作。
为了能够加载数据,我们需要一个加载数据的函数,以便在渲染之前调用它。为了实现这一点,我们需要稍微重写一下视图组件。
我添加了一个到 Redux Store 的连接。我不想讨论如何做到这一点以及这一切意味着什么,因为我一周前写了一篇关于它的文章。
请随意阅读!
// client/Homepage.js
import React from 'react';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import fetchSomeData from './somewhere';
class Homepage extends React.Component {
componentDidMount() {
const {fetchSomeData: doFetch} = this.props;
doFetch();
}
render() {
return (
<div class="wrapper">
<h1>Welcome, this is the homepage</h1>
<button onClick={() => console.log("I', clicked!"}>
Click me!
</button>
</div>
)
}
}
const mapStateToProps = state => ({
someData: getSomeData(state);
})
const mapDispatchToProps = dispatch => bindActionCreators({
fetchSomeData,
}, dispatch);
function loadData(store, match, cookie) {
const actionToBeDispatched = [];
actionToBeDispatched.push(store.dispatch(fetchSomeData()));
return Promise.all(actionsToBeDispatched);
}
export default {
loadData,
component: connect(mapStateToProps, mapDispatchToProps)(Homepage),
};
在这个代码示例中,有两件事非常重要。首先,我们得到了一个loadData()
函数。它的作用是加载我们需要的每一位数据。loadData()
返回一个Promise
值,让我们知道所有操作何时完成。
另外,我们不像以前那样导出组件,而是导出一个包含 和loadData()
组件本身的对象。这个对象会在路由中拆分,这样每个路由仍然包含一个组件,但会有一个 loadData 键。
// routes.js
import Homepage from './client/Homepage';
import AboutUs from './client/AboutUs';
const routes = [
{
...Homepage,
path: "/",
exact: true
},
{
...AboutUs,
path: "/",
exact: true
}
]
export default routes;
好的,让我们把所有东西整合在一起!
// index.js
import express from 'express';
import { matchRoutes } from 'react-router-config';
import renderer from './renderer';
import createStore from './createStore';
const app = express();
app.get('*', (req, res) => {
const store = createStore({});
const promises = matchRoutes(routes, req.path).map(
({ route, match }) => (route.loadData ? route.loadData(store, match, req.get('cookie') || {}, req.query) : null)
);
Promise.all(promises).then(() => {
const result = renderer(req, store);
const context = result.context;
if(context && context.status !== undefined) {
res.status(context.status);
}
res.send(result.jsx);
});
});
app.listen(3000, () => {
console.log('Server is listenting to port 3000');
});
在路由中,我们使用createStore
之前编写的 创建一个存储。然后,我们使用一个名为 的函数,matchRoutes()
该函数接受路由数组和请求路径作为参数。它返回一个匹配路由的数组。每个元素都包含路由和来自 的匹配数据react-router
。
这样,我们就可以调用loadData()
路由对象中的函数,并等待它完成数据加载。之后,我们将存储的内容传递给renderer()
函数,以确保一切正常渲染。
loadData()
本例中传入了一个 Cookie 和一个查询参数。它们并非必需,但我决定保留它们,以便大家能够了解如何在loadData()
函数中添加 Cookie 或查询参数。
我们快完成了。最后一件事是确保客户端加载时的状态与服务器一致。现在,服务器会正确设置所有内容,然后将其发送给客户端。但是客户端会把事情搞乱,因为它不知道服务器端存储的内容是什么。所以我们需要将存储发送给客户端。
让我们更新我们的renderer()
// renderer.js
import { renderToString } from 'react-dom/server';
import { StaticRouter as Router } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import routes from './routes.js';
function renderer(req, store) {
const context = {};
const state = store.getState();
const content = renderToString(
<Router context={context} location={req.path} query={req.query}>
<div>{renderRoutes(routes)}</div>
</Router>
);
const jsx = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="app">${content}</div>
<script>
window.STORE_DATA = ${JSON.stringify(state).replace('<script>', '')}
</script>
<script src="/bundle.js"></script>
</body>
</html>
`;
return {jsx, context};
}
export default renderer;
我们习惯JSON.stringify()
将整个商店的数据放入一个变量中。
我们会删除所有script
标签,因为它们会造成混乱,并且存在潜在的安全问题。
你还记得我们之前写过一个叫 的函数吗createStore()
?它当时没什么意义,因为……呃……有点多余。现在我们的状态存储在一个变量中,我们可以把这个状态存入createStore()
函数中,从而创建一个 store。然后恍然大悟 ;)
// client/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import {Provider} from 'react-redux';
import createStore from '../createStore';
import routes from '../routes';
import rootReducer from '../rootReducer';
const store = createStore(window.STORE_DATA);
const jsx = (
<Provider store={store}>
<Router>
<div class="wrapper">{renderRoutes(routes)}</div>
</Router>
</Provider>
);
ReactDOM.hydrate(jsx, document.getElementById('app'));
好的,就是这样。虽然路途漫长,但我们终于走到了尽头。我们的 React 应用可以运行了,路由也设置好了,Redux Store 也已到位,并且我们有一个在客户端上进行 hydrated 的服务器端渲染应用。你可以在浏览器中禁用 JavaScript 来测试。页面应该会按照你想要的方式显示,只是没有任何 JavaScript 函数。
这里还有一些事情我没有解决。比如前端的身份验证。你可以使用 Cookie 来实现,但还是有点棘手。这篇文章够长了,也许我下次再讨论这个问题。
一如既往,我希望你有所收获,也希望你喜欢这篇文章。如果喜欢,欢迎点赞或留言。我非常感激你的提示、建议,或者仅仅是一句友好的“你好”。
感谢阅读!
鏂囩珷鏉ユ簮锛�https://dev.to/markusclaus/server-side-rendering-with-react-and-redux-59od