大勢所趨:流式服務端渲染

豆皮粉兒們,你們好,又見面了。html

5c6e7105-97d1-46b5-a71f-5958494f9332.gif

前言

隨着互聯網技術的突飛猛進,前端代碼變得日益複雜。然而前端代碼的複雜帶來了客戶端體積增大,用戶須要下載更多的內容才能將頁面渲染出來。爲了下降首屏渲染時間,提升用戶體驗,前端工程師們推出了不少有效強力的技術,服務端渲染就是其中之一。前端

服務端渲染

爲了解釋什麼是服務端渲染,讓咱們先畫出傳統客戶端渲染(Client Side Render) 的流程圖:vue

image.png

能夠看到,CSR 的鏈路很是長,須要通過:node

  1. 請求 html
  2. 請求 js
  3. 請求 數據
  4. 執行 js

等至少 4 步才能完成首次渲染(First Paint) 爲了下降 FP 時間,前端工程師引入了服務端渲染(Server Side Render):react

image.png

然而,SSR 雖然下降了 FP 時間,可是在 FP 與 可交互(Time To Interactive) 中有大量的不可交互時間,在極端狀況下,用戶會一臉懵逼:「咦,頁面上不是已經有內容了嗎,怎麼點不了滾不動?」git

總結一下:github

CSR與SSR的共同點是,先返回了 HTML,由於 HTML 是一切的基礎。web

以後 CSR 先返回了 js,後返回了 data,在首次渲染以前頁面就已經可交互了。算法

而 SSR 先返回了 data,後返回 js,頁面在可交互前就完成了首次渲染,使用戶能夠更快的看到數據。瀏覽器

可是,先返回 js 仍是先返回 data,這二者並不衝突,不該該是阻塞串行的,而應該是並行的。

它們的阻塞致使了在 FP 與 TTI 之間總有一段時間效果不如人意。爲了使它們並行,來進一步提升渲染速度,咱們須要引入流式服務端渲染(Steaming Server Side Render)渲染 的概念。

基本思想

綜上,理想中的流式服務端渲染流程以下:

image.png

同時爲了最大程度提升加載速度,因此須要下降首字節時間(Time To First Byte),最好的方法就是複用請求,所以,僅需發送兩個請求:

  1. 請求 html,server 會先返回骨架屏的 html,以後再返回所需數據,或者帶有數據的 html,最後關閉請求。
  2. 請求 js,js 返回並執行後就能夠交互了。

爲何要叫「流式服務端渲染」?是由於返回html的那個請求的相應體是流(stream),流中會先返回如骨架屏/fallback的同步HTML代碼,再等待數據請求成功,返回對應的異步HTML代碼,都返回後,纔會關閉此HTTP鏈接。

優點在於:

  • 請求 data 與 請求 js 是並行的,而之前的大多解決方案都是串行的。
  • 在最優狀況下,僅發送兩個請求,大幅度 下降了 TTFB 總時長

可是,ssr 框架一般只執行render函數一次,爲了讓其知道何爲加載狀態,何爲數據狀態,咱們須要對其進行升級改造,首先就是lazySuspense

lazySuspense

而後咱們來經過簡單的討論實現原理來進一步研究它們是如何爲流式服務端渲染服務的。 一個最簡單的 lazy 以下:

function lazy(loader) {
  let p
  let Comp
  let err
  return function Lazy(props) {
    if (!p) {
      p = loader()
      p.then(
        exports => (Comp = exports.default || exports),
        e => (err = e)
      )
    }
    if (err) throw err
    if (!Comp) throw p
    return <Comp {...props} />
  }
}
複製代碼

其主要邏輯爲,加載目標組件,如目標組件正在加載,則拋出對應的Promise,不然正常渲染目標組件。

爲何這裏選擇的是throw這樣的設計呢?是由於在語法層面,只有throw能跳出多層函數的邏輯,找到最近的catch繼續執行,而其餘流程控制關鍵字,如breakcontinuereturn等,都是調度單個函數內的邏輯,影響的是語句塊block。

常常把throwError結合使用的讀者可能會感到意外,可是有時候就須要跳出常理看待問題的能力。

lazy 一般和 Suspense 配套使用,一個簡單的Suspense以下所示:

function Suspense({ children, fallback }) {
  const forceUpdate = useForceUpdate()
  const addedRef = useRef(false)
  try {
    // 先嚐試渲染 children,爲方便理解就簡單編寫了
    return children
  } catch (e) {
    if(e instanceof Promise) {
      if(!addedRef.current) {
        e.then(forceUpdate)
        addedRef.current = true
      }      
      return fallback
    } else {
      throw e
    }
  }
}
複製代碼

主要邏輯爲:嘗試渲染children,若是children拋出了Promise,則渲染fallback,當Promise resolve,則 rerender。

至於這個Promise是來自lazy的,仍是來自fetch的,其實不是很在意。 然而,框架內部的 Suspense 一般不會這麼寫,其最簡實現爲:

function Suspense({ children }) {
  return children
}
複製代碼

沒錯,就這麼簡單,和Fragment代碼相同,僅僅是爲調度提供一個標誌位而已。

爲了提升可擴展性與魯棒性,React 內部使用Symbol做爲標誌位,但原理相同。

在調度此組件時,若是被throw打斷,就會回退至fallback:

try {
  updateComponent(WIP) // 被 throw 打斷
} catch(e) {
  WIP = WIP.parent // 回退到 Suspense 組件
  WIP.child = WIP.props.fallback // 更換 child 指針
}
複製代碼

部分框架,如 vue/preact,它們的底層數據結構不是 fiber 或者鏈表,原理則爲設置兩個佔位符,根據調度時的具體 state 來決定渲染哪一個佔位

<Suspense> 
  <template #default> 
    <article-info/> 
  </template> 
  <template #fallback> 
    <div>Loading…</div> 
  </template> 
</Suspense>
複製代碼

因爲不是這次的重點,這裏就不展開了,感興趣的同窗能夠去閱讀有關源碼。

最後一塊積木

在完成lazySuspense的原理探究後,讓咱們來爲流式服務端渲染放上最後一塊積木:ssr 框架。

app.get("/", (req, res) => {
  res.write("<!DOCTYPE html><html><head><title>My Page</title></head><body>");
  res.write("<div id='root'>"); 
const stream = ReactServerDom.renderToNodeStream (<App />);
stream.pipe(res, { end: false });
stream.on('end', () => {
    res.write("</div></body></html>");
    res.end();
  });
});
複製代碼

renderToNodeStream 的過程當中,每完成一個組件的渲染則直接放入 stream 中。在瀏覽器看來,可能收到的 html 字符串以下所示:

<html>
  <body>
    <div id="root">
      <input />
      <div>some content</
複製代碼

看起來只有一半,這要怎麼展現呢?

別擔憂,現代瀏覽器對於 html 有着優異的容錯能力,哪怕只有一半,它也能把這一半無缺無損的渲染出來,這就是流式服務端渲染的基礎所在。

在調度時,當碰見Suspense從而須要WIP回退時,會往流中放入fallback並執行Promise,當Promise resolve ,放入對應的替換代碼,一個簡單的例子以下所示: 先渲染fallback:

<html>
  <body>
    <div id="root">
      <div className="loading" data-react-id="123" />
複製代碼

當Promise resolve 後,返回:

<div data-react-id="456">{content}</div>
<script>
  // 舉個例子,並非真有這個API
  React.replace("123", "456")
</script>
複製代碼

使用 inline 的 js 腳原本替換 dom,以此實現流式加載。

總體看起來以下所示:

<html>
  <body>
    <div id="root">
      <div className="loading" data-react-id="123" />
      <!-- 同步 HTML 渲染完成後返回客戶端 js -->
      <script src="./index.js" />
      <!-- 客戶端使用「部分水合」算法對服務端 HTML 與客戶端虛擬 dom 進行 merge,跳過由 Suspense 管理的節點 -->

      <!-- 過了一段時間 -->
      <div data-react-id="456">{content}</div>
      <script>
        // 舉個例子,並非真有這個API
        React.replace("123", "456")
      </script>
    </div>
  </body>
</html>
複製代碼

結語

流式服務端渲染爲下降渲染時間、提升用戶體驗開啓了一扇全新的大門,美中不足的是,仍在理論當中,各大框架均在研發,暫無可用 demo,請讀者拭目以待。

原文連接:bytedance.feishu.cn/wiki/wikcn5…

參考資料


數據平臺前端團隊,在公司內負責風神、TEA、Libra、Dorado等大數據相關產品的研發。咱們在前端技術上保持着很是強的熱情,除了數據產品相關的研發外,在數據可視化、海量數據處理優化、web excel、WebIDE、私有化部署、工程工具都方面都有不少的探索和積累。 ~ 歡迎進入團隊主頁的招聘頁面給咱們投遞簡歷。

相關文章
相關標籤/搜索