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