什么是 SSR?
Server Slide Rendering,縮寫為 SSR 即服務(wù)器端渲染瑞眼。
現(xiàn)在很多的前端項(xiàng)目都是單頁(yè)應(yīng)用曹抬,為了良好的用戶體驗(yàn)和前后端分離,我們會(huì)單獨(dú)創(chuàng)建獨(dú)立的客戶端程序≌战裕現(xiàn)在已經(jīng)有了很多成熟的構(gòu)建客戶端應(yīng)用程序的框架重绷,我們可以直接拿來(lái)使用并加以修改成項(xiàng)目需要的,當(dāng)然膜毁,我們也可以完全根據(jù)自己的需求去搭建昭卓。
默認(rèn)情況下愤钾,可以在瀏覽器中輸出組件,進(jìn)行生成 DOM 和操作 DOM 來(lái)實(shí)現(xiàn)用戶交互候醒。然而能颁,有時(shí)候也可以將同一個(gè)組件渲染為服務(wù)器端的 HTML 字符串,將它們直接發(fā)送到瀏覽器倒淫,最后將這些靜態(tài)標(biāo)記"激活"為客戶端上完全可交互的應(yīng)用程序伙菊,這就是服務(wù)器端渲染。
為什么使用 SSR
與傳統(tǒng) SPA (單頁(yè)應(yīng)用程序 (Single-Page Application)) 相比昌简,服務(wù)器端渲染 (SSR) 的優(yōu)勢(shì)主要在于:
- 更好的 SEO占业,由于搜索引擎爬蟲抓取工具可以直接查看完全渲染的頁(yè)面。
單頁(yè)應(yīng)用的頁(yè)面都是通過 ajax 去請(qǐng)求數(shù)據(jù)纯赎,動(dòng)態(tài)生成頁(yè)面谦疾,而搜索引擎爬蟲因?yàn)椴荒茏トS生成后的內(nèi)容,遇到單頁(yè)應(yīng)用項(xiàng)目犬金,什么都抓取不到念恍,不利于 SEO,而 SSR 會(huì)在服務(wù)器端生成頁(yè)面發(fā)送到客戶端晚顷,查看的是完整的頁(yè)面峰伙,對(duì)于像 about 、contact 頁(yè)等的頁(yè)面更加方便 SEO该默。
- 解決首屏白屏問題瞳氓。對(duì)于緩慢的網(wǎng)絡(luò)情況或運(yùn)行緩慢的設(shè)備,無(wú)需等待所有的 JavaScript 都完成下載并執(zhí)行栓袖,才顯示服務(wù)器渲染的標(biāo)記匣摘,所以你的用戶將會(huì)更快速地看到完整渲染的頁(yè)面。通彻危可以產(chǎn)生更好的用戶體驗(yàn)音榜。
單頁(yè)應(yīng)用在第一次加載時(shí),需要將一個(gè)打包好(requirejs 或 webpack 打包)的 js 發(fā)送到瀏覽器后捧弃,才能啟動(dòng)應(yīng)用赠叼,這樣會(huì)有些慢。如果在服務(wù)器端就預(yù)先完成渲染網(wǎng)頁(yè)后违霞,直接發(fā)送到瀏覽器嘴办,這樣用戶將會(huì)更快速地看到完整的渲染的頁(yè)面,通常會(huì)產(chǎn)生更好的用戶體驗(yàn)买鸽。
SSR 工作流程
由上圖可以看到涧郊,服務(wù)端只生成 HTML 代碼,而前端會(huì)生成一份 main.js 提供給服務(wù)端的 HTML 使用癞谒。這就是 React SSR 的工作流程底燎。
準(zhǔn)備
nodejs 建議 v8.9.4 版本以上
如果 nodejs 版本過低可能在運(yùn)行程序時(shí),報(bào) async read ... 錯(cuò)誤弹砚。
SSR 方法
- renderToString(React 15)
把 React 實(shí)例渲染成 HTML 標(biāo)簽双仍。在 React 15 中,SSR 文件中的每個(gè) HTML 元素都有一個(gè) data-reactid 屬性桌吃。在瀏覽器訪問頁(yè)面的時(shí)候朱沃,main.js 能識(shí)別到 HTML 的內(nèi)容,不會(huì)執(zhí)行 React.createElement 二次創(chuàng)建 DOM茅诱。而在 React 16 中逗物,所有的 data-reactid 都從節(jié)點(diǎn)中移除了,頁(yè)面看起來(lái)干凈了許多瑟俭。
- renderToStaticMarkup(React 15)
在 React 15 中翎卓,SSR 文件中的 HTML 元素沒有 data-reactid 屬性,頁(yè)面看上去干凈點(diǎn)摆寄。在瀏覽器訪問頁(yè)面的時(shí)候失暴,main.js 不能識(shí)別到 HTML 內(nèi)容,會(huì)執(zhí)行 main.js 里面的 React.createElement 方法重新創(chuàng)建 DOM微饥。
renderToString 和 renderToStaticMarkup 方法接收一個(gè) React Element逗扒,并將它轉(zhuǎn)化為 HTML 字符串。通過這兩個(gè)方法欠橘,就可以在服務(wù)端生成 HTML矩肩,并在首次請(qǐng)求時(shí)將標(biāo)記下發(fā),以加快頁(yè)面加載速度肃续,并允許搜索引擎爬取你的頁(yè)面以達(dá)到 SEO 優(yōu)化的目的黍檩。
- renderToNodeStream (React 16)
支持直接渲染到節(jié)點(diǎn)流。渲染到流可以減少內(nèi)容的第一個(gè)字節(jié)(TTFB)的渲染時(shí)間痹升,在文檔的下一部分生成之前建炫,將文檔的開頭至結(jié)尾發(fā)送到瀏覽器。 當(dāng)內(nèi)容從服務(wù)器流式傳輸時(shí)疼蛾,瀏覽器將開始解析 HTML 文檔肛跌。速度是 renderToString 的三倍。
- renderToStaticNodeStream(React 16)
renderToStaticNodeStream() 與 renderToNodeStream() 相似察郁,但此方法不會(huì)創(chuàng)建額外的 DOM 屬性衍慎,若是靜態(tài)頁(yè)面,建議使用此方法皮钠,可以取出額外的屬性節(jié)省一些字節(jié)稳捆。
React 16 為了優(yōu)化頁(yè)面初始加載速度,縮短 TTFB 時(shí)間麦轰,提供了這兩個(gè)方法乔夯。這兩個(gè)方法持續(xù)產(chǎn)生字節(jié)流砖织,返回一個(gè)可輸出 HTML 字符串的可讀流。通過可讀流輸出的 HTML 與 ReactDOMServer.renderToString() 返回的 HTML 完全相同末荐。
renderToNodeStream 和 renderToStaticNodeStream 方法返回 Readable侧纯。
當(dāng)收到 renderTo(Static)NodeStream
方法時(shí)會(huì)返回 Readable
流,它處于暫停模式甲脏,并且還沒有渲染眶熬。當(dāng)調(diào)用 read 或 pipe Writable 時(shí)開始渲染,大部分 web 框架從 Writable
繼承響應(yīng)對(duì)象块请,因此娜氏,一般來(lái)說(shuō),只要將 Readable
發(fā)送即可得到響應(yīng)墩新。
renderToString 和 renderToNodeStream 的區(qū)別
renderToString 的功能是一口氣同步產(chǎn)生最終 HTML贸弥,如果 React 組件樹很龐大,那么這樣一個(gè)同步過程就會(huì)比較耗時(shí)抖棘。假設(shè)渲染完整 HTML 需要 500 毫秒茂腥,那么當(dāng)一個(gè) HTTP / HTTPS 請(qǐng)求過來(lái),500 毫秒之后才返回 HTML切省,顯得不大合適最岗,這也是為什么 React 16 提供了 renderToNodeStream 這個(gè)新 API 的原因。
renderToNodeStream 把渲染結(jié)果以“流”
的形式塞給 response 對(duì)象(這里的 response 是 express 或者 koa 的概念)朝捆,這意味著不用等到所有 HTML 都渲染出來(lái)了才給瀏覽器端返回結(jié)果般渡,也許 10 毫秒內(nèi)就渲染出來(lái)了網(wǎng)頁(yè)頭部,那就沒必要等到 500 毫秒全部網(wǎng)頁(yè)都出來(lái)了才推給瀏覽器芙盘,“流”
的作用就是有多少內(nèi)容給多少內(nèi)容驯用,這樣用戶只需要 10 毫秒多一點(diǎn)的延遲就可以看到網(wǎng)頁(yè)內(nèi)容,進(jìn)一步改善了用戶體驗(yàn)儒老。
使用 create-react-app 創(chuàng)建一個(gè) React 項(xiàng)目
目錄結(jié)構(gòu)如下:
開始
新建server目錄蝴乔,用于存放服務(wù)端代碼。
項(xiàng)目中使用到了 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 的項(xiàng)目入口需要做一些預(yù)處理,因?yàn)榉?wù)端只需要純的 HTML 代碼囚衔,不過濾掉會(huì)報(bào)錯(cuò)挖腰。使用 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");
模板代碼調(diào)整
public/index.html 模版代碼需要調(diào)整猴仑,{{root}} 這個(gè)可以是任何可以替換的字符串,等下服務(wù)端會(huì)替換這段字符串肥哎。
<!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>
服務(wù)端渲染頁(yè)面
使用 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
};
// 實(shí)例化 koa
const app = new Koa();
// 靜態(tài)資源
app.use(
koaStatic(path.join(__dirname, '../build'), {
maxage: 365 * 24 * 60 * 1000,
index: 'root'
// 這里配置不要寫成'index'就可以了,因?yàn)樵谠L問localhost:3030時(shí)崖飘,不能讓服務(wù)默認(rèn)去加載index.html文件稳衬,這里很容易掉進(jìn)坑。
})
);
// 設(shè)置路由
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('服務(wù)器啟動(dòng)坐漏,監(jiān)聽 port: ' + config.port + ' running~');
});
去掉 hash 值
執(zhí)行 npm run build 命令的時(shí)候會(huì)自動(dòng)給資源加了 hash 值,而這個(gè) hash 值碧信,我們?cè)?asset-require-hook 的時(shí)候去掉了赊琳,配置里面需要修改下,不然會(huì)出現(xiàn)圖片不顯示的問題砰碴。
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;
}
};
現(xiàn)在,我們已經(jīng)將一個(gè)最簡(jiǎn)單的項(xiàng)目完成了呈枉,由于服務(wù)端讀取的資源是 build 目錄下的趁尼,所以我們應(yīng)先執(zhí)行 npm run build 打包項(xiàng)目,再執(zhí)行 npm run server 啟動(dòng)服務(wù)端項(xiàng)目猖辫。打開 http://localhost:8888/ 查看下:
再查看下代碼結(jié)構(gòu):
{{root}} 已經(jīng)成功被 HTML 標(biāo)簽替代酥泞,服務(wù)器渲染成功!
服務(wù)端使用 renderToNodeStream 生成頁(yè)面
剛剛已經(jīng)使用 renderToString 生成了頁(yè)面啃憎,我們?cè)賴L試使用 renderToNodeStream 生成頁(yè)面:
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
};
// 實(shí)例化 koa
const app = new Koa();
// 靜態(tài)資源
app.use(
koaStatic(path.join(__dirname, '../build'), {
maxage: 365 * 24 * 60 * 1000,
index: 'root'
// 這里配置不要寫成'index'就可以了芝囤,因?yàn)樵谠L問localhost:3030時(shí),不能讓服務(wù)默認(rèn)去加載index.html文件辛萍,這里很容易掉進(jìn)坑悯姊。
})
);
// 設(shè)置路由
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('服務(wù)器啟動(dòng),監(jiān)聽 port: ' + config.port + ' running~');
});
輸入 http://localhost:8888/ 查看頁(yè)面:
可以看到贩毕,renderToNodeStream 也同樣生成了頁(yè)面悯许。
總結(jié)
我們現(xiàn)在已經(jīng)學(xué)會(huì)了 React 15 和 React 16 的服務(wù)端渲染』越祝可以總結(jié)為兩點(diǎn):
搭建 node 環(huán)境先壕,可以訪問到線上文件(build包)。
使用 renderToString 或者 renderToNodeStream 把 HTML 拼接好返回給前端睛藻。
注意:處理css启上、jsx、圖片和 babel 店印。