什么是 SSR?
Server Slide Rendering,缩写为 SSR 即服务器端渲染。
现在很多的前端项目都是单页应用,为了良好的用户体验和前后端分离,我们会单独创建独立的客户端程序。现在已经有了很多成熟的构建客户端应用程序的框架,我们可以直接拿来使用并加以修改成项目需要的,当然,我们也可以完全根据自己的需求去搭建。
默认情况下,可以在浏览器中输出组件,进行生成 DOM 和操作 DOM 来实现用户交互。然而,有时候也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序,这就是服务器端渲染。
为什么使用 SSR
与传统 SPA (单页应用程序 (Single-Page Application)) 相比,服务器端渲染 (SSR) 的优势主要在于:
- 更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。
单页应用的页面都是通过 ajax 去请求数据,动态生成页面,而搜索引擎爬虫因为不能抓取JS生成后的内容,遇到单页应用项目,什么都抓取不到,不利于 SEO,而 SSR 会在服务器端生成页面发送到客户端,查看的是完整的页面,对于像 about 、contact 页等的页面更加方便 SEO。
- 解决首屏白屏问题。对于缓慢的网络情况或运行缓慢的设备,无需等待所有的 JavaScript 都完成下载并执行,才显示服务器渲染的标记,所以你的用户将会更快速地看到完整渲染的页面。通常可以产生更好的用户体验。
单页应用在第一次加载时,需要将一个打包好(requirejs 或 webpack 打包)的 js 发送到浏览器后,才能启动应用,这样会有些慢。如果在服务器端就预先完成渲染网页后,直接发送到浏览器,这样用户将会更快速地看到完整的渲染的页面,通常会产生更好的用户体验。
SSR 工作流程
由上图可以看到,服务端只生成 HTML 代码,而前端会生成一份 main.js 提供给服务端的 HTML 使用。这就是 React SSR 的工作流程。
准备
nodejs 建议 v8.9.4 版本以上
如果 nodejs 版本过低可能在运行程序时,报 async read ... 错误。
SSR 方法
- renderToString(React 15)
把 React 实例渲染成 HTML 标签。在 React 15 中,SSR 文件中的每个 HTML 元素都有一个 data-reactid 属性。在浏览器访问页面的时候,main.js 能识别到 HTML 的内容,不会执行 React.createElement 二次创建 DOM。而在 React 16 中,所有的 data-reactid 都从节点中移除了,页面看起来干净了许多。
- renderToStaticMarkup(React 15)
在 React 15 中,SSR 文件中的 HTML 元素没有 data-reactid 属性,页面看上去干净点。在浏览器访问页面的时候,main.js 不能识别到 HTML 内容,会执行 main.js 里面的 React.createElement 方法重新创建 DOM。
renderToString 和 renderToStaticMarkup 方法接收一个 React Element,并将它转化为 HTML 字符串。通过这两个方法,就可以在服务端生成 HTML,并在首次请求时将标记下发,以加快页面加载速度,并允许搜索引擎爬取你的页面以达到 SEO 优化的目的。
- renderToNodeStream (React 16)
支持直接渲染到节点流。渲染到流可以减少内容的第一个字节(TTFB)的渲染时间,在文档的下一部分生成之前,将文档的开头至结尾发送到浏览器。 当内容从服务器流式传输时,浏览器将开始解析 HTML 文档。速度是 renderToString 的三倍。
- renderToStaticNodeStream(React 16)
renderToStaticNodeStream() 与 renderToNodeStream() 相似,但此方法不会创建额外的 DOM 属性,若是静态页面,建议使用此方法,可以取出额外的属性节省一些字节。
React 16 为了优化页面初始加载速度,缩短 TTFB 时间,提供了这两个方法。这两个方法持续产生字节流,返回一个可输出 HTML 字符串的可读流。通过可读流输出的 HTML 与 ReactDOMServer.renderToString() 返回的 HTML 完全相同。
renderToNodeStream 和 renderToStaticNodeStream 方法返回 Readable。
当收到 renderTo(Static)NodeStream
方法时会返回 Readable
流,它处于暂停模式,并且还没有渲染。当调用 read 或 pipe Writable 时开始渲染,大部分 web 框架从 Writable
继承响应对象,因此,一般来说,只要将 Readable
发送即可得到响应。
renderToString 和 renderToNodeStream 的区别
renderToString 的功能是一口气同步产生最终 HTML,如果 React 组件树很庞大,那么这样一个同步过程就会比较耗时。假设渲染完整 HTML 需要 500 毫秒,那么当一个 HTTP / HTTPS 请求过来,500 毫秒之后才返回 HTML,显得不大合适,这也是为什么 React 16 提供了 renderToNodeStream 这个新 API 的原因。
renderToNodeStream 把渲染结果以“流”
的形式塞给 response 对象(这里的 response 是 express 或者 koa 的概念),这意味着不用等到所有 HTML 都渲染出来了才给浏览器端返回结果,也许 10 毫秒内就渲染出来了网页头部,那就没必要等到 500 毫秒全部网页都出来了才推给浏览器,“流”
的作用就是有多少内容给多少内容,这样用户只需要 10 毫秒多一点的延迟就可以看到网页内容,进一步改善了用户体验。
使用 create-react-app 创建一个 React 项目
目录结构如下:
开始
新建server目录,用于存放服务端代码。
项目中使用到了 ES6,所以还要配置下 .babelrc。
配置 .babelrc
{
"presets": [
"env",
"react"
],
"plugins": [
"transform-decorators-legacy",
"transform-runtime",
"react-hot-loader/babel",
"add-module-exports",
"transform-object-rest-spread",
"transform-class-properties",
[
"import",
{
"libraryName": "antd",
"style": true
}
]
]
}
过滤资源代码
server 的项目入口需要做一些预处理,因为服务端只需要纯的 HTML 代码,不过滤掉会报错。使用 asset-require-hook 过滤掉一些引入 css、图片这样的资源代码。
require("asset-require-hook")({
extensions: ["svg", "css", "less", "jpg", "png", "gif", "jpeg"],
name: '/static/media/[name].[ext]'
});
require("babel-core/register")();
require("babel-polyfill");
require("./app");
模板代码调整
public/index.html 模版代码需要调整,{{root}} 这个可以是任何可以替换的字符串,等下服务端会替换这段字符串。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">{{root}}</div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
服务端渲染页面
使用 renderToString 生成 html 代码,去替换掉 index.html 中的 {{root}} 部分。
import App from '../src/App';
import Koa from 'koa';
import React from 'react';
import Router from 'koa-router';
import fs from 'fs';
import koaStatic from 'koa-static';
import path from 'path';
import { renderToString } from 'react-dom/server';
// 配置文件
const config = {
port: 8888
};
// 实例化 koa
const app = new Koa();
// 静态资源
app.use(
koaStatic(path.join(__dirname, '../build'), {
maxage: 365 * 24 * 60 * 1000,
index: 'root'
// 这里配置不要写成'index'就可以了,因为在访问localhost:3030时,不能让服务默认去加载index.html文件,这里很容易掉进坑。
})
);
// 设置路由
app.use(
new Router()
.get('*', async (ctx, next) => {
ctx.response.type = 'html'; //指定content type
let shtml = '';
await new Promise((resolve, reject) => {
fs.readFile(path.join(__dirname, '../build/index.html'), 'utf-8', function(err, data) {
if (err) {
reject();
return console.log(err);
}
shtml = data;
resolve();
});
});
// 替换掉 {{root}} 为我们生成后的HTML
ctx.response.body = shtml.replace('{{root}}', renderToString(<App />));
})
.routes()
);
app.listen(config.port, function() {
console.log('服务器启动,监听 port: ' + config.port + ' running~');
});
去掉 hash 值
执行 npm run build 命令的时候会自动给资源加了 hash 值,而这个 hash 值,我们在 asset-require-hook 的时候去掉了,配置里面需要修改下,不然会出现图片不显示的问题。
module.exports = {
webpack: function(config, env) {
// ...add your webpack config
// console.log(JSON.stringify(config));
// 去掉hash值,解决asset-require-hook资源问题
config.module.rules.forEach(d => {
d.oneOf &&
d.oneOf.forEach(e => {
if (e && e.options && e.options.name) {
e.options.name = e.options.name.replace('[hash:8].', '');
}
});
});
return config;
}
};
现在,我们已经将一个最简单的项目完成了,由于服务端读取的资源是 build 目录下的,所以我们应先执行 npm run build 打包项目,再执行 npm run server 启动服务端项目。打开 http://localhost:8888/ 查看下:
再查看下代码结构:
{{root}} 已经成功被 HTML 标签替代,服务器渲染成功!
服务端使用 renderToNodeStream 生成页面
刚刚已经使用 renderToString 生成了页面,我们再尝试使用 renderToNodeStream 生成页面:
import App from '../src/App';
import Koa from 'koa';
import React from 'react';
import Router from 'koa-router';
import fs from 'fs';
import koaStatic from 'koa-static';
import path from 'path';
import { renderToNodeStream } from 'react-dom/server';
// 配置文件
const config = {
port: 8888
};
// 实例化 koa
const app = new Koa();
// 静态资源
app.use(
koaStatic(path.join(__dirname, '../build'), {
maxage: 365 * 24 * 60 * 1000,
index: 'root'
// 这里配置不要写成'index'就可以了,因为在访问localhost:3030时,不能让服务默认去加载index.html文件,这里很容易掉进坑。
})
);
// 设置路由
app.use(
new Router()
.get('*', async (ctx, next) => {
ctx.response.type = 'html'; //指定content type
let shtml = '';
await new Promise((resolve, reject) => {
fs.readFile(path.join(__dirname, '../build/index.html'), 'utf-8', function(err, data) {
if (err) {
reject();
return console.log(err);
}
shtml = data;
resolve();
});
});
// 替换掉 {{root}} 为我们生成后的HTML
ctx.response.body = shtml.replace('{{root}}', renderToNodeStream(<App />));
})
.routes()
);
app.listen(config.port, function() {
console.log('服务器启动,监听 port: ' + config.port + ' running~');
});
输入 http://localhost:8888/ 查看页面:
可以看到,renderToNodeStream 也同样生成了页面。
总结
我们现在已经学会了 React 15 和 React 16 的服务端渲染。可以总结为两点:
搭建 node 环境,可以访问到线上文件(build包)。
使用 renderToString 或者 renderToNodeStream 把 HTML 拼接好返回给前端。
注意:处理css、jsx、图片和 babel 。