React Server Component: 混合式渲染

位元組跳動終端技術發表於2022-11-29

作者:謝奇璇

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(`http://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>

參考自:
https://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,但就像前面所說的它並不與其他的技術互斥,我們可以將其進行結合,更況且他不僅僅能應用於這樣的靜態場景。

參考文件

相關文章