初談 React SSR

什么是 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 工作流程

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)用 readpipe 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)如下:

項(xiàng)目目錄結(jié)構(gòu)

開始

新建server目錄蝴乔,用于存放服務(wù)端代碼。
server 目錄

項(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/ 查看下:

hello world 網(wǎng)頁(yè)展示

再查看下代碼結(jié)構(gòu):

代碼結(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è)面

可以看到贩毕,renderToNodeStream 也同樣生成了頁(yè)面悯许。

總結(jié)

我們現(xiàn)在已經(jīng)學(xué)會(huì)了 React 15 和 React 16 的服務(wù)端渲染』越祝可以總結(jié)為兩點(diǎn):

  1. 搭建 node 環(huán)境先壕,可以訪問到線上文件(build包)。

  2. 使用 renderToString 或者 renderToNodeStream 把 HTML 拼接好返回給前端睛藻。

注意:處理css启上、jsx、圖片和 babel 店印。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末冈在,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子按摘,更是在濱河造成了極大的恐慌包券,老刑警劉巖纫谅,帶你破解...
    沈念sama閱讀 219,427評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異溅固,居然都是意外死亡蟆沫,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,551評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門藕溅,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)齿尽,“玉大人,你說(shuō)我怎么就攤上這事亮元∶图疲” “怎么了?”我有些...
    開封第一講書人閱讀 165,747評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵爆捞,是天一觀的道長(zhǎng)奉瘤。 經(jīng)常有香客問我,道長(zhǎng)煮甥,這世上最難降的妖魔是什么盗温? 我笑而不...
    開封第一講書人閱讀 58,939評(píng)論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮成肘,結(jié)果婚禮上卖局,老公的妹妹穿的比我還像新娘。我一直安慰自己双霍,他們只是感情好吼驶,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,955評(píng)論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著店煞,像睡著了一般蟹演。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上顷蟀,一...
    開封第一講書人閱讀 51,737評(píng)論 1 305
  • 那天酒请,我揣著相機(jī)與錄音,去河邊找鬼鸣个。 笑死羞反,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的囤萤。 我是一名探鬼主播昼窗,決...
    沈念sama閱讀 40,448評(píng)論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼涛舍!你這毒婦竟也來(lái)了澄惊?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,352評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎掸驱,沒想到半個(gè)月后肛搬,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,834評(píng)論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡毕贼,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,992評(píng)論 3 338
  • 正文 我和宋清朗相戀三年温赔,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片鬼癣。...
    茶點(diǎn)故事閱讀 40,133評(píng)論 1 351
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡陶贼,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出待秃,到底是詐尸還是另有隱情骇窍,我是刑警寧澤,帶...
    沈念sama閱讀 35,815評(píng)論 5 346
  • 正文 年R本政府宣布锥余,位于F島的核電站,受9級(jí)特大地震影響痢掠,放射性物質(zhì)發(fā)生泄漏驱犹。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,477評(píng)論 3 331
  • 文/蒙蒙 一足画、第九天 我趴在偏房一處隱蔽的房頂上張望雄驹。 院中可真熱鬧,春花似錦淹辞、人聲如沸医舆。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,022評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)蔬将。三九已至,卻和暖如春央星,著一層夾襖步出監(jiān)牢的瞬間霞怀,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,147評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工莉给, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留毙石,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,398評(píng)論 3 373
  • 正文 我出身青樓颓遏,卻偏偏與公主長(zhǎng)得像徐矩,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子叁幢,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,077評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容