May 28, 2020
服务端渲染(SSR)区别于客户端渲染(CSR),当用户访问一个网址的时候,客户端渲染是服务端返回一部分 HTML 结构,通过执行 JavaScript 将余下的 HTML 渲染到页面上,而服务端渲染则是在服务端生成完整的 HTML 结构返回给浏览器。 以 React 为例:在客户端渲染时一般 html 的 body 中只有一个 id 为 root 的标签,页面在浏览器上渲染出来后,通过执行 JavaScript 将页面内容插入到这个标签中。而服务端渲染则在服务器端将页面内容插入到这个标签中,返回给浏览器的时候就已经有完整的页面结构了。
由此我们可以看到服务端渲染具有如下优势:
服务端渲染的这些优势,伴随着一定的代价:
用同一份代码在服务端和客户端分别执行一次。代码在服务端执行时渲染了页面,在客户端执行时接管了页面交互。
这样做是因为事件绑定处理是浏览器的行为,服务端代码只能生成 html string,而客户端代码执行的时候使用 ReactDOM.hydrate 进行事件绑定。
看完了上面这些概念,我们知道看了 SSR 的优势,那么如何用代码实现呢?
一个最简单的,基于 Koa 的 SSR 实现如下:
const Koa = require('koa');
const app = new Koa();
app.use(ctx => {
ctx.body = `
<html>
<head>
<title>title</title>
</head>
<body>
hello SSR
</body>
</html>
`;
});
app.listen(3000);
网页上展示的内容都是服务器端返回的,这样只做到了服务器端渲染,没有做同构处理,当页面复杂一些,有一些交互的时候就不行了。
引入 React 同构之后,一次完整的 React 同构 SSR 主要流程如下:
具体怎样实现呢?下面讲下几个关键步骤和一些细节:
源代码放到 /src 目录下,新建 /src/containers/Home.js 作为首页,containers 文件夹下放同构的组件,客户端和服务器端的入口文件都会引用这些组件来拼成页面:
👇 /src/containers/Home.js
import React, { Component } from 'react';
class Home extends Component {
render() {
return (
<div>Home page.</div>
);
}
}
export default Home;
在 /src 目录下新建 client 和 server 目录,分别存放客户端和服务器异构的代码
👇 /src/client/index.js 客户端使用 containers 目录下的组件进行 React 组件渲染
import React from 'react';
import Home from './containers/Home'
const App = () => {
return <Home />;
};
ReactDom.hydrate(<App />, document.getElementById('root'));
👇 /src/client/index.js 服务器端起服务,同时使用 containers 目录下的组件,使用 react-dom/server 提供的 renderToString 方法渲染页面
import Koa from 'koa';
const app = Koa();
import Home from './containers/Home';
import { renderToString } from 'react-dom/server';
const content = renderToString(<Home />);
app.use(async ctx => {
const htmlStr = `
<html>
<head>
<title>title</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`);
ctx.body = htmlStr;
});
app.listen('3000', function() {
console.log('started!');
});
安装依赖(react、react-dom、webpack、babel等),使用 webpack 进行打包。
从上面的流程图可以看出,我们至少需要打包两份代码:一份是服务器端代码,用于 Node 端起服务、渲染页面;另一份是浏览器端拿到 html 文件后请求的客户端代码,用于接管页面交互。
👇 webpack.base.js 导出公用的 webpack 配置
rules: [{
test: /\.js?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-react', ['@babel/preset-env', {
targets: {
browsers: ['last 2 versions']
}
}]]
}
}]
👇 webpack.client.js 导出客户端的配置,入口文件为 /src/client/index.js,导出到 public 目录下
const path = require('path');
const merge = require('webpack-merge');
const base = require('./webpack.base');
const clientConfig = {
mode: 'development',
entry: './src/client/index.js',
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'public')
}
};
module.exports = merge(base, clientConfig);
// 在server 端返回的 html 模板文件中引入导出的 index.js
// 这样浏览器请求页面的时候会下载客户端打包的 react 代码,进行页面渲染,接管页面交互
`
<script src="/index.js"></script>
`
👇 webpack.server.js 导出服务端的配置,入口文件为 /src/server/index.js,导出到 dist 目录下
const path = require('path');
const nodeExternals = require('webpack-node-externals');
const merge = require('webpack-merge');
const config = require('./webpack.base');
const serverConfig = {
target: 'node',
mode: 'development',
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
externals: [nodeExternals()]
};
module.exports = merge(config, serverConfig);
注意配置中的 target 为 node 是告诉 webpack 打包的对象是 node 端的代码,这样一些原生模块webpack 就不会做处理。
配置中的 webpack 的 externals 属性是告诉 webpack 在打包过程中,遇到 externals 中声明的模块不用处理。
使用时调 node 执行 /dist/bundle.js 即可起服务器,监听端口,返回服务器端渲染的页面了。
关于 Node 端代码打包,有兴趣的同学可以看小爝的这篇文章:https://zhuanlan.zhihu.com/p/20782320
服务端渲染只发生在第一次进入页面的时候,再跳转的时候不是服务器端跳转,而是 JS 控制跳转(react 代码接管)。
因此首次渲染应该在服务器端发送请求,将结果渲染到页面上返回,否则客户端渲染页面后再请求数据,页面会有空白。
服务器端发送请求后,应该将请求的结果存起来,交给客户端直接使用,客户端不应该再发请求了。以 react-redux 为例,请求回来的数据存放到 store 中,渲染时依据 store 中的数据,渲染具体做法是:
服务器端渲染的时候,给 window 挂一个 state = store.getState() (数据注水)
class Home extends React.Component {
// 同构部分代码将服务器端发的请求独立出来(我们在这里命名为 fetching 方法)
static fetching = ({dispatch}) => {
return dispatch(getListAction());
}
render()...
}
服务端代码使用 react-router-config 匹配到所渲染路由的组件
import { matchRoutes } from 'react-router-config';
app.use(async(ctx) => {
const matchedRoutes = matchRoutes(routes, ctx.request.path)
const store = getStore();
const promises = [];
// 遍历路由匹配到的组件中的 fetching 方法
matchedRoutes.forEach(item => {
if (item.route.fetching) {
// 代码优化:多个 promise 其中某个有错误, 走不到 all 里,而是直接进 catch,其他的请求会中断
// 因此用 Promise 再包一层,让每个 promise 请求的 catch 都 resolve 回来,保证一定会走到 all 里
const promise = new Promise((resolve, reject) => {
item.route.fetching(store).then(resolve).catch(resolve);
})
promises.push(promise);
}
})
// 函数执行后将返回数据存到 store
await Promise.all(promises).catch(error => {
ctx.app.emit('error', err, ctx);
})
// 服务端渲染时候使用这个 store
const content = renderToString(
<Provider store={store}>
...
</Provider>
);
// 数据注水:服务端渲染的 store 存到 window.context 下面
ctx.body = `
<html>
<body>
<div id="root">${content}</div>
<script>
window.context = { state: ${JSON.stringify(store.getState())} }
</script>
<script src="/index.js" ></script>
</body>
</html>
`;
ctx.body = await render(ctx);
});
客户端渲染的时候,从 window.context 拿出数据直接使用 (数据脱水)
// 数据脱水:客户端渲染初始化的时候,将 window.context 里的数据(即 SSR 请求返回的数据) 脱水,取出来,作为客户端渲染 store 中的初始值
export const getClientStore = () => {
const defaultState = window.context.state;
return createStore(reducer, defaultState);
};
客户端渲染的时候不走 fetching 方法,在 componentDidMount 的时候判断 list 是否有值,有值的话不发请求。
从其他页面跳转过来的时候,不走服务端渲染,因此 list 没有值,这时正常发送请求。
class Home extends React.Component {
// 服务端渲染时不会执行 componentDidMount 声明周期
componentDidMount() {
if (!this.props.list.length) {
this.props.getHomeList();
}
}
render()...
}
服务器端发起请求的时候,不会像浏览器端请求那样携带 cookie 等信息
这时候我们可以在 axios 的配置中加上 headers cookie,其他浏览器端的信息也可以通过这种形式引入
axios.create({
headers: {
cookie: req.get('cookie') || ''
}
});
服务器端渲染不只包含 DOM 结构和页面交互,还包扩 CSS,如果服务器端渲染的时候不渲染样式,那么页面加载的时候会有抖动,举个例子,src/containers/Home 组件下引入 CSS,给 body 设置背景色,打包后页面首次渲染出来是没有背景色的,执行 index.js 时 JS 将 CSS 模块的代码插入到 style 标签中,第二次渲染时才会有背景色,打开控制台的 Performance 面板可以看到渲染的详细过程:
为了解决这个问题,我们需要在服务器端渲染的时候将 CSS 代码插入到 style 标签中返回。
在 webpack.client.js 中配置 style-loader && css-loader 可以支持引入 css 模块,但是在 webpack.server.js 中这样配置则 build 时会报错:
webpack:///./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js?:93
var style = document.createElement('style');
^
ReferenceError: document is not defined
这是因为服务端环境没有全局变量 window,将 style-loader 替换成 isomorphic-style-loader 这个模块可以帮我们获取到所有模块的 CSS 代码,使用 insertCss 方法将这些代码插入到 style 标签中。
使用 React-helmet 将 <Helmet> 标签插入到组件中,即可修改 html 中 head 部分内容
<Helmet>
<title>Another Title</title>
</Helmet>
react-router-dom 模块 import { Redirect } from ‘react-router-dom’; 服务端渲染时,使用 react-router-config 的 renderRoutes 方法,可以自动获取路由下的重定向情况,并赋值到 context 上,组件里引入 react-router-dom,提供的 <Redirect /> 可以被 renderRoutes 获取到:
const context = {};
const content = renderToString(
<Provider store={store}>
<StaticRouter location={ctx.request.path} context={context}>
<div>
{renderRoutes(routes)}
</div>
</StaticRouter>
</Provider>
);
// 获取到路由重定向时
if (context.action === 'REPLACE') {
ctx.status = 301;
ctx.redirect('/');
} else if (context.NOT_FOUND) { // 在 404 页面设置 flag,SSR 时即可拿到 404 的 flag
ctx.status = 404;
}
在 route 配置中,所有配置项的末尾配置 NotFound 组件:
class NotFound extends React.Component {
componentWillMount() {
const { staticContext } = this.props;
// 在 404 页面设置 flag,SSR 时即可拿到 404 的 flag
staticContext && (staticContext.NOT_FOUND = true);
}
render() {
return <div>404!</div>;
}
}
// route 配置:
export default [{
path: '/',
component: APP,
routes: [
...allRoutes, // 所有路由
{
component: NotFound,
}
]
}];