這⼀次徹底弄懂:React 服務端渲染

1、前言

在前端項目需要首屏渲染速度優化或 SEO 的場景下,大家或多或少都聽過到過服務端渲染 (SSR),但大多數人對服務端渲染具體實現和底層原理還是比較陌生的。本文基於公司官網開發時做了服務端渲染改造基礎上,系統理解和梳理這套體系的模式和邏輯,並寫了一些筆記和 Demo(文後鏈接) 便於深入理解。這次我們來以 React 爲例,把服務端渲染徹底講弄明白。本文主要有以下內容:

1.1 什麼是服務端渲染?

服務端渲染, SSR (Server-side Rendering) ,顧名思義,就是在瀏覽器發起頁面請求後由服務端完成頁面的 HTML 結構拼接,返回給瀏覽器解析後能直接構建出有內容的頁面。

用 node 實現一個簡單的 SSR

我們使用 Koa 框架來創建 node 服務:

//  demo1
var Koa = require("koa");
var app = new Koa();

// 對於任何請求,app將調用該函數處理請求:
app.use(async (ctx) ={
  // 將HTML字符串直接返回 
  ctx.body = `
    <html>
      <head>
         <title>ssr</title>
        </head>
        <body>
        <div id="root">
            <h1>hello server</h1>
            <p>word</p>
        </div>
      </body> 
      </html>`;
});
//監聽
app.listen(3001, () ={
  console.log("listen on 3001 port!");
});

啓動服務後訪問頁面,查看網頁源代碼是這樣:

npx create-react-app my-app

上面的例子就是一個簡單的服務端渲染,其服務側直接輸出了有內容的 HTML,瀏覽器解析之後就能渲染出頁面。與服務端渲染對應的是客戶端渲染 ,CSR(Client Side Rendering),通俗的講就是由客戶端完成頁面的渲染。其大致渲染流程是這樣:在瀏覽器請求頁面時,服務端先返回一個無具體內容的 HTML,瀏覽器還需要再加載並執行 JS,動態地將內容和數據渲染到頁面中,才能完成頁面具體內容的顯示。目前主流的 React ,Vue, Angular 等 SPA 頁面未經特殊處理均採用客戶端渲染。最常見腳手架 create-react-app 生成的項目就是客戶端渲染:

上面採用客戶端渲染的 HTML 頁面中 中無內容,需在瀏覽器端加載並執行 bundle.js 後才能構建出有內容頁面。

1.2 爲什麼用服務端渲染?

1.2.1 服務端渲染的優勢

相比於客戶端渲染,服務端渲染有什麼優勢?我們可以從下圖對比一下這兩種不同的渲染模式。

首屏時間更短

採用客戶端渲染的頁面,要進行 JS 文件拉取和 JS 代碼執行,動態創建 DOM 結構,客戶端邏輯越重,初始化需要執行的 JS 越多,首屏性能就越慢;客戶端渲染前置的第三方類庫 / 框架、polyfill 等都會在一定程度上拖慢首屏性能。Code splitting、lazy-load 等優化措施能夠緩解一部分,但優化空間相對有限。相比而言,服務端渲染的頁面直接拉取 HTMl 就能顯示內容,更短的首屏時間創造更多的可能性。

利於 SEO

在別人使用搜索引擎搜索相關的內容時,你的網頁排行能靠得更前,這樣你的流量就有越高,這就是 SEO 的意義所在。那爲什麼服務端渲染更利於爬蟲爬你的頁面呢?因爲對於很多搜索引擎爬蟲(非 google)HTML 返回是什麼內容就爬什麼內容,而不會動態執行 JS 代碼內容。對客戶端渲染的頁面來說,簡直無能爲力,因爲返回的 HTML 是一個空殼。而服務端渲染返回的 HTML 是有內容的。

SSR 的出現,就是爲了解決這些 CSR 的弊端。

1.2.2 權衡使用服務端渲染

並不是所有的 WEB 應用都必須使用 SSR,這需要開發者來權衡,因爲服務端渲染會帶來以下問題:

代碼複雜度增加。爲了實現服務端渲染,應用代碼中需要兼容服務端和客戶端兩種運行情況,部分代碼只能在客戶端運行,需要對其進行特殊處理,才能在服務器渲染應用程序中運行。

需要更多的服務器資源。由於服務器增加了渲染 HTML 的需求,使得原本只需要輸出靜態資源文件的 nodejs 服務,新增了數據獲取的 IO 和渲染 HTML 的 CPU 佔用,如果流量突然暴增,有可能導致服務器宕機,因此需要使用響應的緩存策略和準備相應的服務器負載。

涉及構建設置和部署的更多要求。與可以部署在任何靜態文件服務器上的完全靜態單頁面應用程序 (SPA) 不同,服務器渲染應用程序,需要處於 Node.js server 運行環境。

因此,在使用服務端渲染 SSR 之前,需要考慮投入產出比:是否真的需要 SEO,是否需要將首屏時間提升到極致。如果都沒有,使用 SSR 反而小題大做了。

1.3 服務端渲染的發展史

其實服務端渲染並不是什麼新奇的概念,前後端分層之前很長的一段時間裏都是以服務端渲染爲主(JSP、PHP),那時後端一把梭哈,在服務端生成完整的 HTML 頁面。但那時的服務端渲染和現在還是有本質的區別,存在比較多的弊端,每一個請求都要動態生成 HTML,存在大量的重複,服務器機器成本也相對比較高,前後端代碼完全摻雜在一起,開發維護難。

隨着業務不斷髮展變化,後端要寫的 JS 邏輯也越發複雜,而且 JS 有很多潛在的坑使後端越發覺得這是塊燙手山芋,於是逐漸出現了前後端分層。伴隨 AJAX 的興起,瀏覽器可以做到了不再重現請求頁面就可更新局部視圖。還可以利用客戶端免費的計算資源,後端側逐漸演變成了提供數據支持。jquery 的興起,良好的客戶端兼容性使 JS 不再受困於各種版本瀏覽器兼容問題,一統了前端天下。

此後伴隨 node 的興起,前後端分離越演越烈。前端能擺脫後端的依賴單獨起服務,三大框架 vue,react,angular 也迅勢崛起,以操作數據就能更新視圖,前端開發人員逐漸擺脫了與煩人的 Dom 操作打交道,能夠專心的關注業務和數據邏輯。前端同時探索出了功能插件,UI 庫,組件等多種代碼複用方案,形成了繁榮的前端生態。

但是三大框架採用客戶端渲染模式,隨着代碼邏輯的加重,首屏時間成了一個很大的問題,同時開發人員也發現 SEO 也出了問題,大多搜索引擎根本不會去執行 JS 代碼。但是也不可能再回頭走老路,於是前端又探索出了一套服務端渲染的框架來解決掉這些問題。此時的服務端渲染是建立在成熟的組組件,模塊生態之上,基於 Node.js 的同構方案成爲最佳實踐。

2、React 服務端渲染的原理

2.1 基本思路

React 服務端渲染流程

React 服務端渲染的基本思路,簡單理解就是將組件或頁面通過服務器生成 html 字符串,再發送到瀏覽器,最後將靜態標記 "混合" 爲客戶端上完全交互的應用程序。因爲要考慮 React 在服務端的運行情況,故相比之前講的多了在瀏覽器端綁定狀態與事件的過程。

我們可以結合下面的流程圖來一覽完整的 React 服務端渲染的全貌:當瀏覽器去請求一個頁面,前端服務器端接收到請求並執行 React 組件代碼,此時 React 代碼中可能包含向後端服務器發起請求,待請求完成返回的數據後,前端服務器組裝好有內容的 HTML 裏返給瀏覽器,瀏覽器解析 HTML 後已具備展示內容,但頁面並不具備交互能力。

下一階段,在返回的 HTMl 中還有 script 鏈接,瀏覽器再拉取 JS 並執行其包含的 React 代碼,其能在瀏覽器端執行完整的生命週期,並通過相關 API 實現複用此前返回 HTML 節點並添加事件的綁定,此時頁面才就具備完全交互能力。總的來說,react 服務端渲染包含有兩個過程:服務端渲染 + 客戶端 hydrate 渲染。服務端渲染在服務端渲染出了首屏內容;客戶端 hydrate 渲染複用服務端返回的節點,進行一次類似於 render 的 hydrate 渲染過程,把交互事件綁上去(此時頁面可交互),並接管頁面。

服務端處理後返回的

客戶端 “浸泡” 還原後的

核心思想(同構)

從上面的流程中可以看到,客戶端和服務端都要執行 React 代碼完成渲染,那是不是就要寫兩份代碼,供雙端使用? 當然不需要,也完全不合理。所謂同構,就是讓一份 React 代碼,既可以在服務端中執行,也可以在客戶端中執行。

SSR 技術棧

我們這裏簡單理了一下服務端渲染涉及到的技術棧:

知道了服務端渲染、同構的大概思路之後,下面從頭開始,一步一步完成具體實踐,深入理解其原理。

2.2 服務端如何渲染 React 組件?

按照之前流程的大概思路,我們首先需要將 React 組件在服務端轉換成 HTML 字符串,那怎麼做呢?React 提供的面向服務端的 API(react-dom/server),提供了相關方法能夠將 React 組件渲染成靜態的(HTML)標籤。下面我們簡單瞭解下 react-dom/server。

react-dom/server

react-dom/server 有 renderToString、renderToStaticMarkup,renderToNodeStream、renderToStaticNodeStream 四個方法能夠將 React 組件渲染成靜態的(HTML)標籤,前兩者能在客戶端和服務端運行,後兩者只能在服務端運行。

renderToStaticMarkup VS renderToString:renderToString 方法會在 React 內部創建的額外 DOM 屬性,例如 data-reactroot, 在相鄰文本節點之間生成 ,這些屬性是客戶端執行 hydrate 複用節點的關鍵所在,data-reactroot 屬性是服務端渲染的標誌之一。如果你希望把 React 當作靜態頁面生成器來使用,renderToStaticMarkup 方法會非常有用,因爲去除額外的屬性可以節省一些字節。

// Home.jsx
import React from "react";
const Home = () ={
  return (
    <div>
      <h2 onClick={() => console.log("hello")}>This is Home Page</h2>
      <p>Home is the page ..... more discribe</p>
    </div>
  );
};
export default Home;

我們使用 React-dom/server 下提供的 renderToString 方法,在服務端將其轉換爲 html 字符串:

//  server.js
import Koa from "koa";
import React from "react";
import { renderToString } from "react-dom/server";
import Home from "./containers/Home";

const app = new Koa();
app.use(async (ctx) ={
  // 核心api renderToString 將react組件轉化成html字符串
  const content = renderToString(<Home />);
  ctx.body = `
    <html>
      <head>
        <title>ssr</title>
      </head>
      <body>
        <div id="root">${content}</div>
      </body>
    </html>
   `;
});
app.listen(3002, () ={
  console.log("listen:3002");
});

可以看到上面代碼裏有 ES6 的 import 和 jsx 語法,不能直接運行在 node 環境,需要藉助 webpack 打包, 構建目標是 commonjs。新建 webpack.server.js 具體配置如下:

// webpack.server.js
const path = require("path");
const nodeExternals = require("webpack-node-externals");
module.exports = {
  mode: "development",
  target: "node",
  entry: "./server.js",
  resolve: {
    extensions: [".jsx"".js"".tsx"".ts"],
  },
  module: {
    rules: [
        {
        test: /\.jsx?$/,
        loader: "babel-loader",
        exclude: /node_modules/,
        options: {
          presets: ["@babel/preset-react""@babel/preset-env"],
          plugins: [
            [
              "@babel/plugin-transform-runtime",
              {
                absoluteRuntime: false,
                corejs: false,
                helpers: true,
                regenerator: true,
                version: "7.0.0-beta.0",
              },
            ],
          ],
        },
      },
    ],
  },
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "build"),
  },
  externals: [nodeExternals()],
};

在 webpack 構建完成後,可在 Node 環境運行 build/bundle.js,訪問頁面後查看網頁源代碼,可以看到,React 組件中的內容已經完整地包含在服務端返回到 html 裏面。我們成功邁出了服務端渲染第一步。此時,我們也有必要再深入瞭解 renderToString 到底做了什麼,提前踩坑!

renderToString

除了將 React 組件轉換成 html 字符串外,renderToString 還有做了下面這些:

  1. 會執行傳入的 React 組件的代碼,但是其只執行到 React 生命週期初始化過程的 render 及之前,即下面紅框的部分,其餘大部分生命週期函數在服務端都不執行;這也是服務端渲染的坑點之一。

2. renderToString 生成的產物中會包含一些額外生成特殊標記,代碼體積會有所增大,其中屬性 data-reactroot 是服務端渲染的標誌,便於後續客戶端通過 hydrate 複用 HTML 節點。在 React16 前後其產物也有差距:在 React 16 之前,服務端渲染採用的是基於字符串校驗和(string checksum)的 HTML 節點複用方式, 會額外生成生成 data-reactid、data-react-checksum 等屬性;React 16 改用單節點校驗來複用(服務端返回的)HTML 節點,不再生成 data-reactid、data-react-checksum 等體積佔用大戶,只在空白節點間多了 這樣的標記。

renderToString react16前
<div data-reactroot="" data-reactid="1" data-react-checksum="122239856">
  <span data-reactid="2">Welcome to React SSR!</span>  
  <!-- react-text: 3 --> Hello There! <!-- /react-text -->
</div>

// renderToString react16
<div data-reactroot=""><h1 class="here"><span>Welcome to React SSR!</span><!-- --> Hello There!</h1></div>
  1. 會被故意忽略掉的 on 開頭的的屬性,也就忽略掉了 react 代碼中事件處理,這是也是坑點之一。服務端返回的 html 裏沒有處理事件點擊,需要靠後續客戶端 js 執行綁定事件。
function shouldIgnoreAttribute(
  name: string,
  propertyInfo: PropertyInfo | null,
  isCustomComponentTag: boolean,
): boolean {
  if (propertyInfo !== null) {
    return propertyInfo.type === RESERVED;
  }
  if (isCustomComponentTag) {
    return false;
  }
  if (
    name.length > 2 &&
    (name[0] === 'o' || name[0] === 'O') &&
    (name[1] === 'n' || name[1] === 'N')
  ) {
    return true;
  }
  return false;
}

上面的例子我們可以看到 React 的代碼裏有點擊事件,但點擊後沒有反應。需要靠後續客戶端 js 執行綁定事件。如何實現?這就需要同構了。

2.3 實現基礎的同構

前文已經大概講了同構的概念,那爲什麼需要同構?之前的服務端代碼在處理點擊事件時故意忽略掉了這類屬性,在服務端執行的生命週期也是不完整的,此時的頁面是不具備交互能力的。同構,正是解決這些問題的關鍵,React 代碼在服務器上執行一遍之後,瀏覽器再去加載 JS 後又運行了一遍 React 代碼,完成事件綁定和完整生命週期的執行,從而才能成爲完全可交互頁面。

react-dom:hydrate

實現同構的另一個核心 API 是 React-dom 下的 **hydrate,**該方法能在客戶端初次渲染的時候去複用服務端返回的原本已經存在的 DOM 節點,於渲染過程中爲其附加交互行爲(事件監聽等),而不是重新創建 DOM 節點。需要注意是,服務端返回的 HTML 與客戶端渲染結果不一致時,出於性能考慮,hydrate 可以彌補文本內容的差異,但並不能保證修補屬性的差異,而是將錯就錯;只在 development 模式下對這些不一致的問題報 Warning,因此必須重視 SSR HydrationWarning,要當 Error 逐個解決。

那具體實現同構?

上面這裏我們提供了一個基本的架構圖,可以看到,服務端運行 React 生成 html 代碼我們已經基本實現,目前需要做的就是生產出客戶端執行的 index.js,那麼這個 index.js 我們如何生產出來呢?

**具體實踐 **

首先新建客戶端代碼 client.js,引入 React 組件,通過 ReactDom.hydrate 處理掛載到 Dom 節點, hydrate 是實現複用的關鍵。

// client.js
import React from "react";
import ReactDom from "react-dom";
import Home from "./containers/Home";

const App = () ={
  return <Home></Home>;
};

ReactDom.hydrate(<App />, document.getElementById("root"));

客戶端代碼也需要 webpack 打包處理,新建 webpack.client.js 具體配置如下,需要注意打包輸出在 public 目錄下,後續的靜態資源服務也起在了這個目錄下。

// webpack.client.js
const path = require("path");
const resolve = (dir) => path.resolve(__dirname, "./src", dir);
module.exports = {
  mode: "development",
  entry: "./client.js",
  output: {
    filename: "index.js",
    path: path.resolve(__dirname, "public"),
  },
  module: {
    rules: [
      // babel-loader處理js的一些配置
    ],
  },
};

服務端的靜態資源服務使用 koaStatic 起在 public 目錄,這樣就能通過外鏈訪問到打包出來的客戶端 js,同時我們在 html 中嵌入這個鏈接。

// server.js
import koaStatic from "koa-static";

const app = new Koa();
app.use(koaStatic("public"));

app.use(async (ctx) ={
  const content = renderToString(<Home />);
  console.log(content);
  ctx.body = `
    <html>
      <body>
        <div id="root">${content}</div>
            <script src="./index.js"></script>
      </body>
    </html>
   `;
});
app.listen(3003, () ={
  console.log("listen:3003");
});

簡單看下此時的代碼結構是這樣:

├── package.json
├── webpack.client.js
├── webpack.server.js
├── server.js
├── client.js
└── containers
    └── Home.jsx

通過上面一番操作,簡單的同構基本可以跑起來了,點擊對應位置後看到有了反應,查看網頁源代碼如下。可以看到多了 script 標籤的 index.js 這段,這是在客戶端執行的 js 代碼。

以上我們僅僅是就完成了一個 React 基礎的同構,但這還遠不夠,一個完整的前端頁面還包含路由,狀態管理,請求服務端數據等,這些也需要進行同構,且看下面爲你一一道來。

2.4 路由的同構

我們之前頁面只是一個頁面, 但實際開發的應用用一般都是多個頁面的,這就需要加入路由了。服務端渲染加入路由就涉及到同一份路由在不同端的執行,這就是路由的同構。

下面一步步來:首先加入 About 頁面,並書寫路由配置 routes.js

// routes.js
import Home from "./containers/home";
import About from "./containers/about";
export default [
  { path: "/", component: Home, exact: true },
  {
    path: "/about",
    component: About,
    exact: true,
  },
];

在客戶端側加入路由的寫法還是熟悉的寫法,考慮到頁面中可能涉及多級路由的渲染,這裏直接引入 react-router-config 中來處理:

// client.js
import { BrowserRouter } from "react-router-dom";
import { renderRoutes } from "react-router-config";
import Routes from "./routes";

const App = () ={
  return (
    <BrowserRouter>
      <div>{renderRoutes(Routes)}</div>
    </BrowserRouter>
  );
};

react-router 爲服務端提供了 StaticRouter,需顯式地向 location 傳 path。

// server.js
import { StaticRouter } from "react-router-dom";
import { renderToString } from "react-dom/server";
import Routes from "./routes";
import { renderRoutes } from "react-router-config";

const app = new Koa();
app.use(koaStatic("public"));

app.use(async (ctx) ={
  const content = renderToString(
    <StaticRouter location={ctx.request.path}>
      <div>{renderRoutes(Routes)}</div>
    </StaticRouter>
  )}

以上就完成了路由的配置,還比較簡單吧,此時頁面的路由跳轉基本沒問題了。

2.5 Redux 的同構

如何讓前端頁面的應用狀態可控、讓協作開發高效也是我們必須考慮的問題。Redux 作爲 React 最常見的狀態管理方案被很多項目引入來解決這一問題。那引入 Redux 如何被到同構項目中?這裏,我們還是簡單回顧一下 redux 運作流程,不熟悉的可以移步 redux 熟悉下。接下來開啓 Redux 的同構之旅。

第一步:創建全局 STORE

首先我們創建了一個 store.js,初始化配置並導出一個函數用來實例化 store,以提供給客戶端和服務端同時使用。爲什麼 store 要導出一個函數?因爲這段代碼後面服務端使用時,如果下面的 store 導出的是一個單例,所有的用戶用的是同一份 store,那將是災難性的結果。

// store.js
import { createStore, applyMiddleware, combineReducers } from "redux";
import thunk from "redux-thunk";
// 這裏提前引入了About組件下的store
import { reducer as AboutReducer } from "../containers/store";

const reducer = combineReducers({
  about: AboutReducer,
});

// 導出成函數的原因
export default () ={
  return createStore(reducer, applyMiddleware(thunk));
};
第二步:連接全局 STORE

客戶端的寫法還是熟悉的樣子:

//client.js
import { Provider } from "react-redux";
import getStore from "./store";

const App = () ={
  return (
    <Provider store={getStore()}>
      <BrowserRouter>
        <div>{renderRoutes(Routes)}</div>
      </BrowserRouter>
    </Provider>
  );
};

服務端 server.js 的寫法也是類似:

// server.js
import { Provider } from "react-redux";
import getStore from "./store";

const app = new Koa();
app.use(koaStatic("public"));

app.use(async (ctx) ={
  const content = renderToString(
    <Provider store={getStore()}>
      <StaticRouter location={ctx.request.path}>
        <div>{renderRoutes(Routes)}</div>
      </StaticRouter>
    </Provider>
  );
}
第三步:組件的 store

新建 About 組件使用的 store,其 action 和 reducer 的寫法如下,注意此時我們在 action 裏發起了一個異步請求。

// containers/store/reduccer.js
import { CHANGE_LIST } from "./constants";

const defaultState = { name: "panpan", age: 18, list: [] };
export default (state = defaultState, action) ={
  switch (action.type) {
    case CHANGE_LIST:
      return { ...state, list: action.payload };
    default:
      return state;
  }
};
// containers/store/action.js
import axios from "axios";
import { CHANGE_LIST } from "./constants";

const changeAction = (payload) =({
  type: CHANGE_LIST,
  payload,
});

const getHomeList = () ={
  return (dispatch) ={
    return axios.get("http://localhost:3008/mock/1").then((res) ={
      dispatch(changeAction(res.data.data || []));
    });
  };
};

export default {
  getHomeList,
};

About 組件連接 Redux:

// containers/About.js
import { connect } from "react-redux";
import { action } from "./store";

const About = () ={
  useEffect(() ={
    props.getList();
  }[]);
  // ...
}
const mapStateToProps = (state) =({
  name: state.about.name,
  age: state.about.age,
  list: state.about.list,
});
const mapDispatchToProps = (dispatch) =({
  getList() {
    dispatch(action.getHomeList());
  },
});

export default connect(mapStateToProps, mapDispatchToProps)(About);

經過一番改造後,項目變成了這樣:

├── package.json
├── mock.server.js
├── webpack.client.js
├── webpack.server.js
├── routes.js
├── server.js
├── client.js
└── store
    └── index.js
└── containers
    ├── Home.js
    ├── About.js 
    └── store
        ├── index.js
        ├── action.js
        ├── reducer.js
        └── constants.js

通過上述操作 Redux 基本可以跑起來了,可以發現寫法跟熟悉的客戶端渲染大體差不多,只多了引入 server 端的步驟。但是目前的 redux 還存在一定的問題,我們一起再來看。

服務端沒數據問題

上面的 redux 在同構項目中跑起來咋一看是沒什麼問題,但當我們把 js 禁用或直接查看源代碼時,就會發現 About 組件內並不存在異步請求的數據列表,換句話說服務器端的 store 的 list 始終是空的,服務端並沒有發起相應的數據請求。爲什麼會這樣呢?

分析一下:當瀏覽器發起頁面請求後,服務器接收到請求,此時服務器和客戶端的 store 的 list 都爲空, 接着服務端開始渲染執行 React 代碼,根據此前講 rendertostring 坑之一,服務端調用 React 代碼時裏面的生命週期的只到初始化時的 render 及之前,而 About 組件內發起的異步請求是在 useEffect 內,相當於是在 ComponentDidMount 階段發起請求,所以服務器端渲染時根本不會執行裏面的異步請求,因此服務器端的 store 的 list 始終是空的,所以我們看不到列表數據。之後客戶端拉取了 JS 並執行 React 代碼,React 在客戶端能夠執行完整的生命週期,故可以執行 useEffect 裏的函數獲取到數據並渲染到頁面。換而言之,目前獲取異步數據只是進行了後期的客戶端渲染階段。

如何讓服務端將獲得數據的操作執行一遍,以達到真正的服務端渲染的效果?這就是接下來要講的服務端渲染異步數據。

2.6 服務端渲染異步數據

上文的同構項目中跑起來後,我們是在組件的 useEffect 中發起的異步請求,服務端並不能執行到這一塊。那能不能在其他生命週期獲取異步請求數據?答案是不推薦,因爲 React16 採用了 Fiber 架構後,render 之前的生命週期都可能被中斷而執行多次,類似 getDerivedStateFromProps(靜態方法), ComponentWillMount(廢棄), UNSAFE_componentWillMount 的生命週期鉤子都有可能執行多次,所以不建議在這些生命週期中做有請求數據之類副作用的操作。而 componentDidMount 在 render 之後是確定被執行一次的,所以 React 開發中建議在 componentDidMount 生命週期函數進行異步數據的獲取。那有沒有其他的解決方案呢? React Router 恰好也考慮到了這點,提供了這樣一種解決方案,需要我們對路由進行一些改造。

React Router 解決方案

React Router 解決方案的基本思路:

首先,改造原有的路由,配置了一個 loadData 參數,這個參數代表要在服務端獲取數據的函數:

// router.js
import Home from "./containers/home";
import About from "./containers/about";
export default [
  { path: "/", component: Home, exact: true },
  {
    path: "/about",
    component: About,
    exact: true,
    loadData: About.loadData,
  },
];

在服務端匹配路徑對應的路由,如果這個路由對應的組件有 loadData 方法,那麼就執行一次, 並將 store 傳進 loadData 裏面去,注意 loadData 函數調用後需要返回 Promise 對象,等待 Promise.all 都 resolve 後,此時傳過去的 store 已經完成了更新,便可以在 renderToString 時放心使用:

// server.js
import { renderRoutes, matchRoutes } from "react-router-config";
import { Provider } from "react-redux";
import getStore from "./store";

app.use(async (ctx) ={
  const store = getStore();
  // 匹配到路徑對應的路由
  const matchArr = matchRoutes(Routes, ctx.request.path);
  let promiseArr = [];
  matchArr.forEach((item) ={
    // 判斷有沒有 loadData
    if (item.route.loadData) {
      // 要將store傳遞過去 
      // item.route.loadData() 返回的是一個promise
      promiseArr.push(item.route.loadData(store));
    }
  });
  // 等待異步完成,store已完成更新
  await Promise.all(promiseArr);
  const content = renderToString(
    <Provider store={store}>
      <StaticRouter location={ctx.request.path}>
        <div>{renderRoutes(Routes)}</div>
      </StaticRouter>
    </Provider>
  );
}

接下來是組件內 loadData 函數,發起異步請求,並返回一個 Promise

// containers/About.js
import { connect } from "react-redux";
import { action } from "./store";

const About = (props) ={
  useEffect(() ={
    props.getList();
  }[]);
  // ...
};
About.loadData = (store) ={
  //可能存在多個數據請求,所以用promise.all包一層 
  return Promise.all([store.dispatch(action.getHomeList())]);
};
const mapStateToProps = (state) =({
  name: state.about.name,
  age: state.about.age,
  list: state.about.list,
});
const mapDispatchToProps = (dispatch) =({
  getList() {
    dispatch(action.getHomeList());
  },
});

export default connect(mapStateToProps, mapDispatchToProps)(About);

通過以上改造,服務端可以獲取到異步數據了。但是眼尖的朋友可能注意到,頁面此時還存在問題,頁面中還不時存在 list 閃爍問題,這是什麼原因導致的呢?這就涉及到數據的同步問題。

數據的注水和脫水

讓我們來分析一下客戶端和服務端的運行流程:

可以看到客戶端和服務端的 store 都經歷了初始化置空的問題,導致 store 不同步, 那如何才能讓這兩個 store 的數據同步變化呢? 這就涉及到數據的注水和脫水。“注水”:在服務端獲取獲取之後,在返回的 html 代碼中加入這樣一個 script 標籤,這樣就將服務端 store 數據注入到了客戶端全局的 window.context 對象中。

// server.js
app.use(async (ctx) ={
  // ...
  ctx.body = `
    <html>
      <head>
        <title>ssr</title>
        
      </head>
      <body>
        <div id="root">${content}</div>
        <script>
         window.context = {
          state: ${JSON.stringify(store.getState())}
        }
        </script>
        <script src="./index.js"></script>
      </body>
    </html>
   `;
});

“脫水” 處理:把 window 上綁定的數據給到客戶端的 store,因此在 store.js 區分了客戶端和服務端不同的導出函數。

// store.js
// 客戶端使用
export const getClientStore = () ={
  const defaultState = window.context ? window.context.state : {};
  return createStore(reducer, defaultState, applyMiddleware(thunk));
};
// 服務端使用
export const getServerSore = () ={
  return createStore(reducer, applyMiddleware(thunk));
};

至此 redux 包含異步數據的獲取的同構就完成了。

2.7 css 的服務端渲染

爲什麼需要做 css 要服務端渲染?主要是解決頁面的 FOUC 閃屏問題。頁面如果沒做 css 的服務服務端渲染,我們一開始拉取到的 HTML 頁面是沒有樣式的,頁面的樣式正常顯示主要依賴於後面的客戶端渲染,我們知道客戶端渲染的時間相對要長很多,如果渲染前後存在較大的樣式差距,就會引起閃屏。

還是以 About 組件爲例,頁面中加入樣式:

.title {
  color: aqua;
  background: #999;
  height: 100px;
  line-height: 100px;
  font-size: 40px;
}
// containers/About.js
import styles from "./about.css";

const About = (props) ={
  // ...
  return (
    <h3 className={styles.title}>List Content</h3>
  );
};

需要 webpack 中相應的配置處理 css,我們先處理客戶端打包

{
    test: /\.css?$/,
    use: [
      "style-loader",
      {
        loader: "css-loader",
        options: {
          modules: true,
        },
      },
    ],
 }

上面的代碼跑起來,就會發現有明顯的閃爍,覆盤一下:頁面一開始 html 是沒樣式的,待到客戶端 js 執行完成後,頁面才突然有了樣式顯示正常。爲了避免這種閃爍帶來的不愉快體驗,服務端也需要進行 css 的渲染。

在服務端如何處理 css?

客戶端 webpack 採用 css-loader 處理後,style-loader 直接將樣式通過 DOM 操作進行插入,這對於瀏覽器環境很好很方便,但是對於服務端的 Node 環境,這就沒法愉快的玩耍了。Node 環境下可將樣式插入到生成的 html 字符串中,而不是進行 DOM 操作。這時就需要用到另外一個跨平臺的 loader:isomorphic-style-loader,在服務端的 webpack 配置是這樣:

// webpack.server.js
{
    test: /\.css?$/,
    use: [
      "isomorphic-style-loader",
      {
        loader: "css-loader",
        options: {
          modules: true,
        },
      },
    ],
  },

通過 isomorphic-style-loader 處理,我們可以在組件內直接通過 styles._getCss 即可拿到 CSS 代碼,但這還不夠,如何將拿到的 css 傳回到服務端 sever.js 裏從而塞入返回體呢?

// containers/About.js
import styles from "./about.css";

const About = (props) ={
  console.log(styles._getCss && styles._getCss());
  // ...
}
CSS 的服務端渲染

CSS 服務端渲染還需要藉助 StaticRouter 中已經幫我們準備的一個鉤子變量 context,傳入外部對象變量到 StaticRouter 到 context 裏。路由配置對象 routes 中的組件都能在服務端渲染的過程中拿到這個 context,這個 context 對於組件來說相當於 props.staticContext。將獲取到的 css 推入到 staticContext.css 裏,這樣服務端的 context 對象就完成了改變, 我們便可以拼接 css 到 head 中。

// server.js
app.use(async (ctx) ={
  // 初始化 context
  let context = { css: [] };
  const content = renderToString(
    <Provider store={store}>
      // StaticRouter 傳入context,組件接收到的props爲staticContext
      <StaticRouter location={ctx.request.path} context={context}>
        <div>{renderRoutes(Routes)}</div>
      </StaticRouter>
    </Provider>
  );
  // 將css插入到head裏面
  ctx.body = `
    <html>
      <head>
        <title>ssr</title>
        <style>${context.css.join("\n")}</style>
      </head>
      ...
    </html>
   `;
});
// containers/About.js
import styles from "./about.css";

const About = (props) ={
   // 只有服務端才傳過來了context,組件中props爲staticContext
  if (props.staticContext) {
    // 將css推入數組,改變了傳入的context
    props.staticContext.css.push(styles._getCss());
  }
  // ...
}

通過上面的操作,css 的服務端渲染基本能正常工作。需要注意的是 React-router 傳過來的 context 只有配置對象 routes 中的組件才能拿到,如果組件裏面再嵌入子組件,需要把 staticContext 透傳過去,才能對子組件 props.staticContext 進行相應操作。當然這裏更推薦官方 demo 裏的另一種寫法,且看下面。

更推薦寫法

我們可以查看 isomorphic-style-loader 的 demo,更推薦寫法是:客戶端、服務端都用 isomorphic-style-loader,webpack 處理客戶端 css 是這樣配置的:

//  webpack.client.js
{
    test: /\.css?$/,
    use: [
      "isomorphic-style-loader",
      {
        loader: "css-loader",
        options: {
          modules: true,
        },
      },
    ],
},

組件內的寫法也有相應改變,isomorphic-style-loader 提供了 hooks:useStyles

// containers/About.js
import useStyles from "isomorphic-style-loader/useStyles";
import styles from "./about.css";

const About = (props) ={
  useStyles(styles);
  // ...
}

在服務端的代碼裏是這樣的:

// server.js
import StyleContext from "isomorphic-style-loader/StyleContext";
// ...

app.use(async (ctx) ={
  const css = new Set();
  const insertCss = (...styles) =>
    styles.forEach((style) => css.add(style._getCss()));
  const content = renderToString(
    <Provider store={store}>
      <StyleContext.Provider value={{ insertCss }}>
        <StaticRouter location={ctx.request.path} context={context}>
          <div>{renderRoutes(Routes)}</div>
        </StaticRouter>
      </StyleContext.Provider>
    </Provider>
  );
  ctx.body = `
    <html>
      <head>
        <title>ssr</title>
        <style>${[...css].join("")}</style>
      </head>
      <body>
        <div id="root">${content}</div>
        <script>
         window.context = {
          state: ${JSON.stringify(store.getState())}
        }
        </script>
        <script src="./index.js"></script>
      </body>
    </html>
  `;
})

類似的,客戶端也需要做下面的調整:

// client.js
import StyleContext from "isomorphic-style-loader/StyleContext";
// ...

const App = () ={
  const insertCss = (...styles) ={
    const removeCss = styles.map((style) => style._insertCss());
    return () => removeCss.forEach((dispose) => dispose());
  };
  return (
    <Provider store={getStore()}>
      <StyleContext.Provider value={{ insertCss }}>
        <BrowserRouter>
          <div>{renderRoutes(Routes)}</div>
        </BrowserRouter>
      </StyleContext.Provider>
    </Provider>
  )
}

2.8 優化 title 和 description

頁面中的 title,keywords 和 description 在 SEO 中具有舉足輕重的地位。上面的 React 項目中初始只有一份 title 和 description,雖然不同頁面可使用 js 生成的動態 title 和 descroption,但這類信息搜索引擎是沒辦法抓取到的。爲了更好的 SEO,我們需要根據不同的頁面組件顯示來對應不同的網站標題和描述,這如何實現的呢?我們可以引入 react-helmet 來解決這個問題。

引入 react-helmet

組件內:

// containers/About.js
 import { Helmet } from "react-helmet";
 // ...
 return (
    <div>
      <Helmet>
        <meta charSet="utf-8" />
        <title>SSR About Page</title>
        <meta  />
      </Helmet>
      <div>
  )
  // ..

服務端 html 部分:

// server.js
const html = `
    <!doctype html>
    <html >
        <head>
            ${helmet.title.toString()}
            ${helmet.meta.toString()}
        </head>
    </html>
`;

3、開箱即用的 SSR 框架

Next.js

Next.js 是一款面向生產使用的 React 框架,提供了好些開箱即用的特性,支持靜態渲染 / 服務端渲染混用、支持 TypeScript、支持打包優化、支持按路由預加載等等:其中,完善的靜態渲染 / 服務端渲染支持讓 Next.js 在 React 生態中獨樹一幟。

Next.js 中的預渲染(Pre-rendering),具體的分爲兩種方式:

SSG(Static Site Generation):也叫 Static Generation,在編譯時生成靜態 HTML

SSR(Server-Side Rendering):也叫 Server Rendering,用戶請求到來時動態生成 HTML

與 SSR 相比,Next.js 更推崇的是 SSG,因爲其性能優勢更大(靜態內容可託管至 CDN,性能提升立竿見影)。因此建議優先考慮 SSG,只在 SSG 無法滿足的情況下(比如一些無法在編譯時靜態生成的個性化內容)才考慮 SSR、CSR。

UmiJS

Umi 很多功能是_參考 next.js_ 做的,要說有哪些地方不如 Umi,可能是不夠貼近業務,不夠接地氣。Umi 3 結合自身業務場景,在 SSR 上做了大量優化及開發體驗的提升,內置 SSR,一鍵開啓,開發調試方便。Umi 不耦合服務端框架,無論是哪種框架或者 Serverless 模式,都可以非常簡單進行集成。

icejs

icejs 是淘系前端飛冰團隊開發的一個基於 React 的漸進式框架。支持服務端渲染(即 SSR)能力,開發者可以按需一鍵開啓 SSR 的模式。

4、一些新的 API

新 Hook:useId

服務端、客戶端無法簡單生成穩定、唯一的 id 是個由來已久的問題,早在多年前就有人提過 issue。直到最近 React conf 2021 上再次提出這個問題,推出了官方 Hook——useId,可在服務端、客戶端生成唯一的 id,其背後的原理—— 每個 id 代表該組件在組件樹中的層級結構,具體的就不展開了,有興趣的可以去了解一下。

服務端 suspense

React 18 帶來了內置支持了 React.lazy 的 全新 SSR 架構, 性能優化的利器。這個架構能很大程度上提升用戶體驗:對比 React18 之前對整個應用 hydrate,現在可以做到對單個組件 hydrate,帶來的一個好處,就是可以設置組件的渲染優先級。對比 code splitting 的優勢在於如果同時設置了多個 suspense 組件,但是用戶點擊了之中某個組件,會優先 hydrate 那個被點擊的組件。

5、結語

以上就是本文關於 React 服務端渲染 (SSR) 的全部內容, 內容還是比較複雜的 。對於服務端渲染原理的學習可以幫助更好借鑑優秀的程序寫法和激發對日常代碼編程架構的思考,如果你更傾向箱即用的解決方案,那可以使用現有的 SSR 框架來搭建項目,這些框架的模版抽象和額外的功能擴展可以提供平滑的開箱體驗。

附 Demo 地址:https://github.com/hellopanpan/my-ssr-react

參考:

https://juejin.cn/post/6844904017487724557

https://juejin.cn/post/6844903881390964744

https://zhuanlan.zhihu.com/p/90746589

https://www.jianshu.com/p/3aa991ac3ce7

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/j2rB8qE5OOPmLHAS7qdCrQ