初談 React SSR

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

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 流,它處于暫停模式,并且還沒有渲染。當調用 readpipe 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目錄,用于存放服務端代碼。
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/ 查看下:

hello world 網頁展示

再查看下代碼結構:

代碼結構

{{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 生成頁面

可以看到,renderToNodeStream 也同樣生成了頁面。

總結

我們現在已經學會了 React 15 和 React 16 的服務端渲染。可以總結為兩點:

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

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

注意:處理css、jsx、圖片和 babel 。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,362評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,577評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,486評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,852評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,600評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,944評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,944評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,108評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,652評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,385評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,616評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,111評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,798評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,205評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,537評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,334評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,570評論 2 379

推薦閱讀更多精彩內容