React Server Component: 混合式渲染

作者:謝奇璇

React 官方對 Server Comopnent 是這樣介紹的: zero-bundle-size React Server Components。

這是一種實驗性探索,但相信該探索是個未來 React 發展的方向,與 React Server Component 相關的周邊生態正在積極的建設當中。

術語介紹

在 React Server Component (以下稱 Server Component) 推出之後,我們可以簡單的將 React 組件區分為以下三種:

Server Component 服務端渲染組件,擁有訪問數據庫、訪問本地文件等能力。無法綁定事件對象,即不擁有交互性。
Client Component 客戶端渲染組件,擁有交互性。
Share Component 既可以在服務端渲染,又可以在客戶端渲染。具體如何渲染取決於誰引入了它。當被服務端組件引入的時候會由服務端渲染當被客戶端組件引入的時候會由客戶端渲染。

React 官方暫定通過「文件名後綴」來區分這三種組件:

  1. Server Component 需要以 .server.js(/ts/jsx/tsx) 為後綴
  2. Client Component 需要以 .client.js(/ts/jsx/tsx) 為後綴
  3. Share Component 以 .js(/ts/jsx/tsx) 為後綴

混合渲染

簡單來說 Server Component 是在服務端渲染的組件,而 Client Component 是在客戶端渲染的組件。

與類似 SSR , React 在服務端將 Server Component 渲染好後傳輸給客戶端,客戶端接受到 HTML 和 JS Bundle 後進行組件的事件綁定。不同的是:Server Component 只進行服務端渲染,不會進行瀏覽器端的 hyration(注水),總的來說頁面由 Client Component 和 Server Component 混合渲染。

這種渲染思路有點像 Islands 架構,但又有點不太一樣。

如圖:橙色為 Server Component, 藍色為 Client Component 。

React 是進行混合渲染的?

React 官方提供了一個簡單的 Demo , 通過 Demo,探索一下React sever component的運作原理。

渲染入口

瀏覽器請求到 HTML 後,請求入口文件 – main.js, 裏面包含了 React Runtime 與 Client Root,Client Root 執行創建一個 Context,用來保存客戶端狀態,與此同時,客戶端向服務端發出 /react 請求。

// Root.client.jsx 偽代碼

function Root() {
    const [data, setData] = useState({});
    
    // 向服務端發送請求
    const componentResponse = useServerResponse(data);
    return (
        <DataContext.Provider value={[data, setData]}> 
            componentResponse.render();
        </DataContext.Provider>
    );
}

看出這裡沒有渲染任何真實的 DOM, 真正的渲染會等 response 返回 Component 後才開始。

請求服務端組件

Client Root 代碼執行後,瀏覽器會向服務端發送一個帶有 data 數據的請求,服務端接收到請求,則進行服務端渲染。

服務端將從 Server Component Root 開始渲染,一顆混合組件樹將在服務端渲染成一個巨大的 VNode。

如圖,這一顆混合組件樹會被渲染成這樣一個對象,它帶有 React 組件所有必要的信息。

module.exports = {
    tag: 'Server Root',
    props: {...},
    children: [
        { tag: "Client Component1", props: {...}: children: [] },
        { tag: "Server Component1", props: {...}: children: [
            { tag: "Server Component2", props: {...}: children: [] },
            { tag: "Server Component3", props: {...}: children: [] },
        ]}
    ]
}

不僅僅是這樣一個對象, 由於 Client Comopnent 需要 Hydration, React 會將這部分必須要的信息也返回回去。React 最終會返回一個可解析的 Json 序列 Map。

M1:{"id":"./src/BlogMenu.client.js","chunks":["client0"],"name":"xxx"}
J0:["$","main", null, ["]]
  • M:  代表 Client Comopnent 所需的 Chunk 信息
  • J:  代表 Server Compnent 渲染出的類 react element格式的字符串

React Runtime 渲染

組件數據返回給瀏覽器後,React Runtime 開始工作,將返回的 VNode 渲染出真正的 HTML。與此同時,發出請求,請求 Client Component 所需的 JS Bundle。當瀏覽器請求到 Js Bundle 後,React 就可以進行選擇性 Hydration(Selective Hydration)。需要注意的是, React 團隊傳輸組件數據選擇了流式傳輸,這也意味着 React Runtime 無需等待所有數據獲取完後才開始處理數據。

啟動流程

  1. 瀏覽器加載 React Runtime, Client Root 等 js 代碼
  2. 執行 Client Root 代碼,向服務端發出請求
  3. 服務端接收到請求,開始渲染組件樹
  4. 服務端將渲染好的組件樹以字符串的信息返回給瀏覽器
  5. React Runtime 開始渲染組件且向服務端請求 Client Component Js Bundle 進行選擇性 Hydration(注水)

Client <-> Server 如何通信?

Client Component 與 Server Component 有着天然的環境隔離,他們是如何互相通信的呢?

Server Component -> Client Component

在服務端的渲染都是從 Server Root Component 開始的,Server Component 可以簡單的通過 props 向 Client Component 傳遞數據。

import ClientComponent from "./ClientComponent";

const ServerRootComponent = () => {
    return <ClientComponent title="xxx" />
};

但需要注意的是:這裡傳遞的數據必須是可序列化的,也就是說你無法通過傳遞 Function 等數據。

Client Component  -> Server Component

Client Component 組件通過 HTTP  向服務端組件傳輸信息。Server Component 通過 props 的信息接收數據,當 Server Component 拿到新的 props 時會進行重新渲染, 之後通過網絡的手段發送給瀏覽器,通過 React Runtime 渲染在瀏覽器渲染出最新的 Server Component UI。這也是 Server Component 非常明顯的劣勢:渲染流程極度依賴網絡。

// Client Component
function ClientComponent() {
    const sendRequest = (props) => {
        const payload = JSON.stringify(props);
        fetch(`//xxxx:8080/react?payload=${payload}`)
    }
    return (
        <button 
           onclick = {() => sendRequest({ messgae: "something" })}
        >
            Click me, send some to server
        </button>
    )
}
// Serve Component
const ServerRootComponent = ({ messgae: "something" }) => {
    return <ClientComponent title="xxx" />
};

Server Component 所帶來的優勢

RSC 推出的背景是 React 官方想要更好的用戶體驗,更低的維護成本,更高的性能。通常情況下這三者不能同時獲得,但 React 團隊覺得「小孩子才做選擇,我全都要」。

根據官方提出 RFC: React Server Components,可以通過以下幾點能夠看出 React 團隊是如何做到”全都要”的:

更小的 Bundle 體積

通常情況下,我們在前端開發上使用很多依賴包,但實際上這些依賴包的引入會增大代碼體積,增加 bundle 加載時間,降低用戶首屏加載的體驗。

例如在頁面上渲染 MarkDown ,我們不得不引入相應的渲染庫,以下面的 demo 為例,不知不覺我們引入了  240 kb 的 js 代碼,而且往往這種大型第三方類庫是沒辦法進行 tree-shaking。

// NOTE: *before* Server Components

import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)

function NoteWithMarkdown({text}) {
  const html = sanitizeHtml(marked(text));
  return (/* render */);
}

可以想像,為了某一個計算任務,我們需要將大型 js 第三方庫傳輸到用戶瀏覽器上,瀏覽器再進行解析執行它來創造計算任務的 runtime, 最後才是計算。從用戶的角度來講:「我還沒見到網頁內容,你就佔用了我較大帶寬和 CPU 資源,是何居心」。然而這一切都是可以省去的,我們可以利用 SSR 讓 React 在服務端先渲染,再將渲染後的 html 發送給用戶。從這一方面看,Server Component 和 SSR 很類似,但不同的是 SSR 只能適用於首頁渲染,Server Component 在用戶交互的過程中也是服務端渲染,Server Component 傳輸的也不是 html 文本,而是 json。Server Component 在服務端渲染好之後會將一段類 React 組件 json 數據發送給瀏覽器,瀏覽器中的 React Runtime 接收到這段 json  數據 後,將它渲染成 HTML。

我們舉一個更加極端的例子:若用戶無交互性組件,所以組件都可以在服務端渲染,那麼所有 UI 渲染都將走「瀏覽器接收”類 react element 文本格式”的數據,React Runtime 渲染」的形式進行渲染。 那麼除了一些 Runtime, 我們無需其他 JS Bundle。而 Runtime 的體積是不會隨着項目的增大而增大的,這種常數係數級體積也可以稱為 “Zero-Bundle-Size”。因此官方這稱為: “Zero-Bundle-Size Components”。

更好的使用服務端能力

為了獲取數據,前端通常需要請求後端接口,這是因為瀏覽器是沒辦法直接訪問數據庫的。但既然我們都藉助服務端的能力了,那我們當然可以直接訪問數據庫,React 在服務器上將數據渲染進組件。

通過自由整合後端能力,我們可以解決:「網絡往返過多」和「數據冗餘」問題。甚至我們可以根據業務場景自由地決定數據存儲位置,是存儲在內存中、還是存儲在文件中或者存儲在數據庫。除了數據獲取,還可以再開一些”腦洞”。

  • 我們可以在 Server Component 的渲染過程中將一些高性能計算任務交付給其他語言,如 C++,Rust。這不是必須的,但你可以這麼做。
  • ……

簡單粗暴一點的說:Nodejs 擁有什麼樣的能力,你的組件就能擁有什麼能力。

更好的自動化 Code Split

在過去,我們可以通過 React 提供的 lazy + Suspense 進行代碼分割。這種方案在某些場景(如 SSR)下無法使用,社區比較成熟的方案是使用第三方類庫 @loadable 。然而無論是使用哪一種,都會有以下兩個問題:

  1. Code Split 需要用戶進行手動分割,自行確認分割點。
  2. 與其說是 Code Split,其實更偏向懶加載。也就是說,只有加載到了代碼切割點,我們才會去即時加載所切割好的代碼。這裡還是存在一個加載等待的問題,削減了code split給性能所帶來的好處。

React核心團隊所提出 Server Component 可以幫助我們解決上面的兩個問題。

  1. React Server Component 將所有 Client Component 的導入視為潛在的分割點。也就是說,你只需要正常的按分模塊思維去組織你的代碼。React 會自動幫你分割
import ClientComponent1 from './ClientComponent1';


function ServerComponent() {
    return (
        <div>
            <ClientComponent1 />
        </div>
    )
}
  1. 框架側可以介入 Server Component 的渲染結果,因此上層框架可以根據當前請求的上下文來預測用戶的下一個動作,從而去「預加載」對應的js代碼。

避免高度抽象所帶來的性能負擔

React server component通過在服務器上的實時編譯和渲染,將抽象層在服務器進行剝離,從而降低了抽象層在客戶端運行時所帶來的性能開銷。

舉個例子,如果一個組件為了可配置行,被多個 wrapper 包了很多層。但事實上,這些代碼最終只是渲染為一個<div>。如果把這個組件改造為 server component 的話,那麼我們只需要往客戶端返回一個<div>字符串即可。下面例子,我們通過把這個組件改造為server component,那麼,我們大大降低網絡傳輸的資源大小和客戶端運行時的性能開銷:

// Note.server.js
// ...imports...

function Note({id}) {
  const note = db.notes.get(id);
  return <NoteWithMarkdown note={note} />;
}

// NoteWithMarkdown.server.js
// ...imports...

function NoteWithMarkdown({note}) {
  const html = sanitizeHtml(marked(note.text));
  return <div ... />;
}

// client sees:
<div>
  <!-- markdown output here -->
</div>

參考自:
//juejin.cn/post/6918602124804915208#heading-5

我們可以通過在 Server Component ,將 HOC 組件進行渲染,可能渲染到最後只是一個 <div> 我們就無需將 bundle 傳輸過去,也無需讓瀏覽器消耗性能去渲染。

Sever Component 可能存在的劣勢

弱網情況下的交互體驗

如上文所述: React Server Component 的邏輯, 他的渲染流程依靠網絡。服務端渲染完畢後將類 React 組件字符串的數據傳輸給瀏覽器,瀏覽器中的 Runtime React 再進行渲染。顯然,在弱網環境下,數據傳輸會很慢,渲染也會因為網速而推遲,極大的降低了用戶的體驗。Server Component 比較難能可貴的是,它跟其他技術並不是互斥的,而是可以結合到一塊。例如:我們完全可以將 Server Component 的計算渲染放在邊緣設備上進行計算,在一定程度上能給降低網絡延遲帶來的問題。

開發者的心智負擔

在 React Server Component 推出之後,開發者在開發的過程中需要去思考: 「我這個組件是 Server Component 還是 Client Component」,在這一方面會給開發者增加額外的心智負擔,筆者在寫 Demo 時深有體會,思維上總是有點不習慣。Nextjs 前一段時間發佈了 v13,目前已實現了 Server & Client Component 。參考 Next13 的方案,默認情況下開發者開發的組件都是 Server Component ,當你判斷這個組件需要交互或者調用 DOM, BOM 相關 API 時,則標記組件為 Client Component。

「默認走 Server Component,若有交互需要則走 Client Component」 通過這種原則,相信在一定程度上能給減輕開發者的心智負擔。

應用場景: 文檔站

從上面我們可以知道 Server Component 在輕交互性的場景下能夠發揮它的優勢來,輕交互的場景一般我們能想到文檔站。來看一個小 Demo, 通過這個 Demo 我們觀察到幾個現象:

  1. 極小的 Js bundle。
  2. 文件修改無需 Bundle。

當然像文檔站等偏向靜態的頁面更適合 SSR, SSG,但就像前面所說的它並不與其他的技術互斥,我們可以將其進行結合,更況且他不僅僅能應用於這樣的靜態場景。

參考文檔