我們一起理解 React 服務(wù)端組件
有件事讓我感覺自己真的老了:React 今年已經(jīng) 10 歲了。
自從 React 首次被引入以來,經(jīng)歷了幾次演變。 React 團隊并不羞于改變:如果他們發(fā)現(xiàn)了更好的問題解決方案,就會采用。
React 團隊推出了 React 服務(wù)端組件(React Server Components),這是最新的編寫范式。 React 組件有史以來第一次可以專門在服務(wù)器上運行。
網(wǎng)上對這個概念有太多不理解。許多人對服務(wù)端組件是什么、如何工作、有什么好處以及是如何與服務(wù)器端渲染等內(nèi)容結(jié)合使用存在很多疑問。
我一直在使用 React 服務(wù)端組件進行大量實驗,也回答了我自己產(chǎn)生的很多問題。我必須承認,我對這些東西比我預(yù)想的要興奮得多,因為它真的很酷!
今天,我將幫助你揭開 React 服務(wù)端組件的神秘面紗,回答你可能對 React 服務(wù)端組件存在的許多問題!
服務(wù)端渲染快速入門
由于實際場景中,React 服務(wù)端組件通常與服務(wù)端渲染(Server Side Rendering,簡稱 SSR)配合使用,因此預(yù)先了解服務(wù)端渲染的工作原理會很有幫助。當然,如果你已經(jīng)很熟悉 SSR 了,則可以跳過本節(jié)的學(xué)習(xí)。
在我 2015 年第一次使用 React 時,那時候的大多數(shù) React 項目都還采用“客戶端渲染”策略。
在客戶端渲染模式下,用戶會先收到下面這樣一個比較簡單的網(wǎng)頁。
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="/static/js/bundle.js"></script>
</body>
</html>
bundle.js 包含整個項目初始化和運行階段的所有代碼。包括 React、其他三方依賴以及我們自己的業(yè)務(wù)代碼。
JS 文件下載并解析后,React 會立即介入,準備好渲染應(yīng)用所需要的 DOM 節(jié)點,并插入到空的 <div id="root"> 里。到這里,用戶就得到可以交互的頁面了。
雖然這個空的 HTML 文檔會很快接收,但 JS 文件的下載和解析是需要一些時間的,另外隨著我們項目規(guī)模的擴大,JS 文件本身的體積可能也在不斷變大。
在客戶端接收到 HTML 文檔,到 JS 文件處理結(jié)束的中間階段,用戶通常會面臨白屏問題,這種體驗就比較糟糕了。
服務(wù)端渲染就能有效的避免這種體驗。服務(wù)端渲染會將我們首屏要展示的 HTML 內(nèi)容在服務(wù)端預(yù)先生成,再發(fā)送到客戶端。這樣,客戶端在接收到 HTML 時,就能渲染首屏內(nèi)容,也就不會遇到白屏問題了。
當然,服務(wù)端渲染的 HTML 網(wǎng)頁同樣會包含 <script> 標簽,因為發(fā)送的首屏內(nèi)容還需要交由 React 托管,附加交互能力。具體來說:與客戶端從頭構(gòu)建 DOM 不同,服務(wù)端渲染模式下,React 會利用現(xiàn)有的 HTML 結(jié)構(gòu)進行構(gòu)建,并為 DOM 節(jié)點附加交互能力,以便響應(yīng)用戶操作。這個過程被稱為“水合(hydration)”。
我很喜歡 React 核心團隊成員 Dan Abramov 對這一過程的通俗解釋:
水合(Hydration)就類似使用交互和事件處理程序的“水”澆到“干”的 HTML 上。
JS 包下載后,React 將快速運行我們的整個應(yīng)用程序,構(gòu)建 UI 的虛擬草圖,并將其“擬合”到真實的 DOM 節(jié)點、附加事件處理程序、觸發(fā) effect 等。
簡而言之,SSR 就是服務(wù)器生成初始 HTML,這樣用戶在等待 JS 處理過程中,不會看到白屏。另外,客戶端 React 會接手服務(wù)器端 React 的工作,為 DOM 加入交互能力。
?? 關(guān)于靜態(tài)站點生成
當我們談?wù)摲?wù)器端渲染時,我們通常想到的可能是下面的流程:
- 用戶訪問 myWebsite.com
- Node.js 服務(wù)器接收請求,并立即渲染 React 應(yīng)用程序,生成 HTML
- 服務(wù)端生成的 HTML 被發(fā)送到客戶端
這是實現(xiàn)服務(wù)器端渲染的一種可能方法,但不是唯一的方法。另一種選擇是在構(gòu)建(build)應(yīng)用程序時生成 HTML。
通常,React 應(yīng)用程序需要進行編譯,將 JSX 轉(zhuǎn)換為普通的 JavaScript,并打包我們的所有模塊。如果在這一過程中,我們?yōu)樗胁煌穆酚伞邦A(yù)渲染”所有 HTML 如何?
這種做法通常稱為靜態(tài)站點生成 (static site generatio,簡稱 SSG),它是服務(wù)器端渲染的一個變體。
在我看來,“服務(wù)器端渲染”是一個通用術(shù)語,包括幾種不同的渲染策略。不過,都有一個共同點:初始渲染都是使用 ReactDOMServer API,發(fā)生在 Node.js 等服務(wù)器運行時環(huán)境。
現(xiàn)有渲染方案分析
本節(jié)我們再來談?wù)?React 中的數(shù)據(jù)獲取。通常,我們有兩個通過網(wǎng)絡(luò)進行通信的獨立應(yīng)用程序:
- 客戶端 React 應(yīng)用程序
- 服務(wù)器端 REST API
在客戶端我們使用類似 React Query、SWR 或 Apollo 這樣的工具向后端發(fā)起網(wǎng)絡(luò)請求,從后端數(shù)據(jù)庫中獲取數(shù)據(jù)并通過網(wǎng)絡(luò)發(fā)送回來。
我們可以將這一過程可視化成下面這樣。
圖片
這里就展示了客戶端渲染 (CSR) 的工作流程。從客戶端接收到 HTML 開始。這個 HTML 文檔不包含任何內(nèi)容,但會有一個或多個 <script> 標簽。
JS 文件下載并解析好后,React 應(yīng)用程序?qū)樱瑒?chuàng)建一堆 DOM 節(jié)點并填充 UI。不過,一開始我們沒有任何實際數(shù)據(jù),因此往往會使用一個骨架屏來表示處于加載狀態(tài)中,這一階段稱為“Render Shell”,也就是“渲染骨架屏”。
這種模式很常見了。以 UberEats 網(wǎng)站舉例,在獲取到實際數(shù)據(jù)前,會展示下面的加載效果。
圖片
在獲取實際數(shù)據(jù)并替換當前內(nèi)容前,用戶會一直看到這個加載頁面。
以上就是典型的客戶端渲染方案。再來看看服務(wù)端渲染方案的執(zhí)行流程。
圖片
可以看到,“Render Shell”階段被放在了服務(wù)端,也就是說用戶收到就不是空白 HTML 了,這是比客戶端渲染好一點的地方,至少沒有白屏了。
為了方便比較,我們在圖標中有增加了一些常用網(wǎng)絡(luò)性能指標。看看在這兩個流程之間切換,有哪些指標發(fā)生了改變。
圖片
圖表中這些 Web 性能指標的介紹如下:
- First Paint(首次繪制):因為總體布局在服務(wù)端渲染了,所以用戶不會看到白屏了。這個指標還叫 First Contentful Paint,即首次內(nèi)容繪制,簡稱 FCP
- Page Interactive:React 下載好了,應(yīng)用也經(jīng)過渲染、水合處理了,現(xiàn)在頁面元素能夠響應(yīng)交互了。這個指標還叫 Time To Interactive,即可交互時間,簡稱 TTI
- Content Paint:用戶想看的內(nèi)容在頁面中出現(xiàn)了。也也就說我們從數(shù)據(jù)庫中拿到的數(shù)據(jù)在頁面中成功渲染了。這個指標還叫 Largest Contentful Paint,即最大內(nèi)容繪制,簡稱 LCP
通過在服務(wù)器上進行初始渲染,我們能夠更快地繪制初始“Shell”頁面,即“骨架屏”頁面。體驗上會感覺更快一些,因為它提供了一種響應(yīng)標識,告訴你頁面正在渲染。
某些情況下,這將是一個有意義的改進。但這樣的流程會感覺有點傻,用戶訪問我們的應(yīng)用程序不是為了查看加載屏幕,而是為了查看內(nèi)容。
當再次查看 SSR 圖時,我不禁想到如果把數(shù)據(jù)庫請求也放在服務(wù)器上執(zhí)行,那么我們不就可以避免客戶端網(wǎng)頁的網(wǎng)絡(luò)請求了嗎?
換句話說,也就是下面這樣。
圖片
我們不會在客戶端和服務(wù)器之間來回切換,當數(shù)據(jù)庫查詢結(jié)果作為初始請求的一部分時,在客戶端接收到的 HTML 文檔中,就包含用戶向看到的內(nèi)容了。
不過,我們該怎么做呢?
React 并沒有提供這方面渲染方案的支持,不過生態(tài)系統(tǒng)針對這個問題提出了很多解決方案。像 Next.js 和 Gatsby 這樣的元框架(Meta Frameworks)就創(chuàng)造了自己的方式來專門在服務(wù)器上運行代碼。
以 Next.js 為例(使用舊的 Pages Router 模式):
import db from 'imaginary-db';
// This code only runs on the server:
export async function getServerSideProps() {
const link = db.connect('localhost', 'root', 'passw0rd');
const data = await db.query(link, 'SELECT * FROM products');
return {
props: { data },
};
}
// This code runs on the server + on the client
export default function Homepage({ data }) {
return (
<>
<h1>Trending Products</h1>
{data.map((item) => (
<article key={item.id}>
<h2>{item.title}</h2>
<p>{item.description}</p>
</article>
))}
</>
);
}
這里簡單介紹下:當服務(wù)器收到請求時,會先調(diào)用 getServerSideProps 函數(shù),它返回一個 props 對象。接著,這些 props 被傳給組件,這個組件會先使用這些 props 在服務(wù)器上進行一次渲染,然后將結(jié)果發(fā)送到客戶端,最后在客戶端進行水合。
getServerSideProps 是一個特殊的函數(shù),只在服務(wù)器端執(zhí)行,函數(shù)本身也不會包含在發(fā)送給客戶端的 JavaScript 文件中。
這種方法在當時是非常超前的,但也有一些缺點:
- 這個策略僅適用于路由級別的組件,也就是在整個頁面組件樹的最頂部的這個組件,而對后代子組件無法適用
- 這個策略并沒有標準化,導(dǎo)致每個元框架的具體實現(xiàn)各不相同。Next.js 是一種,Gatsby 則是另一種,Remix 再是一種
- 所有的 React 組件都會在客戶端上進行一次水合,即便組件本身可能并不需要(比如:沒有任何交互功功能、只是用于純展示作用的組件)
當然,React 團隊也意識到了這個問題,并一直嘗試給出一個官方方案。最終,方案確定了下來,也就是我們看到的 React Server Components,即 React 服務(wù)端組件,簡稱 RSC。
React 服務(wù)端組件介紹
React 服務(wù)端組件是一個全新的渲染模式,在這個模式下,組件完全在服務(wù)器上運行,讓我們可以組件中做類似查詢數(shù)據(jù)庫的后端操作。
下面是一個“服務(wù)端組件”的簡單示例。
import db from 'imaginary-db';
async function Homepage() {
const link = db.connect('localhost', 'root', 'passw0rd');
const data = await db.query(link, 'SELECT * FROM products');
return (
<>
<h1>Trending Products</h1>
{data.map((item) => (
<article key={item.id}>
<h2>{item.title}</h2>
<p>{item.description}</p>
</article>
))}
</>
);
}
export default Homepage;
如果你已經(jīng)寫了很多年的 React,這樣的代碼一定會讓你感覺奇怪 ??。
我就是其中之一。當我看到這種寫法時,本能地驚嘆道。 “函數(shù)組件不能異步呀!而且我們不能直接在渲染中出現(xiàn)這樣的副作用!”
這里要理解的關(guān)鍵點是:服務(wù)端組件只會渲染一次,永遠不會重新渲染。它們在服務(wù)器上運行一次生成 UI,并將渲染的值發(fā)送到客戶端并原地鎖定,輸出永遠不會改變。
這表示 React 的 API 的很大一部分與服務(wù)端組件是不兼容的。例如,我們不能使用 useSate(),因為狀態(tài)可以改變,但服務(wù)端組件不支持重新渲染。我們不能使用 useEffect(),因為它只在渲染后在客戶端上運行,而服務(wù)端組件是不會發(fā)送到客戶端的。
不過,由于服務(wù)端環(huán)境限制,也給服務(wù)端組件的編寫帶來一定靈活性。例如:在傳統(tǒng)客戶端 React 中,我們需要將副作用放入 useEffect() 回調(diào)或事件處理程序中,避免每次渲染時重復(fù)調(diào)用。但如果組件本身只運行一次,我們就不必擔心這個問題了!
服務(wù)端組件本身非常簡單,但“React 服務(wù)端組件”模式要復(fù)雜得多。這是因為我們還要支持以前的常規(guī)組件,混用就會帶來混亂。
為了與新的“React 服務(wù)端組件”做區(qū)分,傳統(tǒng) React 組件被稱為“客戶端組件(Client Component)”。老實說,我不是很喜歡這個名字。
“客戶端組件”聽起來好像這些組件只在客戶端上渲染,實際上并非如此——客戶端組件在客戶端和服務(wù)器端都會渲染。
圖片
我知道所有這些術(shù)語都非常令人困惑,所以我做了一下總結(jié):
- React 服務(wù)端組件(React Server Components)是這個新模式的名稱
- 我們所了解的“標準”React 組件被重新命名為客戶端組件(Client Component),這是對舊事物的一個新稱呼
- 這個新模式引入了一個新的類型組件:服務(wù)端組件(Server Component),這些組件專門在服務(wù)器上渲染,其代碼也不會包含在發(fā)送給客戶端的 JS Bundle 中,因此也不會參與水合或重新渲染
?? 服務(wù)端組件與服務(wù)器端渲染
這里必須要澄清一下:React 服務(wù)端組件并不是服務(wù)器端渲染的替代品。你不應(yīng)該把 React Server Components 理解成“SSR 的 2.0 版本”
這 2 者更像是可以拼湊在一起的拼圖,相輔相成。
我們?nèi)匀恍枰?wù)器端渲染來生成初始 HTML。React Server Components 則是建立在基礎(chǔ)之上,讓我們從客戶端 JavaScript 包中省略這些組件,確保它們只在服務(wù)器上運行。
事實上,你也可以在沒有服務(wù)器端渲染的情況下使用 React 服務(wù)端組件。實踐中它們通常一起使用,來得到更好的結(jié)果。如果你想查看示例,React 團隊已經(jīng)構(gòu)建了一個沒有 SSR 的最小 RSC demo[2]。
在使用服務(wù)端組件之前
通常,當新的 React 功能出現(xiàn)時,我們可以通過將 React 依賴項升級到最新版本來使用,類似 npm install react@latest 就可以了,不過服務(wù)端組件不是這樣。
我的理解是:服務(wù)端組件需要與 React 之外的一些系統(tǒng)緊密配合才能使用,比如打包工具(bundler)、服務(wù)器、路由之類的。
當我寫這篇文章時,Next.js 13.4+ 通過引入全新的重新架構(gòu)“App Router” 來支持服務(wù)端組件的使用。
當然,在可以遇見的將來,會有越來越多的基于 React 的框架會支持這一特性。React 官方文檔有一個 “Bleeding-edge frameworks”[3] 的部分,其中列出了支持 React 服務(wù)端組件的框架列表。
使用客戶端組件
在 Next.js App Router 架構(gòu)下,默認所有組件都會被看作服務(wù)端組件,客戶端組件需要特別聲明,這需要通過一個新的指令說明。
'use client';
import React from 'react';
function Counter() {
const [count, setCount] = React.useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Current value: {count}
</button>
);
}
export default Counter;
注意,這里頂部的 'use client',這就是在告訴 React 這是一個客戶端組件,應(yīng)該包含在 JS Bundle 中,以便在客戶端上重新渲染。
這種聲明方式借鑒了 JavaScript 的嚴格模式聲明——'use strict'。
在 App Router 架構(gòu)下,所有組件默認被看作是服務(wù)端組件,無需任何聲明。當然,你可能會想到服務(wù)端組件是不是使用 'use server'——NO,不是!'use server' 其實是用在 Server Actions,而非服務(wù)端組件上的,不過這塊內(nèi)容超出了本文范圍就不講了,有興趣的同學(xué)可以私下學(xué)習(xí)。
?? 哪些組件應(yīng)該是客戶端組件?
這里你可能就有疑問了:我該怎么知道一個組件應(yīng)該是服務(wù)端組件還是客戶端組件呢?
這里可以給大家一個一般規(guī)則:如果一個組件可以是服務(wù)端組件,那么它就應(yīng)該是服務(wù)端組件。服務(wù)端組件往往更簡單且更容易推理,還有一個性能優(yōu)勢,即服務(wù)端組件不在客戶端上運行,所以它們的代碼不包含在我們的 JavaScript 包中。因此,React 服務(wù)端組件對改進頁面交互指標(TTI)有所幫助。
不過,這不意味著我們要盡可能把作為組件都改成服務(wù)端組件,不合理也不可能。在 RSC 之前,每個 React 應(yīng)用程序中的 React 組件都是客戶端組件。
當你開始使用 React 服務(wù)端組件時,你會發(fā)現(xiàn)它寫起來這非常直觀。而我們的一些組件由于需要狀態(tài)或 Effect,只能在客戶端上運行。你可以通過在組件頂部添加 'use client' 指令指定當前組件是客戶端組件,否則默認就是服務(wù)端組件。
客戶端邊界
當我熟悉 React 服務(wù)端組件時,我遇到的第一個問題是:如果組建 props 改變了,會發(fā)生什么?
假設(shè),我們有一個像這樣的服務(wù)端組件:
function HitCounter({ hits }) {
return (
<div>
Number of hits: {hits}
</div>
);
}
如果在初始服務(wù)器端渲染中, hits 等于 0 。然后,這個組件將生成以下結(jié)果。
<div>
Number of hits: 0
</div>
但是,如果 hits 的值發(fā)生變化會怎樣?假設(shè)它是一個狀態(tài)變量,從 0 更成了 1。HitCounter 這個時候就需要重新渲染,但它不能重新渲染,因為它是服務(wù)端組件!
這里的問題是,如果沒有上下文環(huán)境,只是孤立的考慮服務(wù)端組件并沒有真正的意義。我們必須擴大范圍,從更高的角度審視,考慮我們應(yīng)用程序的結(jié)構(gòu)。
假設(shè)我們有如下的組件樹結(jié)構(gòu):
圖片
如果所有這些組件都是服務(wù)端組件,那么就不會存在上面的問題,因為所有組件都不會重新渲染,props 也就沒有改變的可能性。
但假設(shè) Article 組件擁有 hits 狀態(tài)變量。為了使用狀態(tài),我們需要將其轉(zhuǎn)換為客戶端組件:
圖片
你觀察到這里的問題了嗎?當 Article 重新渲染時,任何下屬子組件也會重新渲染,包括 HitCounter 和 Discussion。但是,如果這些是服務(wù)端組件,是無法重新渲染的。
為了避免這類矛盾場景的出現(xiàn),React 團隊添加了一條規(guī)則:客戶端組件只能導(dǎo)入其他客戶端組件。'use client' 指令表示 HitCounter 和 Discussion 的這些實例將自動成為客戶端組件。
我在使用 React 服務(wù)端組件時遇到的最大的“啊哈(ah-ha)”時刻之一,是意識到服務(wù)端組件的這種新模式其實就是關(guān)于創(chuàng)建客戶端邊界的(client boundaries)。在實踐中,總會遇到下面的場景:
圖片
當我們將 'use client' 指令添加到 Article 組件時,我們創(chuàng)建了一個“客戶端邊界”。邊界內(nèi)的所有組件都隱式成為客戶端組件。即使像 HitCounter 這樣的組件沒有使用 'use client' 指令,在這種特殊情況下它們?nèi)匀粫诳蛻舳松线M行水合和渲染。
也就是說,我們不必將 'use client' 添加到每個客戶端上運行的組件,只需要在創(chuàng)建新的客戶端邊界的組件上添加即可。
解決服務(wù)端組件帶來的限制問題
當我第一次了解到客戶端組件無法渲染服務(wù)端組件時,它對我來說感覺非常限制。如果我需要在應(yīng)用程序中使用高層狀態(tài)怎么辦?那所有組件豈不是都成為客戶端組件了?
事實證明,在許多情況下,我們可以通過重構(gòu)組件來解決這個限制。
這是一件很難解釋的事情,所以讓我們先舉個例子說明:
'use client';
import { DARK_COLORS, LIGHT_COLORS } from '@/constants.js';
import Header from './Header';
import MainContent from './MainContent';
function Homepage() {
const [colorTheme, setColorTheme] = React.useState('light');
const colorVariables = colorTheme === 'light'
? LIGHT_COLORS
: DARK_COLORS;
return (
<body style={colorVariables}>
<Header />
<MainContent />
</body>
);
}
在這段代碼中,我們需要使用 React 狀態(tài)允許用戶在深色/淺色模式之間切換。這類功能通常需要在應(yīng)用程序樹的較高層級設(shè)置,以便我們可以將 CSS 變量 token 應(yīng)用到 <body> 上。
為了使用狀態(tài),我們需要讓 Homepage 成為客戶端組件。由于這是我們應(yīng)用程序的頂部,表示其他所有組件 - Header 和 MainContent - 也將隱式成為客戶端組件。
為了解決這個問題,讓我們將主題管理提取到單獨的組件文件中:
// /components/ColorProvider.js
'use client';
import { DARK_COLORS, LIGHT_COLORS } from '@/constants.js';
function ColorProvider({ children }) {
const [colorTheme, setColorTheme] = React.useState('light');
const colorVariables = colorTheme === 'light'
? LIGHT_COLORS
: DARK_COLORS;
return (
<body style={colorVariables}>
{children}
</body>
);
}
返回 HomaPage,就可以像這樣重新組織了:
// /components/Homepage.js
import Header from './Header';
import MainContent from './MainContent';
import ColorProvider from './ColorProvider';
function Homepage() {
return (
<ColorProvider>
<Header />
<MainContent />
</ColorProvider>
);
}
現(xiàn)在就可以從 Homepage 中刪除 'use client' 指令了,因為它不再使用狀態(tài)或任何其他客戶端 React 功能,也就表示 Header 和 MainContent 不再需要被迫轉(zhuǎn)換成客戶端組件了!
當然,你可能會有疑問了。ColorProvider 是一個客戶端組件,是 Header 和 MainContent 的父組件。不管怎樣,它仍然處在樹結(jié)構(gòu)的較高層級,是吧?
確實。不過,Header 和 MainContent 是在 Homepage 中引入的,這表示它們的 props 只受到 HomaPage 影響。也就是說,客戶端邊界只對邊界頂部組件的內(nèi)部有影響,對同處于一個父組件下的其他組件沒有影響。
請記住,我們試圖解決的問題是服務(wù)端組件無法重新渲染的問題,因此無法為它們的任何子組件設(shè)置新的 props。Homepage 決定 Header 和 MainContent 的 props 是什么,并且由于 Homepage 本身是一個服務(wù)端組件,那么同屬于服務(wù)端組件的 Header、MainContent 自然就沒有 props 會改變的擔憂。
不得不承認的是,理解服務(wù)端組件架構(gòu)確實是一件費腦筋的事情。即使有了多年的 React 經(jīng)驗,我仍然覺得這很令人困惑,需要相當多的練習(xí)才能培養(yǎng)對這種新架構(gòu)的直覺。
更準確地說,'use client' 指令是在文件/模塊級別下工作的??蛻舳私M件中導(dǎo)入的任何模塊也必須是客戶端組件。畢竟,當打包工具打包我們的代碼時,也是依據(jù)這些導(dǎo)入聲明一同打包的!
淺析底層實現(xiàn)
現(xiàn)在讓我們從一個較低的層面來看服務(wù)端組件的實現(xiàn)。當我們使用服務(wù)端組件時,輸出是什么樣的?實際生成了什么?
讓我們從一個超級簡單的 React 應(yīng)用程序開始:
function Homepage() {
return (
<p>
Hello world!
</p>
);
}
在 Next.js App Router 模式下,所有組件默認都是服務(wù)端組件。也就是說,Homepage 就是服務(wù)端組件,會在服務(wù)端渲染。
當我們在瀏覽器中訪問此應(yīng)用程序時,我們將收到一個 HTML 文檔,如下所示:
<!DOCTYPE html>
<html>
<body>
<p>Hello world!</p>
<script src="/static/js/bundle.js"></script>
<script>
self.__next['$Homepage-1'] = {
type: 'p',
props: null,
children: "Hello world!",
};
</script>
</body>
</html>
我們看到 HTML 文檔包含由 React 應(yīng)用程序生成的 UI,即“Hello world!”段落。其實這屬于服務(wù)器端渲染結(jié)果,跟 React 服務(wù)端組件沒有關(guān)系。
再往下,是一個 <script> 標簽來加載我們的 JS 包。這個腳本中包括 React 等依賴項,以及我們應(yīng)用程序中使用的所有客戶端組件代碼。由于我們的 Homepage 是服務(wù)端組件,所以這個組件的代碼不包含在這個 JS 包中。
最后,第二個 <script> 標簽,其中包含一些內(nèi)聯(lián) JS:
self.__next['$Homepage-1'] = {
type: 'p',
props: null,
children: "Hello world!",
};
這里就比較有趣了。本質(zhì)上這里所做的就是告訴 React——“嘿,我知道你看不到 Homepage 組件代碼,但不用擔心:這就是它渲染的內(nèi)容”。通常來說,當 React 在客戶端上水合時,這種做法會加速整個渲染進程,因為部分組件(服務(wù)端組件)已經(jīng)在后端渲染出來了,其組件代碼也不會包含在 JS 文件中。
我們會將服務(wù)器生成的虛擬表示發(fā)送回去,當 React 在客戶端加載時,它會重用這這部分虛擬描述,而不是重新生成它。
這就是上面的 ColorProvider 能夠工作的原因。 Header 和 MainContent 的輸出通過 children 屬性傳遞到 ColorProvider 組件。ColorProvider 可以根據(jù)需要重新渲染,但數(shù)據(jù)是靜態(tài)的,在服務(wù)器就鎖定了。
如果你想了解服務(wù)端組件如何序列化并通過網(wǎng)絡(luò)發(fā)送的,可以使用 Alvar Lagerl?f 開發(fā)的 RSC Devtools[4] 進行查看。
?? 服務(wù)端組件不需要服務(wù)器
我們有一道,服務(wù)器端渲染其實是很多不同渲染策略的總稱。包括:
- 靜態(tài)的:HTML 是在構(gòu)建階段生成的
- 動態(tài)的:HTML 是在用戶請求是生成的,即“按需”生成的
React Server Components 與上述這 2 渲染策略都是兼容的。當服務(wù)端組件在 Node.js 調(diào)用渲染時,會返回的當前組件的 JavaScript 對象表示。這個操作可以在構(gòu)建時,也可以在請求時。
也就是說,在沒有服務(wù)器的情況下使用 React 服務(wù)端組件!我們可以生成一堆靜態(tài) HTML 文件并將它們托管在某個地方,事實上,這就是 Next.js App Router 中默認就是這個策略——除非我們真的需要推遲到“請求”階段,否則所有這些工作都會在構(gòu)建期間提前發(fā)生。
服務(wù)端組件的好處
React 服務(wù)端組件比較酷的一點就在于:它是 React 中運行服務(wù)器專有代碼的第一個“官方”方案。另外,自 2016 年以來,我們已經(jīng)能夠在 Next.js 的 App Router 模式下使用服務(wù)端組件了!
不過,這種方案引入之后,編寫 React 代碼的方式變得很不一樣了,因為我們需要編寫專用于服務(wù)端的 React 的代碼了。
這樣帶來的一個最明顯好處就是性能了。服務(wù)端組件不包含在我們發(fā)送給客戶端的 JS 包中,這樣就減少了需要下載的 JS 代碼數(shù)量以及需要水合的組件數(shù)量:
圖片
不過,這對我來說可能是最不令人興奮的事情。畢竟,大多數(shù) Next.js 應(yīng)用程序在“頁面可交互(Page Interactive)”方面已經(jīng)做得足夠快了。
如果你遵循語義 HTML 原則,那么你的大部分應(yīng)用程序甚至在 React 水合之前就可以運行。比如:跳轉(zhuǎn)鏈接、提交表單、展開和折疊手風琴(使用 <details> 和 <summary>)等。者對于大多數(shù)項目來說,React 只需要幾秒鐘的時間來進行水合就很不錯了。
不過,React 服務(wù)端組件真正的優(yōu)勢在于,我們不再需要在功能與打包文件尺寸上妥協(xié)了!
例如,大多數(shù)技術(shù)博客都需要某種語法高亮庫。在我的博客里,我使用 Prism。代碼片段如下所示:
function exampleJavaScriptFunction(param) {
return "Hello world!"
}
一個流行語法高亮庫,通常會支持很多流行的編程語言,有幾兆字節(jié),放到 JS 包中實在太大。因此,我們必須做出妥協(xié),刪除非必須語言和功能。
但是,假設(shè)我們在服務(wù)端組件中進行語法突出顯示。在這種情況下,我們的 JS 包中實際上不會包含高亮庫代碼。因此,我們不必做出任何妥協(xié),另外我們還可以使用所有的附加功能。
Bright[5] 就是支持在服務(wù)端組件中使用的現(xiàn)代語法高亮庫。
圖片
這是讓我對 React 服務(wù)端感到興奮的一個地方。原本包含在 JS 包中成本太高的東西現(xiàn)在可以在服務(wù)器上運行,而不必在包含在 JS 包中了,這也帶來了更好的用戶體驗。
這也不僅僅是性能和用戶體驗。使用 RSC 一段時間后,我開始真正體會到服務(wù)端組件是多么簡單易用。我們永遠不必擔心依賴數(shù)組、過時的閉包、記憶或由事物變化引起的任何其他復(fù)雜的東西。
我真的很高興看到未來幾年事情將如何發(fā)展,因為社區(qū)將利用這種新模式繼續(xù)創(chuàng)造出像 Bright 這樣新的解決方案。對于成為一名 React 開發(fā)者來說,這很令人激動!
完整圖表
React 服務(wù)端組件是一項令人興奮的方案,但它實際上只是“現(xiàn)代 React”難題的一部分。
當我們將 React 服務(wù)端組件與 Suspense 和新的 Streaming SSR 架構(gòu)結(jié)合起來時,事情變得更加有趣。它允許我們做下面這樣瘋狂的事情:
圖片
簡單來說,內(nèi)置 Suspense 組件能夠利用 Streaming SSR + React 服務(wù)端組件架構(gòu)實現(xiàn)局部組件更新。這樣每塊內(nèi)容都可以單獨渲染、處理,能更快響應(yīng)用戶,帶來更好地瀏覽體驗。
不過這部分知識超出了本文范圍,你可以在 Github[6] 上了解有關(guān)此架構(gòu)的更多信息。
參考資料
[1]
Making Sense of React Server Components: https://www.joshwcomeau.com/react/server-components/
[2]最小 RSC demo: https://github.com/reactjs/server-components-demo
[3]“Bleeding-edge frameworks”: https://react.dev/learn/start-a-new-react-project#bleeding-edge-react-frameworks
[4]RSC Devtools: https://www.alvar.dev/blog/creating-devtools-for-react-server-components
[5]Bright: https://bright.codehike.org/
[6]Github: https://github.com/reactwg/react-18/discussions/37