Why React Server Component exist?
整理SPA演進至Server Component的歷史,各自的流程以及中間所遭遇的困難.
SPA Problem
Server端發送空殼html以及很大一包的JS讓前端渲染UI
// Server sends empty HTML & js for execute
<div id="root"></div>
<script src="app.js"></script>
大致流程如下:
- 下載html
- 下載js
- 轉換並執行js
- 呼叫api
- 建立DOM
- 顯示內容
優點:
- 初始化只會做一次,url導航會感覺很即時,因為不會整頁重新載入
缺點:
- 直到js載入完成前,用戶只會看到空白的畫面
- 搜尋引擎無法為頁面建立索引,因為他們只讀取html內容,不執行後續的js
- 若用戶網路不佳或裝置不夠強大,初始化會很久,會造成期間用戶無法做任何事情
SSR
SSR的出現就是為了解決SPA問題,所以就在服務器端先pre-render HTML內容然後才送html給用戶
// Server runs React components and sends real HTML
<div id="root">
<h1>Welcome John</h1>
<div class="dashboard">
<p>Your balance: $100</p>
...actual content...
</div>
</div>
<script src="app.js"></script>
優點:
- 快速顯示第一次渲染的html內容(用戶不用等到js執行完就可看到內容)
- 對SEO更友善,爬蟲可以即時看到內容
- 對於慢速裝置的效能較好,因為不用把所有工作都丟給client端做,
因為服務器端會先將內容寫入html才發送出去, 樣本如下:// server load data and render const html = ReactDOMServer.renderToString(<App initialData={someData} />); res.send(` <div id="root">${html}</div> <script>window.__INITIAL_DATA__ = ${JSON.stringify(someData)}</script> <script src="/client.js"></script> `); });
缺點: 用戶端無法有任何互動,因為僅是html的殼,這對大多數網站是沒有意義的,所以需要做"hydration".
Hydration
hydration目的:
- html內的元素掛上對應的event handler
- 初始化React Virtual dom內對應的真實元素並同步組件狀態
hydration流程:
- server渲染components到html上然後送出該html
- client接收html並開始解析html
- client下載react lib(react.js, react-dom.js) + components code
- client執行這些相同的components並建置react virtual DOM樹
- client 再將react virtual DOM樹做脫水(‘hydration’)
下載react的意思是指還需要執行react本身的核心演算與component的相關邏輯然後是React-DOM,然後才是執行自己寫的component的代碼,範例如下:
// server sends those along as JS files
<script src="/react.js"></script>
<script src="/react-dom.js"></script>
<script src="/your-app.js"></script>
所以在client端仍是React,所以需要讓react virtual DOM了解如何hydration.
從下載JS到 hydration 的流程如下:
- React 檢視目前頁面的HTML
- 執行下載的component組件,用來建立virtual DOM, 可以想像就是用React.createElement呼叫這些物件數據用來展現UI
- 開始
hydrationhydrationflow:- React同時間遍歷虛擬DOM樹與真實DOM樹
- 每個節點逐一檢查匹配(檢查真實DOM node是對應到哪個virtaul node)
- 當發現虛擬DOM匹配到實際的div後
- 將該virtaul dom的事件處理函式掛到此真實DOM node上
- 然後更新狀態管理內的狀態
- 然後讓所有東西都是可互動的,結束
到這裡看起來應該ok了,所以問題是?
當我們送出html與js到client端,client還是需要下載與執行,才能讓整頁有互動,所以為什麼需要在server上渲染所有的html內容?
(OS:事件處理函式還是只能靠react去掛載到真實dom上)
Tranditional Hydration Problem
hydration過程中會有個server與client端的node匹配問題,假設有當前時間(或產生隨機亂數),client端做hydrate時,組件產生的當前時間勢必與server當時產生的時間有誤差,這時候做匹配會有問題,React就不知道該如何將event handler掛到那個真實dom了.
範例:
// Server renders:
function Component() {
return <div>Hello {new Date().toString()}</div>
}
// Server HTML:
<div>Hello Mon Jan 21 2025 10:30:15</div>
// When client hydrates, it runs the component again:
<div>Hello Mon Jan 21 2025 10:30:16</div>
// React sees:
// - Server gave me HTML with "10:30:15"
// - My new render wants to show "10:30:16"
// - These don't match! Something's wrong!
另一個問題是server仍然會blocking,假設某個要render的組件內有三個子組件分別要消耗不同的時間,必須等所有組件都完成才能返回html?
範例:
async function HomePage() {
const userData = await fetchUser(); // 300ms
const profileData = await fetchProfile(); // 500ms
const postsData = await fetchPosts(); // 1000ms
// Server can't send ANYTHING until all 1.8 seconds pass!
return (
<div>
<Nav userData={userData} />
<Profile profileData={profileData} />
<Posts postsData={postsData} />
</div>
);
}
當Server開始渲染html,會卡在 renderToNodeStream 1.8秒.
造成用戶端在1.8秒前都是空白畫面,TTFB(Time of First Byte)指標會很慘,思考一下幾點:
- 必須等所有資料完成?
- 能先把某些已經完成的內容送到用戶端嗎?
- 能改變原本組件的順序 範例原本的順序是 userData -> profileData -> postsData 希望順序是 profileData -> userData -> postsData 能簡單的達到嗎?
React Suspense + Partial chunks
使用React Suspense可以讓資料快速響應,也就是讓一開始返回的html內容為fallback的內容,然後等那段資料完成後,透過streaming的形式將chunk返回,返回後就會立即append到頁面,然後透過script將原本的fallback內容替換成chunk已經渲染好的內容.
範例:
// 這個組件第一次寫入html時,會立即渲染Nav內容,以及Suspnese的兩個fallback內容
function ProfilePage() {
return (
<div>
{/* This renders immediately */}
<Nav />
{/* This tells React "hey, I might not be ready" */}
{/* If I am not ready, show this fallback */}
<Suspense fallback={<ProfileSkeleton />}>
<Profile />
</Suspense>
{/* Same here */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</div>
);
}
// 以下是渲染後的html
<div>
<!-- 這是Nav組件渲染的內容 -->
<header>
<div>Weolcome my web site</div>
</header>
<!-- 這是Profile組件渲染的內容 -->
<div data-suspense-boundary="chunk1">
<div class="spinner">Profile Loading...</div>
</div>
<!-- 這是Comments組件渲染的內容 -->
<div data-suspense-boundary="chunk2">
<div class="spinner">Comments Loading...</div>
</div>
<div>
然後當 Profile 的資料準備完成,就會再將內容透過原來的connection推送到前端(原理是transfer-encoding:chunked),下面範例就是推送的chunk內容,這樣就可以讓先返回的html內容渲染到頁面上.
Comments 組件也是相同方式推送內容.
可參考此連結
<!-- New content chunk -->
<template data-suspense-chunk="chunk1">
<div class="profile">Alice's Profile ...</div>
</template>
<!-- Inline script to perform the replacement -->
<script>
document
.querySelector('[data-suspense-boundary="chunk1"]')
.replaceChildren(
document.querySelector('[data-suspense-chunk="chunk1"]').content
);
</script>
以上的關鍵機制是:
- 透過標記(data-suspense-boundary)來建立一個
待補的洞,讓等等才完成的內容可以被正確找到並替換 - 透過
template就不會一返回就被渲染到頁面上(template的內容是不會渲染到頁面的). - 透過一個小的
script來執行內容替換 - 透過標記(data-suspense-boundary)的id,可以讓react能夠知道事件處理該附加到誰身上,也可以同步virtual dom狀態
Suspense 如何工作?
假設Profile組件被Suspense包裹,當試圖渲染Profile組件時,Suspense會攔截到Profile拋出的錯誤,如果是promise類型(如果不是就往上拋此錯誤),就先渲染fallback的組件,等到promise完成才會重新嘗試渲染Profile組件.
範例:
<!-- Profile組件被Suspense包裹 -->
<Suspense fallback={<Loading />}>
<Profile />
</Suspense>
function Profile() {
// This is what happens under the hood when you fetch data
// in a "suspense-enabled" way:
const promise = fetchData();
if (promise.status === "pending") {
// This is what "suspending" actually means:
// The component throws a promise!
throw promise;
}
// If we get here, we have data
// otherwise Suspense would have caught the promise
return <div>{data.name}</div>;
}
// Pseudo code of the Suspense catching part
try {
// Try to render Profile
return <Profile />;
} catch (thrownValue) {
if (thrownValue instanceof Promise) {
// Aha! Component isn't ready yet
// Show loading instead
return <Loading />;
} else {
// Oops, real error, let it bubble up
throw thrownValue;
}
}
Partial Chunks
原理是是用遊覽器對於收到Transfer-Encoding: chunked的Header時,會保留connection,暫時不會關閉它,且視body內的資料為一個chunks,當收到chunk後就附加到html上,直到server返回的chunk size為0,就表示沒chunk要傳了.
Core Problom
上述問題仍無法解決所有的JavaScript必須在用戶端被下載與執行,所以即使透過streaming方式片段載入內容,還是需要下載各個組件代碼(即使某些組件其實根本沒有互動性).
下載後,每個組件都會做以下流程:
- 建立組件實體(思考:某些組件內容屬於靜態的,仍需要做?)
- 建構virtual DOM nodes
- 設定event handler
- 匹配現有html內容(同步組件狀態)
然後從server送到client端的js包含:
- React + React-DOM code
- all component code
- all dependencies
- State management code
- Effect handlers
- Event handlers
以下範例為例,較低效率的點是:
- 為何下載
Profile組件(僅單純顯示資料內容)? - 為何要綁定
Nav組件(僅是個連結)? - 為何要為靜態內容提供React組件?
範例:
function App() {
return (
<Layout>
<Nav />
<Suspense>
<Profile /> // Rarely needs interactivity
<Sidebar />
</Suspense>
<Suspense>
<Comments /> // Needs to be interactive
</Suspense>
</Layout>
);
}
Static Component
範例組件(僅用來顯示資料):
function Profile({ user }) {
return (
<div className="profile">
<h2>{user.name}</h2>
<span>{user.bio}</span>
</div>
);
}
若走傳統SSR(不管是不是用streaming方式),仍必須送出Profile component code,除了用來處理props以及該組件module scope內的東西或相依,也讓React用來:
- 建立組件實體
- 建立和比較virtual DOM nodes
- 處理組件的lifecycle(mount, unmount, update, ...)
- 設定reconciliation process(即差異比較的演算程序,用來更新組件或重建組件)
上述動作是因為React需要在用戶端"rebuild"整個component tree才能匹配目前的html內容且為了潛在的更新做準備,並維護virtual dom結構,但是靜態內容根本不會有更新或互動行為.
React Server Component
為了解決上面問題,React將組件區分為server component與client component,server component用來讓server渲染html用的,client component用來讓用戶端渲染頁面用的,並建立slots給預留給client component使用,讓react在用戶端操作時知道哪邊該注入互動行為.
範例(除了顯示靜態資料也有用戶端的互動行為):
// Server Component - stays on server!
function Profile({ user }) {
return <h1>{user.name}</h1>;
}
// Client Component - ships to client
"use client"
function LikeButton() {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>Like</button>;
}
RSC Payload
範例:
function ServerComponent() {
return (
<div>
<h1>Hello</h1>
<ClientComponent />
</div>
);
}
"use client"
function ClientComponent() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
除了將送出已渲染的chunks也一併送出一個特殊結構RSC Payload,用來保留component boundaries.
產生的內容大致如下:
// Server generates RSC payload AND assigns markers
[
["M", "ClientComponent.js"],
["S", "<div><h1>Hello</h1>"],
["J", "ClientComponent.js", {}, "B:0"], // Server assigns marker
["S", "</div>"]
]
// Server also generates initial HTML with these markers
<div>
<h1>Hello</h1>
<template id="B:0"></template>
</div>
"M"用來告訴客戶端這是個組件,請下載他
"J"用來告訴客戶端這是個module,請將它注入
"S"用來告訴客戶端這是個server component,請替換他的內容
"J" 項目後面有個空物件,是因為ClientComponent沒有任何props需要傳遞,當server端有傳遞props,這物件就會有東西
RSC Payload Structure
"M" - Module Reference
- Tells client which client components it needs to load
- Example: ["M", "Button.client.js"]
"S" - Server Component HTML
- Static HTML from server components
- Example: ["S", "<div>Hello</div>"]
"J" - Client Component Placeholder
- Where to inject a client component
- References a module, includes props, and has a marker
- Example: ["J", "Button.client.js", {color: "blue"}, "B:0"]
- Marker matches template id in HTML
"H" - Hint to preload module
- Background preloading of modules
- Example: ["H", "Modal.client.js"]
"E" - Error
- Server-side error boundaries
- Example: ["E", "Error message", "stack trace"]
"P" - Promise
- For streaming data from server
- Example: ["P", "chunk-123", Promise]
"B" - Bundle Reference
- References to other JavaScript bundles
- Example: ["B", "chunk-123.js"]
NextJS Payload Parser(針對nextjs產生的RSC payload)
What's happening on the client?
當client收到這個payload會做:
- 建置一個
VDOM,但不用再為了所有東西 - 匹配component與對應的slots
- 設置
hydration
React建置了一個VDOM,但不像以前一樣那樣為了適用於所有內容,這個透過讀取RSC Payload的VDOM僅在以下兩種情況才建立:
- 父層元素包含
Client Component Client Component內有佔位標記(placeholder)
React如何建置VDOM?
// RSC payload from server:
[
["M", "ClientComponent.js"],
["S", "<div><h1>Hello</h1>"],
["J", "ClientComponent.js", {}, "B:0"],
["S", "</div>"]
]
// React builds this sparse VDOM:
{
type: "div", // Parent structure is needed
children: [
// Note: No VDOM node for <h1> - it's pure server HTML!
{
type: ClientComponent, // Client component gets a VDOM node
props: {}, // Props passed from server
marker: "B:0" // Links to the template in HTML
}
]
}
RSC Payload 是有順序性的,這樣才能讓VDOM的節點正確匹配到html上的節點,React建置這個VDOM需要做以下流程:
- 載入
Client Component - 將組件與html上的插槽(slots)做匹配
流程:
- 查找標記
- 找出匹配於html節點的組件
- 準確地告訴React 每個
Client Component該指向那個節點
- 讓組件可以有互動
流程:
- 建立
Client Component實體 - 設定組件狀態(eg: useState(0) useEffect(...)等等)
- 附加 event handler(eg: onClick 等事件)
- 將原本的template替換成此即時組件
- 建立
以上流程就使React讓Client Component有互動性了.
為啥要讓 Client Component上面標記"use client"
因為預設所有的組件都是Server Component,這是故意設計的,目標是讓用戶端的javascript變少.
當bundler看到 "use client" 標記,就知道這是要打包到用戶端的組件,會將這類組件集中處理.
結論
開發時需要多思考組件是是用在server端或client端還是兩端,複雜度有提升不少,但Server Component有一個最大的好處,就是原本該組件上相依的庫都不會被送到用戶端,就不用載入一大堆相依的JS就只為了那個靜態的內容,只需要在Server端渲染html時,產生結果就好,可以省掉不少加載js的開銷.
Reference Links
Why are React Server Components actually beneficial? (full history)_2025-01
從 Next.js 13 認識 React Server Components
How to Optimize RSC Payload Size_2025-02
React Server Component 的工作原理_2023-04
題外話
假設某個Server Component是需要等1秒才能有數據,在NextJS中, 若沒有用React.Suspense組件包裹,那返回的html就會被延遲一秒才能返回,若有包裹React.Suspense就會立即返回html,此時的內容是Suspense的fallback組件,當一秒後,會用原本html的connection返回一個chunk並替換內容.
