SSG是后端「編譯時方案」。使用SSG的業務,后端代碼在編譯時會生成html(通常會被上傳CDN)。當前端發起請求后,后端(或CDN)始終會返回編譯生成的HTML。
大家好,我卡頌。
React Server Component(后文簡稱RSC)是React近幾年最重要的特性。雖然他對React未來發展至關重要,但由于:
- 仍屬實驗特性。
- 配置比較繁瑣,且局限較多。
所以雖然體驗Demo[1]已經發布3年了,但仍屬于「知道的人多,用過的人少」。
本文會從以下幾個角度介紹RSC:
- RSC是用來做啥的?
- RSC和其他服務端渲染方案(SSR、SSG)的區別
- RSC的工作原理
希望讀者讀完本文后對RSC的應用場景有清晰的認識。
本文參考了how-react-server-components-work[2]
什么是RSC
對于一個React組件,可能包含兩種類型的狀態:
- 前端交互用的狀態,比如加載按鈕的顯/隱狀態
- 后端請求回的數據,比如下面代碼中的data狀態用于保存后端數據:
function App() {
const [data, update] = useState(null);
useEffect(() => {
fetch(url).then(res => update(res.json()))
}, [])
return <Ctn data={data}/>;
}
「前端交互用的狀態」放在前端很合適,但「后端請求回的數據」邏輯鏈路如果放在前端則比較繁瑣,整個鏈路類似如下:
- 前端請求并加載React業務邏輯代碼。
- 應用執行渲染流程。
- App組件mount,執行useEffect,請求后端數據。
- 后端數據返回,App組件的子組件消費數據。
如果我們根據「狀態類型」將組件分類,比如:
- 「只包含交互相關狀態」的組件,叫客戶端組件(React Client Component,簡寫RCC)。
- 「只從數據源獲取數據」的組件,叫服務端組件(React Server Component,簡寫RSC)。
按照這種邏輯劃分,上述代碼中:
- App組件只包含數據,顯然屬于SSR。
- App組件的子組件Ctn消費data,如果他內部包含交互邏輯,應該屬于RCC。
將上述代碼改寫為:
function App() {
// 從數據庫獲取數據
const data = getDataFromDB();
return <Ctn data={data}/>;
}
其中:
- App組件在后端運行,可以直接從數據源(這里是數據庫)獲取數據
- Ctn組件在前端運行,消費數據
改造后「前端交互用的狀態」邏輯鏈路不變,而「后端請求回的數據」邏輯鏈路卻變短很多:
- 后端從數據源獲取數據,將RSC數據返回給前端。
- 前端請求并加載業務邏輯代碼(來自步驟0)。
- 應用執行渲染流程(此時App組件已經包含數據)。
- App組件的子組件消費數據。
這就是RSC的理念,一句話概括就是 —— 根據狀態類型,劃分組件類型,RCC在前端運行,RSC在后端運行。
與SSR、SSG的區別
同樣涉及到前端框架的后端運行,RSC與SSR、SSG有什么區別呢?
首先,SSG是后端「編譯時方案」。使用SSG的業務,后端代碼在編譯時會生成HTML(通常會被上傳CDN)。當前端發起請求后,后端(或CDN)始終會返回編譯生成的HTML。
RSC與SSR則都是后端「運行時方案」。也就是說,他們都是前端發起請求后,后端對請求的實時響應。根據請求參數不同,可以作出不同響應。
同為后端運行時方案,RSC與SSR的區別主要體現在輸出產物:
- 類似于SSG,SSR的輸出產物是HTML,瀏覽器可以直接解析。
- RSC會流式輸出一種「類JSON」的數據結構,由前端的React相關插件解析。
既然輸出產物不同,那么他們的應用場景也是不同的。
比如,在需要考慮seo(即需要后端直接輸出HTML)時,SSR與SSG可以勝任(都是輸出HTML),而RSC則不行(流式輸出)。
同時,由于實現不同,同一個應用中可以同時存在SSG、SSR以及RSC。
RSC的限制
「RSC規范」是如何區分RSC與RCC的呢?根據規范定義:
- 帶有.server.js(x)后綴的文件導出的是RSC。
- 帶有.client.js(x)后綴的文件導出的是RCC。
- 沒有帶server或client后綴的文件導出的是通用組件。
所以,我們上述例子可以導出為2個文件:
// app.server.jsx
function App() {
// 從數據庫獲取數據
const data = getDataFromDB();
return <Ctn data={data}/>;
}
// ctn.client.jsx
function Ctn({data}) {
// ...省略邏輯
}
對于任意應用,按照「RSC規范」拆分組件后,能得到類似如下的組件樹,其中RSC
和RCC
可能交替出現:
但是需要注意:RCC中是不允許import RSC的。也就是說,如下寫法是不支持的:
// ClientCpn.client.jsx
import ServerCpn from './ServerCpn.server'
export default function ClientCpn() {
return (
<div>
<ServerCpn />
</div>
)
}
這是因為,如果一個組件是RCC,他運行的環境就是前端,那么他的子孫組件的運行環境也是前端,但RSC是需要在后端運行的。
那么上述RSC和RCC交替出現是如何實現的呢?
答案是:通過children。
改寫下ClientCpn.client.jsx:
// ClientCpn.client.jsx
export default function ClientCpn({children}) {
return (
<div>{children}</div>
)
}
在OuterServerCpn.server.jsx中引入ClientCpn與ServerCpn:
// OuterServerCpn.server.jsx
import ClientCpn from './ClientCpn.client'
import ServerCpn from './ServerCpn.server'
export default function OuterServerCpn() {
return (
<ClientCpn>
<ServerCpn />
</ClientCpn>
)
}
組件結構如下:
解釋下這段代碼,首先OuterServerCpn是RSC,則他運行的環境是后端。他引入的ServerCpn組件運行環境也是后端。
ClientCpn組件雖然運行環境在前端,但是等他運行時,他拿到的children props是后端已經執行完邏輯(已經獲得數據)的ServerCpn組件。
RSC協議詳解
我們可以將RSC看作一種rpc(Remote Procedure Call,遠程過程調用)協議的實現。數據傳輸的兩端分別是「React后端運行時」與「React前端運行時」。
一款rpc協議最基本的組成包括三部分:
- 數據的序列化與反序列化
- id映射
- 傳輸協議
以上面的OuterServerCpn.server.jsx舉例:
// OuterServerCpn.server.jsx
import ClientCpn from './ClientCpn.client'
import ServerCpn from './ServerCpn.server'
export default function OuterServerCpn() {
return (
<ClientCpn>
<ServerCpn />
</ClientCpn>
)
}
// ClientCpn.client.jsx
export default function({children}) {
return <div>{children}</div>;
}
// ServerCpn.server.jsx
export default function() {
return <div>服務端組件</div>;
}
這段組件代碼轉化為RSC數據后如下(不用在意數據細節,后文會解釋):
M1:{"id":"./src/ClientCpn.client.js","chunks":["client1"],"name":""}
J0:["$","div",null,{"className":"main","children":["$","@1",null,{"children":["$","div",null,{"children":"服務端組件"}]}]}]
接下來我們從上述三個角度分析這段數據結構的含義。
數據的序列化與反序列化
RSC是一種「按行分隔」的數據結構(方便按行流式傳輸),每行的格式為:
[標記][id]: JSON數據
其中:
- 「標記」代表這行的數據類型,比如J代表「組件樹」,M代表「一個RCC的引用」,S代表Suspense
- id代表這行數據對應的id。
- JSON數據保存了這行具體的數據。
RSC的序列化與反序列化其實就是JSON的序列化與反序列化。反序列化后的數據再根據「標記」不同做不同處理。
比如,對于上述代碼中第二行數據:
J0:["$","div",null,{"className":"main","children":["$","@1",null,{"children":["$","div",null,{"children":"服務端組件"}]}]}]
可以理解為,這行數據描述了一棵組件樹(標記J),id為0,組件樹對應數據為:
[
"$","div",null,{
"className":"main","children":[
"$","@1",null,{
"children":["$","div",null,{
"children":"服務端組件"}]
}
]
}
]
當前端反序列化這行數據后,會根據上述JSON數據渲染組件樹。
id映射
所謂「id映射」,是指 對于同一個數據,如何在rpc協議傳輸的兩端對應上?
在「RSC協議」的語境下,是指 對于同一個組件,經由RSC在React前后端運行時之間傳遞,是如何對應上的。
還是考慮上面的例子,回顧下第二行RSC對應的數據:
[
"$","div",null,{
"className":"main","children":[
"$","@1",null,{
"children":["$","div",null,{
"children":"服務端組件"}]
}
]
}
]
這段數據結構有些類似JSX的返回值,把他與組件層級放到一張圖里對比下:
可以發現,這些信息已經足夠前端渲染<OuterServerCpn/>、<ServerCpn/>組件了,但是<ClientCpn/>對應的數據@1是什么意思呢?
這需要結合第一行RSC的數據來分析:
M1:{"id":"./src/ClientCpn.client.js","chunks":["client1"],"name":""}
M標記代表這行數據是「一個RCC的引用」,id為1,數據為:
{
"id":"./src/ClientCpn.client.js",
"chunks":["client1"],
"name":""
}
第二行中的@1就是指「引用id為1的RCC」,根據第一行RSC提供的信息,React前端運行時知道id為1的RCC包含一個名為client1的chunk,路徑為"./src/ClientCpn.client.js"。
于是React前端運行時會向這個路徑發起JSONP請求,請求回<ClientCpn/>組件對應代碼:
如果應用包裹了<Suspense/>,那么請求過程中會顯示fallback效果。
可以看到,通過協議中的:
- M[id],定義id對應的「RCC數據」。
- @[id],引用id對應的「RCC數據」。
就能將同一個RCC在React前后端運行時對應上。
那么,為什么RCC不像RSC一樣直接返回數據,而是返回引用id呢?
主要是因為RCC中可能包含前端交互邏輯,而有些邏輯是不能通過「RSC協議」序列化的(底層是JSON序列化)。
比如下面的onClick props是一個函數,函數是不能通過JSON序列化的:
<button onClick={() => console.log('hello')}>你好</button>
這里我們再梳理下「RSC協議」中「id映射」的完整過程:
- 業務開發時通過.server | client后綴區分組件類型。
- 后端代碼編譯時,所有RCC(即.client后綴文件)會編譯出獨立文件(這一步是react-server-dom-webpack[3]插件做的,對于Vite,也有人提了Vite插件的實現 PR[4])。
- React后端返回給前端的RSC數據中包含了組件樹(J標記)等按行表示的數據。
- React前端根據J標記對應數據渲染組件樹,遇到「引用RCC」(形如M[id])時,根據id發起JSONP請求。
- 請求返回該RCC對應組件代碼,請求過程的pending狀態由<Suspense/>展示。
傳輸協議
RSC數據是以什么格式在前后端間傳遞呢?
不同于一些rpc協議會基于TCP或UDP實現,「RSC協議」直接基于「HTTP協議」實現,其Content-Type為text/x-component。
總結
本文從理念、原理角度講解了RSC,過程中回答了幾個問題。
Q:RSC和其他服務端渲染方案有什么區別?
A:RSC是服務端運行時的方案,采用流式傳輸。
Q:為什么需要區分RSC與RCC(通過文件后綴)?
A:因為RSC需要在后端獲取數據后流式傳輸給前端,而RCC在后端編譯時編譯成獨立文件,前端渲染時再以JSONP的形式請求該文件
Q:為什么RCC中不能import RSC?
A:因為他們的運行環境不同(前者在前端,后者在后端)
由于配置繁瑣,并不推薦在現有React項目中使用RSC。想體驗RSC的同學,可以使用Next.js并開啟App Router:
在這種情況下,組件默認為RSC。
參考資料
[1]體驗Demo:https://Github.com/reactjs/server-components-demo
[2]how-react-server-components-work:https://www.plasmic.app/blog/how-react-server-components-work
[3]react-server-dom-webpack:https://www.npmjs.com/package/react-server-dom-webpack
[4]Vite插件的實現 PR:https://github.com/facebook/react/pull/26926