原文地址:How React server components work: an in-depth guide
React 伺服器元件如何運作:深入指南#
React 伺服器元件(RSC)是一個令人興奮的新功能,在不久的將來會對頁面加載性能、包的大小以及我們如何編寫 React 應用程序產生巨大影響。儘管 RSC 在 React 18 中仍然是一個早期的實驗性功能,我們一直在挖掘它在引擎蓋下的工作原理。在這篇博文中,我們很高興地與大家分享我們所學到的東西。
什麼是 React 伺服器元件#
React 伺服器元件允許伺服器和客戶端(瀏覽器)在渲染你的 React 應用程序時進行協作,我們開發的頁面一般是 React Dom 樹渲染的結果,React Dom 樹一般是由很多 Component 組成的,RSC 使得 Tree 中的一些 Component 可以由伺服器渲染,而一些由瀏覽器渲染。
這是 React 團隊提供的一個快速插圖,顯示了最終目標是什麼:一棵 React 樹,其中橙色元件在伺服器上渲染,而藍色元件在客戶端渲染。
這不是伺服器端渲染嗎?#
**RSC 不是伺服器端渲染(SSR)!** 它有點令人困惑,因為它們都有名稱中的 “伺服器”,它們都在伺服器上工作。但它更容易理解它們作為兩個獨立和正交的功能。使用 RSC 不需要使用 SSR,反之亦然!SSR 模擬一個環境,用於將 React 樹渲染為原始 HTML; 它不會區分伺服器和客戶端元件,並且它使它們相同的方式!
不過,也可以將 SSR 和 RSC 結合使用,這樣您就可以使用伺服器元件進行伺服器端呈現,並在瀏覽器中適當地將它們合併起來。在以後的文章中,我們將更多地討論它們是如何一起工作的。
但是現在,讓我們忽略 SSR,只關注 RSC。
為什麼我們會想要這個?#
在 React 伺服器元件之前,所有 React 元件都是 “客戶端” 元件 - 它們都在瀏覽器中運行。當您的瀏覽器訪問反應頁面時,它會下載所有必要的 React 元件的代碼,構造 React Element 樹,並將其呈現給 DOM(或者如果您使用的 SSR,則水解 DOM)。瀏覽器是一個很好的地方,因為它允許您的 React 應用程序是互動式 - 您可以安裝事件處理程序,跟蹤狀態,響應事件的響應和更新 DOM 的響應。那么我們為什麼要在伺服器上呈現任何東西?
在伺服器上呈現比在瀏覽器上呈現有一些優勢
-
伺服器可以更直接地訪問您的數據源 - 是他們的數據庫,GraphQL 端點或文件系統。伺服器可以直接獲取所需的數據,而不通過一些公共 API 端點跳躍,通常與您的數據源更緊密地匯總,因此它可以比瀏覽器更快地獲取數據。
-
伺服器可以廉價地使用大量的代碼模塊,比如用於將標記轉換為 html 的 npm 包,因為伺服器不需要像瀏覽器那樣每次使用時都需要下載這些依賴項,而瀏覽器必須將所有使用的代碼作為 javascript 包下載。
簡而言之,**React 伺服器元件使伺服器和瀏覽器能夠做到最好的事情。** 伺服器元件可以專注於獲取數據和渲染內容,並且客戶端元件可以專注於有狀態交互,導致較快的頁面加載,較小的 JavaScript 捆綁尺寸以及更好的用戶體驗。
類比直觀的認識#
讓我們先對它的工作原理有一些直觀的認識。
我的孩子喜歡裝飾蛋糕,但他們不是那麼抱怨它們。要求他們從頭開始製作和裝飾杯形蛋糕將是一個(可愛的)噩夢。我需要用手向他們的面粉和糖,棒的黃油,讓他們進入烤箱,讀它們一噸的指示,並花一整天。但嘿,我可以更快地做烘烤部分;如果我通過第一次烘烤蛋糕並使糖果烘烤,並將那些人交給我的孩子,而不是原料的作品 - 他們可以更快地裝飾樂趣!更好,我不需要擔心他們所有人都用烤箱擔心。贏!
React 伺服器元件就是為了實現這種分工,讓伺服器先做它能做的更好,然後再把剩下的交給瀏覽器完成。這樣一來,比起一整袋面粉和一個該死的烤箱,服務員要給的東西更少,12 個小紙杯蛋糕運輸起來更有效率。
考慮對您的頁面的 React 樹,有一些要在伺服器上呈現的元件以及客戶端的一些元件。這是考慮高級策略的一種簡化方法:伺服器可以像往常一樣 “渲染” 伺服器元件,將您的 React 元件轉換為 Div 和 P 等本機 HTML 元素。但是,每當它遇到 Client 元件時意味著要在瀏覽器中呈現,它就剛剛輸出佔位符,其中包含填充此孔的指令,其中包含右 Client 元件和道具。然後,瀏覽器採用該輸出,填充 Client 元件。
這不是它如何工作,我們即將很快跳入那些真正的粗糙細節;但它是一個有用的高級別畫面!
伺服器 - 客戶端元件劃分#
但首先,什麼是伺服器元件?如何確定哪些元件用於伺服器,哪些元件用於客戶端?
React 團隊根據元件寫入的文件的擴展名:如果文件以.server.jsx
結尾,則它包含伺服器元件;如果它以.client.jsx
結尾,它包含客戶端元件。如果它沒有,那麼它包含可以用作伺服器和客戶端元件的元件。
這種定義是務實的 - 開發者和 Bundler 很容易告訴他們分開。專為 Bundler,他們現在能夠通過檢查文件名來處理不同的客戶元件。因為你很快就會看到,Bundler 在制定 RSC 工作方面發揮著重要作用。
由於伺服器元件在伺服器上運行,而 Client 件在客戶端上運行,因此每個都可以執行許多限制。** 但要記住的最重要的是 Client 元件無法導入 Server 元件!** 這是因為伺服器元件無法在瀏覽器中運行,並且可能有代碼在瀏覽器中不起作用;如果客戶端元件依賴於伺服器元件,那麼我們將最終將這些非法依賴項拉到瀏覽器包中。
最後一點可能會讓人挠頭;這意味著像這樣的客戶端元件是非法的
// ClientComponent.client.jsx
// NOT OK:
import ServerComponent from './ServerComponent.server'
export default function ClientComponent() {
return (
<div>
<ServerComponent />
</div>
)
}
但是,如果客戶端元件無法導入伺服器元件,因此無法實例化伺服器元件,那麼我們如何使用這樣的反應樹結束,使用伺服器和客戶端元件在一起交錯?如何在客戶端元件(藍點)下有伺服器元件(橙色點)?
雖然您無法從客戶端元件導入和渲染伺服器元件,但仍然可以使用組合 - 即,客戶端元件仍然可以採用僅是不透明的 ReactNodes,並且可能遇到這些 ReactNodes 由伺服器元件呈現。例如:
// ClientComponent.client.jsx
export default function ClientComponent({ children }) {
return (
<div>
<h1>Hello from client land</h1>
{children}
</div>
)
}
// ServerComponent.server.jsx
export default function ServerComponent() {
return <span>Hello from server land</span>
}
// OuterServerComponent.server.jsx
// OuterServerComponent can instantiate both client and server
// components, and we are passing in a <ServerComponent/> as
// the children prop to the ClientComponent.
import ClientComponent from './ClientComponent.client'
import ServerComponent from './ServerComponent.server'
export default function OuterServerComponent() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
)
}
這種限制將對您如何組織元件以更好地利用 RSC 來重大影響。
RSC 渲染的生命#
讓我們潛入當您嘗試渲染 React 伺服器元件時實際發生的內容的細節。您不需要了解這裡能夠使用伺服器元件的所有內容,但它應該為您提供一些直覺如何工作!
1. 伺服器接收渲染的請求#
因為伺服器需要做一些渲染工作,所以使用 RSC 的頁面的生命總是從伺服器開始,響應一些 API 調用來渲染一個 React 元件。這個 "根" 元件總是一个伺服器元件,它可以渲染其他伺服器或客戶端元件。伺服器根據請求中傳遞的信息來確定使用哪個伺服器元件和什麼 props。這個請求通常是以特定 url 的頁面請求的形式出現的,儘管 Shopify Hydrogen 有更精細的方法,React 團隊的官方演示也有一個原始實現。
2. 伺服器將根元件元素序列化為 JSON#
這裡的最終目標是將最初的根伺服器元件渲染成一棵由基本 html 標籤和客戶端元件 "佔位符" 組成的樹。然後,我們可以將這棵樹序列化,並將其發送給瀏覽器,而瀏覽器可以對其進行反序列化,用真正的客戶端元件填充客戶端佔位符,並渲染出最終結果。
所以,在上面的示例之後 - 假設我們想要渲染 <OuterServerComponent />。我們可以剛剛做 json.stringify(<OuterserverComponent />)來獲取序列化元素樹嗎?
幾乎,但不太好!😅 恢復實際的反應元素是 - 一個對象,具有類型字段為字符串 - 對於基本 HTML 標記,如 “div” - 或函數 - 對於反應元件實例。
// React element for <div>oh my</div>
> React.createElement("div", { title: "oh my" })
{
$$typeof: Symbol(react.element),
type: "div",
props: { title: "oh my" },
...
}
// React element for <MyComponent>oh my</MyComponent>
> function MyComponent({children}) {
return <div>{children}</div>;
}
> React.createElement(MyComponent, { children: "oh my" });
{
$$typeof: Symbol(react.element),
type: MyComponent // reference to the MyComponent function
props: { children: "oh my" },
...
}
當您有一個元件元素時 - 不是基本 HTML 標記元素 - 類型字段引用元件函數,並且功能不是 json-serializable!
要正確 json-stryify 所有內容,React 將特殊的替換功能傳遞給JSON.Stringify()
,該函數適當地處理這些元件函數參考;您可以在ReastFlightServer.js 中找到它作為 resolvemodeltojson()。
具體地,每當它看到才能序列化的反應元素時,
-
如果它是基礎 HTML 標記(類型字段是一个像 “'
div
')的字符串,那麼它已經是序列化的!沒什麼特別的。 -
如果是伺服器元件,則將伺服器元件函數(存儲在類型字段中)與其道具調用,並序列化結果。這有效地 “渲染” 伺服器元件;這裡的目標是將所有伺服器元件轉換為基礎 HTML 標記。
-
如果它是客戶元件,那麼... 實際上已經序列化了!類型字段實際上已經指向模塊引用對象,而不是元件函數。等等,什麼?!
什麼是 “模塊參考” 對象?
RSC 引入了 React 元素類型字段的新可能值,稱為 “模塊引用”; 而不是元件函數,它是一個可序列化的 “引用”。
例如,ClientComponent
可能看起來像這樣:
{
$$typeof: Symbol(react.element),
// The type field now has a reference object,
// instead of the actual component function
type: {
$$typeof: Symbol(react.module.reference),
// ClientComponent is the default export...
name: "default",
// from this file!
filename: "./src/ClientComponent.client.js"
},
props: { children: "oh my" },
}
但這種手在哪裡發生了發生 - 在那裡我們正在將客戶元件函數的引用轉換為可序列化的 “模塊引用” 對象?
事實證明,這是捆綁的是表演這個神奇的技巧!React 團隊已發布對react-server-dom-webpack
的 WebPack 作为webpack-loader或node-register的官方 RSC 支持。當伺服器元件從* .client.jsx
文件導入某些內容時,而不是實際獲取該件事,它只獲取模塊參考對象,其中包含該件事的文件名和導出名稱。沒有客戶元件函數是在伺服器上構建的 React 樹的一部分!
再次考慮上面的示例,我們正在嘗試序列化 <OuterSuperComponent />; 我們最終會享受 json 樹:
{
// ClientComponent元素佔位符,具有“模塊參考”
$$typeof: Symbol(react.element),
type: {
$$typeof: Symbol(react.module.reference),
name: "default",
filename: "./src/ClientComponent.client.js"
},
props: {
// children passed to ClientComponent, which was <ServerComponent />.
children: {
// ServerComponent gets directly rendered into html tags;
// notice that there's no reference at all to the
// ServerComponent - we're directly rendering the `span`.
$$typeof: Symbol(react.element),
type: "span",
props: {
children: "Hello from server land"
}
}
}
}
可序列化的 React 樹
在此過程結束時,我們希望最終能夠使用一個反應樹,在伺服器上看起來更像是這樣的東西,將被發送到瀏覽器 “完成”:
所有 props 都必須是可序列化的
因為我們正在序列化整個 React 樹到 JSON,所以您傳遞給客戶元件或基本 HTML 標記的所有道具也必須是序列化的。這意味著來自伺服器元件,您無法將事件處理程序作為 prop 傳遞!
//注意:伺服器元件無法將函數傳遞為一個prop
// to its descendents, because functions are not serializable.
function SomeServerComponent() {
return <button onClick={() => alert('OHHAI')}>Click me!</button>
}
然而,這裡要注意的一件事是在 RSC 過程中,當我們遇到客戶元件時,我們從不調用客戶元件函數,或者將 “descend” 進入客戶元件。因此,如果您有一個實例化另一個客戶元件的客戶元件:
function SomeServerComponent() {
return <ClientComponent1>Hello world!</ClientComponent1>;
}
function ClientComponent1({children}) {
// It is okay to pass a function as prop from client to
// client components
return <ClientComponent2 onChange={...}>{children}</ClientComponent2>;
}
ClientComponent2 在此 RSC JSON 樹中根本不會出現;相反,我們將只能看到具有模塊引用和 ClientComponent1 的 Props 的元素。因此,ClientComponent1 是完全合法的,將事件處理程序傳遞給 CliencPonent2。
3. 瀏覽器重建 React 樹#
瀏覽器從伺服器接收 JSON 輸出,現在必須啟動重建在瀏覽器中呈現的 React 樹。每當我們遇到類型是模塊引用的元素時,我們都希望將其替換為正確的客戶元件函數。
這項工作再次需要我們 bundler 的幫助;它是我們的 bundler 替換了客戶元件函數,使用模塊在伺服器上引用,現在我們的 bundler 知道如何使用瀏覽器中的真實客戶元件函數替換這些模塊引用。
重建的 React 樹將看起來像這樣 - 只需在:
然後我們只是像往常一樣渲染並將這棵樹提交到 DOM!
它是否支持和 Suspense 一起運作#
可以一起運作
我們故意在本文中提到 Suspense,因為懸念是一個巨大的主題,值得自己的博文。但非常短暫的 - Suspense 允許您在需要尚未準備的內容(獲取數據,懶惰導入元件等)時從反應元件中拋出 Promise。這些 Promise 被捕獲到 “Suspense boundary” - 每當從渲染懸念子樹中拋出 Promise 時,會暫停渲染該子樹直到 Promise Resolved,然後再次嘗試。
當我們調用伺服器上的伺服器元件函數以生成 RSC 輸出時,這些功能可能會在獲取所需的數據時拋出 Promise。當我們遇到這樣的拋出 Promise 時,我們輸出佔位符;一旦 Promise 得到解決,我們嘗試再次調用伺服器元件功能,如果我們成功,請輸出已完成的 Chunk。我們實際上是創建 RSC 輸出的 Stream,暫停作為 Promise 被拋出,並在解決它們時流匯集額外的 Chunk。
同樣,在瀏覽器中,我們正在將 RSC JSON 輸出從我們的fetch()
呼叫中流下來。此過程也可能最終遇到輸出中的 Suspense 佔位符(伺服器遇到拋出的 Promise),並且尚未看到 Steam 中的佔位符內容(這裡有些細節)的情況結束,並尚未見到佔位符,或者,如果它遇到客戶元件模塊引用,則可能還拋出 Promise,但在瀏覽器中尚未加載該客戶元件函數 - 在這種情況下,bundler 運行時必須動態獲取必要的 chunks。
由於 Suspense,您可以將伺服器流式傳輸 RSC 輸出作為伺服器元件獲取數據,並且您將瀏覽器逐步呈現數據,並在其變得可用時呈現數據,並在必要時動態獲取客戶元件包。
RSC 線格式#
但是伺服器究竟是什麼?如果你讀到 “json” 和 “流” 時抬起眉毛,你是對持懷疑態度的權利!那麼,伺服器流傳輸到瀏覽器的數據是什麼?
這是一種簡單格式,每行上有一個 json blob,標記為一個 id。以下是我們 <OuterServerComponent/>:
M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
J0:["$","@1",null,{"children":["$","span",null,{"children":"Hello from server land"}]}]
在上面的代碼段中,以M
開頭的行定義了客戶端元件模塊的參考,其中需要查找客戶端捆綁包中的元件函數所需的信息。以J
開頭的行定義了一個實際的 React Element 樹,其中包含@1
引用由M
行定義的客戶端元件。
此格式非常符合 stream 的方式 - 一旦客戶端讀取整行,它就可以解析了一個 json 片段並進行了一些進展。如果伺服器在渲染時遇到 Suspense Boundaries,則會看到與已解決的每個 Chunk 對應的多個J
行。
例如,讓我們的示例更有趣......
// Tweets.server.js
import { fetch } from 'react-fetch' // React's Suspense-aware fetch()
import Tweet from './Tweet.client'
export default function Tweets() {
const tweets = fetch(`/tweets`).json()
return (
<ul>
{tweets.slice(0, 2).map((tweet) => (
<li>
<Tweet tweet={tweet} />
</li>
))}
</ul>
)
}
// Tweet.client.js
export default function Tweet({ tweet }) {
return <div onClick={() => alert(`Written by ${tweet.username}`)}>{tweet.body}</div>
}
// OuterServerComponent.server.js
export default function OuterServerComponent() {
return (
<ClientComponent>
<ServerComponent />
<Suspense fallback={'Loading tweets...'}>
<Tweets />
</Suspense>
</ClientComponent>
)
}
在這種情況下,RSC 流是什麼樣的?
M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
S2:"react.suspense"
J0:["$","@1",null,{"children":[["$","span",null,{"children":"Hello from server land"}],["$","$2",null,{"fallback":"Loading tweets...","children":"@3"}]]}]
M4:{"id":"./src/Tweet.client.js","chunks":["client8"],"name":""}
J3:["$","ul",null,{"children":[["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}],["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}]]}]
J0
行現在有一個額外的 Children - 新的Suspense boundary
,children 指向引用@3
。有趣的是在這裡注意到@3
目前尚未定義!當伺服器完成加載推文時,它會輸出M4
的行 - 它定義了對Tweet.Client.js
元件 - 和J3
的模塊引用 - 它定義了另一個應將其交換到@3
所在的其他反應元素樹(且再次請注意,J3
children 正在引用M4
中定義的Tweet元件
)。
這裡有另一件事要注意,那是 Bundler 將 ClientComponent 和 Tweet 自動將 ClientComponent 和 Tweet 分為兩個單獨的捆綁包,這允許瀏覽器推遲到以後推出推文 bundle 包!
使用 RSC 格式#
如何將此 RSC 流轉換為瀏覽器中的實際反應元素?React-server-dom-webpack
包含 rsc 響應並重新創建 React Element 樹的入口點。以下是您的根客戶端元件可能如下所示的簡化版本:
import { createFromFetch } from 'react-server-dom-webpack'
function ClientRootComponent() {
// fetch() from our RSC API endpoint. react-server-dom-webpack
// can then take the fetch result and reconstruct the React
// element tree
const response = createFromFetch(fetch('/rsc?...'))
return <Suspense fallback={null}>{response.readRoot() /* Returns a React element! */}</Suspense>
}
您要求React-Server-DOM-WebPack
從 API 端點讀取 RSC 響應。然後,Response.Readroot()
返回更新的反應元素,因為響應流被處理過!在讀取任何流之前,它將立即拋出 Promise - 因為尚未準備好內容。然後,當它處理第一個J0
時,它會創建相應的 React Element 樹並解析拋出的 Promise。React 恢復渲染,但是當遇到未且準備就緒@3
引用時,另一個 Promise 被拋出。一旦它讀取J3
,該 Promise 得到解決,並再次恢復渲染,這次完成。因此,隨著我們將 RSC 響應流流,我們將繼續更新和呈現我們在 Suspense boundary 定義的 Chunk 中的元素樹,直到我們完成。
為什麼不僅僅是輸出普通的 HTML?#
為什麼要發明全新的線材格式?客戶端上的目標是重建 React Element 樹。從這種格式完成這一格式比 HTML 更容易,其中我們必須解析 HTML 以創建 React Elements。請注意,React Element 樹的重建是重要的,因為這允許我們將後續更改合併到 DOM 的最小提交。
這比僅僅從客戶端元件獲取數據更好嗎#
如果我們需要向伺服器進行 API 請求以獲取此內容,這比提出要獲取數據的請求,然後在客戶中完全渲染,正如我們今天所做的那樣?
最終,它取決於您在螢幕上呈現的內容。使用 RSC,您獲得了非常規的能力,“處理過的” 數據直接展示給用戶,因此如果您只渲染您將要獲取的小型數據,或者渲染本身需要一個許多您想要避免下載到瀏覽器的 javascript。如果渲染需要很多後台數據獲取,那麼在伺服器運行相關數據會更好,其中數據延遲遠低於瀏覽器。
但是...... 伺服器端渲染怎麼樣?#
我知道我知道我知道。通過 React 18,可以將 SSR 和 RSC 組合起來,以便您可以在伺服器上生成 HTML,然後在瀏覽器中使用 RSC 的 HTML 水合物。在這個主題上留下來的更多關注!
更新伺服器元件呈現的內容#
如果您需要伺服器元件呈現新的內容,例如,例如,如果要在將一個產品視為其他產品之間的頁面之間切換,則為:
同樣,由於渲染發生在伺服器上,這需要另一個 API 調用伺服器以獲得 RSC 線格式的新內容。好消息是,一旦瀏覽器收到新內容,它可以構造一個新的 React 元素樹,並執行與上個 React 樹的常用協調,以找出 DOM 所需的最小更新,而駐留狀態和事件。您的客戶端元件中的處理程序。對於客戶端元件,如果它完全在瀏覽器中發生,則此更新將與其不同。
現在,您必須從根伺服器元件重新渲染整個root server component
,但在將來,可能會為子樹執行此操作。
為什麼我需要使用 RSC 的元框架?#
React 團隊表示,RSC 最初是通過Next.js或Shopify Hydrogen的Meta-Frameworks採用,而不是直接用於普通的反應項目。但為什麼?元框架為你做了什麼?
你不必,但它會讓你的生活更輕鬆。元框架提供友好的包裝器和抽象,因此您永遠不必考慮在伺服器中生成 RSC 流,並在瀏覽器中消耗它。元框架也支持伺服器端渲染,並且他們正在進行工作,以確保如果您使用的是伺服器元件,則可以正確保密伺服器生成的 HTML。
如您所見,您還需要從 Bundler 中的合作才能在瀏覽器中妥善發貨和使用客戶端元件。已經有一個 WebPack 集成,而且 shopify 正在研究Vite 集成。這些插件需要成為 React Repo 的一部分,因為 RSC 所需的許多件未作為公共 NPM 包發布。但是,一旦開發,應該可以使用這些碎片而沒有涉及的元框架。
RSC 準備好了嗎?#
React 伺服器元件現在可作為 Next.js 下個實驗功能,並在當前的開發者預覽中用於 Shopify Hydrogen,但也沒有準備好生產使用。在未來的博客文章中,我們將潛入其中每個框架如何使用 RSC。
但是,毫無疑問,React 伺服器元件將是反應未來的重要組成部分。它是 React 的回答,以更快的頁面加載,較小的 JavaScript 捆綁包,更短的時間互動式 - 更全面的論點是如何使用 React 構建多頁應用程序。它可能還沒有準備好,但很快就會開始關注。