構建高效能的通用 SSR 服務

語言: CN / TW / HK

哪些場景需要 SSR 服務

現如今 Web 前端的主流 UI 框架都是 React 和 Vue,以及 Angular,這些框架主流的做法都是在客戶端基於 JS 來完成 HTML 內容結構的渲染。對於一些 HTML 內容結構主要由動態資料構成的複雜頁面,在效能表現一般的移動裝置上渲染的計算就會顯得有點吃力,那麼此時如果有 SSR 服務在服務端完成 HTML 內容結構的渲染,可以顯著提升渲染的效能。

當然還有頁面需要做 SEO 的場景,不過這不是本文想要表述的範疇。

為什麼要搭建通用的 SSR 服務

其實對於 React 或 Vue 都有相應的開箱即用的框架,可以提供現成的 SSR 服務,但單純為了使用 SSR 服務而去使用這些框架就會有點重,還會對前端的整體架構有諸多限制,並且 SSR 的效能在這些框架中應該還有進一步壓榨的空間。

對於前端應用來說,SSR 服務應該是一個可選項,只對效能敏感的頁面開啟,所以 SSR 服務與前端的應用盡量不要耦合在一起,同時為了確保 SSR 服務的效能,也儘量不要在服務中耦合其他亂七八糟的功能。

如果團隊中既有 React 又有 Vue,在一個 SSR 服務中同時滿足 React 和 Vue 的支援也是可行的。

所以一個通用的 SSR 服務應該儘量和前端應用在架構設計上保持松耦合,保持功能的單一性,如果團隊確實需要,可以同時支援 React 和 Vue。

SSR 的核心流程

SSR 本質上是把在客戶端完成 HTML 內容結構的渲染輸出的工作遷移到了服務端。對於現代的前端框架 React 和 Vue,它們都支援同構的渲染,這意味著可以直接將 React 和 Vue 的 JavaScript 程式碼放在由 Node.js 編寫的服務端上執行。

一般來說「HTML 內容結構的渲染輸出」的結果應該是“動態”的,比如每個使用者請求的頁面內容是不一樣的,或者每隔一段時間內容就會更新等。如果是一個純靜態的頁面,那麼 SSR 的意義也不大,因為這完全可以採用預渲染的方案,而不是在每次請求時才去做渲染輸出。

動態的 HTML 內容結構的渲染輸出意味著需要在服務端完成首屏資料的請求,對於非首屏資料,可以在客戶端進行按需載入。

那麼 SSR 服務主要做的事就是當請求頁面時預請求好首屏資料,然後再使用首屏資料去填充應用來生成 HTML 內容,最終將 HTML 內容傳輸給客戶端。

當一個頁面請求到達 SSR 服務端時,總結 SSR 的核心流程如下: 1. 服務端:請求應用首屏資料。 2. 服務端:將首屏資料填充到應用中,執行服務端渲染,生成 HTML 內容。 3. 前端:基於服務端響應的 HTML 內容來“啟用”應用。

使用者最終感知到的渲染時間軸如圖。

基於以上的核心流程,我們來一步步展開細說流程中面臨的挑戰。

請求首屏資料

通常來說,應用的首屏資料請求是耦合在應用常規的業務程式碼中,需要將耦合的請求引數和條件抽取成獨立的模組 getInitData ,以方便服務端進行呼叫。

getInitData 提供的是發起首屏資料請求的引數,而不是具體的請求方法,因為前端的 Ajax/Fetch 無法直接在 Node.js 服務端環境中執行,Node.js 中對應的是 http.request ,不過有些網路請求庫像 Axios 由於封裝得當可以跨端執行。同時首屏的請求可能會有多個,甚至相互之間還可能存在依賴等,需要注意標準化的抽象。抽象如果比較合理,其實 getInitData 也可以是一個同構的模組,可以同時執行在客戶端和服務端。

在客戶端基本只會使用 HTTP 協議來請求首屏資料,但是在服務端其實有更多選擇,除了 HTTP,還可以使用 RPC,這樣在服務間的呼叫會更高效。

getInitData 在設計時可以做到宿主無關,以及協議無關,一個典型的樣例如下:

// getInitData.js
function getInitData() {
    return {
        example: {
            url: "/api/example",
            method: "get",
            responseValidator: function(res) {
                return res.code === 0
            }
        }
    };
}

module.exports = getInitData;

執行服務端渲染

請求完首屏資料,就可以開始將首屏資料填充到應用中,這裡就開始涉及到前端應用在服務端開始執行的流程了,情況逐漸複雜,這裡會面臨一些問題。

避免宿主環境被“汙染”

由於宿主環境的差異,前端的應用在服務端執行時可能會從 window 讀取一些全域性變數或環境資訊,而在 Node.js 執行環境中是沒有 window 環境的,當然可以在執行前提前 Mock 出一個包含簡版 BOM 的 window 環境,DOM 環境就不要想了。

既然有讀的問題,那就無法避免的會有全域性變數往 window 寫入全域性變數的問題,如果不做處理直接執行時就會“汙染”服務端的環境,可能有人會說,汙染就汙染吧,能有什麼影響呢?實際上影響可能非常嚴重,每一個 HTML 請求是要做無狀態化的,如果在請求時會往一個全域性名稱空間中寫入一些資料,然後再讀取,那麼併發請求時,就變成了一個有狀態的請求,那就全亂套了。

// 請求 A 寫入並讀取資料
/GET a.html
window.foo = 'a';

// 請求 B 與 A 同時發生
/GET b.html
window.foo = 'b';

// 這時的 window.foo 就變成了一個“薛定諤的 foo”

要解決環境汙染的問題,其實就需要引入沙箱,在 Node.js 中提供的 vm 模組就是用於沙箱的。

在請求發生時,構造一個沙箱環境,往沙箱環境裡寫入一個 Mock 的 window 環境,然後讓應用在沙箱裡執行。

const vm = require('vm');
const getInitData = require('./getInitData');

// 建立沙箱的執行上下文環境
let vmContext = {
  window: {
    // 寫入一些常用的資料,如 cookie、UA 等
    document = {
      cookie: '...'
    },

    // Mock setTimeout 0
    setTimeout = function(fn, timeout) {
      if (!timeout) {
        fn();
      }
    };
  }
};

// 執行 getInitData 獲取首屏請求的引數
const requestConfig = getInitData();

// 請求首屏資料
let ssrData;

try {
  // 請求首屏資料
    ssrData = await request(requestConfig);
}
catch (error) {
    console.error('got request error', error);
}

// 將首屏資料注入到沙箱的 window 中
vmContext.window.ssrData = ssrData;

// 將應用包的程式碼使用沙箱進行編譯
const renderScript = new vm.Script(renderScriptCode);
// 服務端渲染的結果
let htmlContents;

try {
  // 執行沙箱中的程式碼
  htmlContents = renderScript.runInNewContext(vmContext, {
    timeout: 500
  });
} catch (error) {
  console.error('got render error', error);
}

process.nextTick(() => {
  if (vmContext) {
    // 清理沙箱環境
    clearContext(vmContext);
    vmContext = null;
  }
});

renderScriptCode 就是 React 的整個應用包,包含了 React 基礎庫和業務程式碼,同時在 renderScriptCode 中還會呼叫 renderToString 來真正執行服務端渲染。

function createRenderScriptCode () {
  return `
    (function () {
       ${contents};
       return window.ssrContext.ReactDOMServer.renderToString(window.ssrContext.entryApp);
    })();
  `;
}

沙箱環境的構建以及將應用放到沙箱中執行時有一些需要注意的點。 * 應用程式碼中可能存在 setTimeout 0 的情況(如Vue2的基礎框架程式碼),這裡把 setTimeout 轉成了立即執行的函式。 * Node.js 的 global 也會有全域性的物件(如String/Number)或函式(如setTimeout),如非必要儘量不要將其注入到沙箱中,可直接使用沙箱中的 global,不要使用 Node.js 執行時的 global。因為不同宿主環境的 API 可能會存在差異。如 window.String = global.String 就是多此一舉且可能引起 React + Recoil 在執行渲染時出現異常。 * 在沙箱執行完後,建議對沙箱進行清理,且清理動作最好在 process.nextTick 中執行。

由於不同宿主環境的 EventLoop 以及全域性 API 的實現存在差異,在沙箱中執行的程式碼應儘量注意這方面的使用和處理,尤其是涉及到 setTimeout 0Promise.resolve(true) 這類場景。

合理的預編譯快取

上面沙箱程式碼的建立和執行是在每次 HTML 請求發生時的處理流程,這也意味著每個請求都要建立一個沙箱,總歸來說沙箱的建立和執行都會有一定的開銷,執行環節省不掉,但是建立其實可以提前處理,這就涉及到前端應用包的預編譯。

在 Node.js 服務啟動時就可以做預編譯,當應用包需要更新時再更新預編譯的沙箱內容。

分塊渲染的實現

在上面主要講了首屏資料請求以及在沙箱中執行服務端渲染,讀到這裡,相信讀者還會產生更多的疑問,如上面並沒有看到服務端渲染的到底是怎麼做的,先不要急,在前端實現部分將重點介紹,這裡還有一個服務端必須要講的重要環節,如果沒了這個環節,整個服務端渲染的效能都會大打折扣。

分塊渲染也有叫“流式渲染”的,實際上我在實現分塊渲染時並不知道這個名詞,而是我後來瞭解到我所在公司內部的其他 SSR 專案也用到了一樣的優化實現,叫做流式渲染。

分塊渲染主要用到了 HTTP 1.1 起就引入的特性分塊傳輸: Transfer-Encoding: chunked 。一般來說一個 HTTP 的請求只會響應一次,而分塊傳輸允許一個 HTTP 的請求的連線中可以多次響應,在 SSR 的場景中,服務端在響應一個 HTML 頁面的請求時至少可以拆分成兩個分塊: * 靜態 HTML 部分:無動態內容,包含靜態樣式、JS 指令碼等,一般來說這是整個 頁面中體積最大的部分。 * 動態 HTML 部分:服務端渲染出的動態 HTML 內容。

HTTP/1.1 引入的分塊傳輸與 HTTP/2 的多路複用不是一種技術,不要混淆了,在 HTTP/2 中仍有分塊傳輸的機制。

當然光有 HTTP 分段傳輸並不夠,實際上還需要客戶端的配合,在瀏覽器/WebView 中,還需要支援分段渲染,其實就是邊載入邊渲染。在 PC 瀏覽器上這並不是什麼新特性,但是在移動裝置上截止到發文時的實測結果來看,iOS 的 WKWebView 仍然不支援分段渲染,Android 的 WebView 是可以支援的。

哪怕有一端支援也是很大的提升,這意味著客戶端在請求 HTML 頁面時,它會先載入並渲染頁面的骨架,而服務端同時在並行處理 SSR 的響應,當服務端處理完成,客戶端獲取到 SSR 響應的結果,再將響應結果追加到頁面中,完成整體的渲染。為了優化載入體驗,可以在等待服務端響應 SSR 的內容時,為靜態頁面增加一個骨架屏。

說到這裡,Node.js 服務端的框架選型都還沒介紹過,在 SSR 的場景中服務端框架承載的功能並不多,這裡選用的是輕量級的 Koa,以 Koa 為例,來說明 SSR 分塊渲染的實現。

ctx.set('Transfer-Encoding', 'chunked');
ctx.res.removeHeader('Content-Length');
ctx.body = createReadStream(staticHTML, getSSRInfo
());

要在 Koa 中實現請求的分塊傳輸,關鍵的三步是: * 設定分塊傳輸的響應頭 Transfer-Encoding: chunked ; * 移除響應體的長度標識 Content-Length ; * 將響應體設定為一個可讀流;

前兩步主要是告知客戶端服務端會使用分塊傳輸,而分塊傳輸中不能直接輸出響應體的長度,而是以最後一個分塊內容的長度為 0 來標記響應體結束。

createReadStream 函式首先會建立一個可讀流,然後將 chunk 新增到壓入流中,這個 chunk 就是靜態的 HTML 部分,而 SSR 的處理結果就封裝在 asyncTask 這個 Promise 函式中,處理的最後一步都會有 stream.push(null) 語句用來標記響應體為空代表響應的結束,沒有這一步,響應就不能正確的結束。

function createReadStream(chunk, asyncTask) {
  let stream = new Readable({
    autoDestroy: true
  });

  const handleStreamCloseOrError = function(error) {
    stream.removeListener('error', handleStreamCloseOrError);
    stream.removeListener('close', handleStreamCloseOrError);
    stream = chunk = null;
    if (error) {
      console.error(error);
    }
  };

  stream.once('error', handleStreamCloseOrError);
  stream.once('close', handleStreamCloseOrError);
  stream._read = function() {};
  stream.push(chunk);

  if (typeof asyncTask === 'function') {
    const handleTaskError = (error) => {
      stream && stream.push(null);
      console.error(error);
    };

    asyncTask().then((result) => {
      if (stream) {
        stream.push(result.data);
        stream.push(null);
      }
    }).catch(handleTaskError);
  } else {
    stream.push(null);
  }

  return stream;
};

至此,Node.js 服務端部分的設計和實現就到此結束了。

在前端“啟用”應用

前端的 React 應用按照正常的客戶端渲染流程需要呼叫 ReacDOM.render ,而對於 SSR 來說,HTML 內容因為提前建立好了,不需要再由 ReactDOM 去建立,此時只需要“啟用” React 應用即可,對應的 API 是 ReactDOM.hydrate ,啟用的操作是把 HTML 的結構與 React 應用建立起對映關係,方便後續的檢視更新,同時繫結好事件。

有把 ReactDOM.hydrate 翻譯成水合的,這個翻譯著實有點“水”,不過 API 本身的設計命名可能也有點飄了。

渲染降級

SSR 的渲染會有失敗率的,比如首屏資料請求失敗、執行渲染時出現未知的錯誤等,此時需要有 渲染降級 的措施,也就 SSR 失敗後降級為 CSR(客戶端渲染)。相當於在客戶端做了一次重試,這樣能儘可能提高渲染成功率,保證使用者最終能看到正常的 UI 介面,這一步也很重要。

應用的入口檔案需要同時支援 SSR 和 CSR。

window.ssrMount = fuction() {
  // CSR
  const doRender = () => {
    ReactDOM.render(
      entryApp,
      document.querySelector(rootId),
      callback
    );
  };

  // SSR
  const doHydrate = () => {
    ReactDOM.hydrate(
      entryApp,
      document.querySelector(rootId),
      callback
    );
  };

  if (window.ssrData) {
    doHydrate();
  } else {
    doRender();
  }
}

window.ssrData 是服務端請求好的首屏資料,通過判斷當前執行時環境是否有這份資料來選擇是否啟用應用來完成 SSR 的最後一步,還是降級到 CSR。

前後端的銜接

window.ssrData 是在服務端注入到沙箱中的,光是這麼注入,只能在沙箱的執行時環境有效,在客戶端的執行時環境仍然獲取不到,還需要進一步寫入。同時 SSR 的結果也沒有追加到頁面中,這就涉及到 SSR 時的前後端銜接的處理。

服務端的資料要通過沙箱注入到客戶端的執行時環境中,只要在 HTML 新增一個 script/textarea 標籤,將資料寫入到標籤中,那麼客戶端在解析 HTML 時就能將獲取到這些資料。

function ssrSuccessHtml ({ rootId, data, htmlContents }) {
  // 動態 id 防衝突
  const now = Date.now();
  const ssrHtmlContentsId = `ssrHtmlContents-${now}`;

  return `
    <textarea id="${ssrHtmlContentsId}" style="display:none">${htmlContents}</textarea>
    <script>
      window.ssrData = ${JSON.stringify(data)};
      document.getElementById("${rootId}").innerHTML = document.getElementById("${ssrHtmlContentsId}").textContent;
      window.ssrMount && window.ssrMount();
    </script>
  `;
}
window.ssrMount

從 cookie 和 query 中解析出的內容直接在 JSON.stringify 後插入到 script 標籤中無法防範 XSS 攻擊,而使用 textarea 的方法儲存 json 可防止 XSS 攻擊,儲存 html 字串可規避字元轉義的問題。

ssrSuccessHtml 是在服務端渲染的沙箱程式碼結束後執行來生成 HTML 的字串,而 HTML 的程式碼解析和執行是在客戶端收到 SSR 結果的分塊時執行的。通過這種方式可以很方便的將服務端沙箱執行時環境的資料傳輸給客戶端執行環境。

同時為了防止渲染執行失敗,也應該提供相應的 ssrFailHtml 的函式,在這個函式中不輸出 ssrData ,但仍然呼叫 ssrMount ,程式碼太簡單就不貼了。

首屏資料的複用

雖然 SSR 的 HTML 字串和 ssrData 都已經注入到了客戶端的執行環境中,也通過呼叫 ReactDOM.hydrate 來激活了應用,但是如果不注意處理好首屏資料的複用,React 應用仍然會重新執行一遍客戶端渲染。

因為按照正常的渲染流程,應用一般會在 create 或 mount 階段去請求首屏資料,資料請求完後呼叫 setState 將資料注入到應用中,應用就會開始啟動渲染的動作,而對於正常完成了 SSR 的應用,應判斷是否存在 ssrData ,通過複用 ssrData 來防止重複渲染。如果應用處理不當,那麼 SSR 就白費了。

服務端渲染與客戶端渲染的流程對比圖。

前端編碼的約束

以上已經介紹完了 SSR 的主要流程和關鍵細節,由於環境的差異對於要接入 SSR 的前端應用會有一些規範約束。無論是 React 還是 Vue,在執行服務端渲染時,應用並不會執行到 Mount 階段(對應 React 的 componentDidMount 或 Vue 的 mounted),而在這個階段之前,像是元件的例項化,create 等都儘量不要去讀取和寫入客戶端環境特有的 DOM 和 BOM,實在要讀取也確保在沙箱環境中已經做了 Mock,否則就會出現渲染失敗。

常規的 DOM 和 BOM 訪問其實很容易被發現,因為會直接報錯。而對於 setTimeout 和 Promise 等非同步執行的函式一旦使用了就容易出現一些意料之外的問題,它們不一定會報錯,但是會導致記憶體洩漏,我就曾經遇到一個專案中比較隱式的使用了 setTimeout,排查記憶體洩漏的問題就花了大量的時間。

所以對於要接入 SSR 的應用,應該建立嚴格的規範約束,如果可以的話最好配合一些自動化的檢測工具來做輔助。

Vue 的服務端渲染專門有文章介紹規範約束的注意點: Server-Side Rendering (SSR) | Vue.js ,建議細讀。

SSR 應用的構建編譯

除了編碼的約束,前端應用接入到 SSR 服務中也應該有工程化的規範約束。前端應用的包最終會部署到 SSR 服務上,服務通過讀取約定的配置檔案來將前端應用註冊,一個典型的配置檔案如下。

{
    {
  "index.html": {
    "disable": false,
    "type": "react",
    "deps": [
      "js/a.js",
      "js/b.js"
    ],
    "entryFile": "js/app.js",
    "getInitDataFile": "ssr/get-initdata.js",
    "serverRenderFile": "ssr/react.js"
  }
}

使用頁面名稱作為配置的 key,具體欄位說明如下: * disable 是否禁用 SSR * type SSR 的型別 * deps 應用的依賴檔案 * entryFile 應用的入口檔案,入口檔案應暴露入口元件和 React 的 API * window.ssrContext.entryApp 入口元件 * window.ssrContext.ReactDOMServer React 服務端 API * getInitDataFile 首屏資料請求引數函式

到這一步,從服務端到客戶端的整體處理流程都介紹完了,最後用一個圖做一下簡單總結。

結語

要想搭建出一個高效能的通用 SSR 服務,確實有很多的點需要注意,涉及的相關技術有一定的深度也有廣度,對於前端同學來說還需要具備 Node.js 服務的開發和運維經驗,絕不是隨便起一個服務把 SSR 跑起來那麼簡單,搞不好會起到負優化的作用,同時還有規範約束的建立,規範不到位引起的服務端記憶體洩漏排查起來會非常頭痛,一旦出現記憶體洩漏,整個服務都會變得不穩定。

React v18 在服務端渲染的支援度比之前的版本更好了,這意味著對官方對服務端渲染的重視程度越來越高,也意味著在應用渲染效能的優化方案會越來越複雜。

文字介紹的 SSR 服務並未涉及到內容快取的優化,同時為了避免服務本身的狀態化,也可以將前端應用包託管到檔案儲存服務上,這裡都不做過多介紹了,文章篇幅已經超了。

由於本文篇幅較長,超出了我一開始的預想,涉及到的內容也比較雜,相關演示程式碼也都是虛擬碼,難免疏漏,還望見諒。