React 的未來:18 以及在那之後

C.T. Lin
22 min readNov 19, 2021

--

前一陣子在 JSDC 給了「The Future of React: 18 and Beyond」的演講,為了濃縮成適合聽眾的 20 分鐘內容,並適當補充一些需要先備的知識,導致會有相當多內容變成遺珠之憾無法提及,所以決定另外來寫一篇文章記錄一下。

這篇文章將會依序介紹到以下的項目:

  • 先備知識 — Suspense、Hydration
  • 升級成 React 18 要做什麼改變?
  • 升級成 React 18 有什麼立即影響?
  • 新 Streaming 架構 Server-Side Rendering
  • 新推出的 Concurrent Feature
  • 「Suspense for Data Fetching」有什麼研究中的項目?

先備知識

還不熟悉 Suspense、Hydration 的人繼續看下去可能會感到一頭霧水,所以在更深入 React 18 的主題之前,首先要帶大家回顧一下這些概念,已經很熟悉這些概念的人可以直接略過這部分。

什麼是 Suspense?

下面這段程式碼有使用到 <Suspense> 這個 Component, 這個 API 最早是在 React 16.6 搭配 Code Splitting 需要的 React.lazy 一起出現:

如同字面上的意思,React 在 render 到 <Suspense> 的時候,如果 <ProfilePage> 還沒準備好就會暫停 render,等到它準備好時再繼續 render <ProfilePage>。

這個機制背後借用了 Promise 的概念,Promise 是一種狀態機,一開始處於 Pending 的狀態,最後狀態會依據成功或失敗變成 Fulfilled 或 Rejected:

再回來看這個範例:

React 在 render 到 <Suspense> 的時候,如果 <ProfilePage> 還沒準備好,React.lazy 就會 throw Promise-like(Thenable)的東西出去,並 render fallback 的 <Spinner>,等到 resolve 時,就可以繼續 render <ProfilePage>,而如果最後是被 reject,就利用類似 try/catch 的機制讓 <ErrorBoundary> 來處理。

什麼是 Hydration?

使用 Server-Side Rendering 送出初始 HTML,可以加快初始畫面渲染的速度,但初始畫面剛渲染完時,DOM 還不在 React 的掌控下,它還沒辦法立即處理使用者的互動。

而 Hydration 這個步驟就是 React 在前端把 DOM 重新掌控的過程,包括 event listener 等等的 JavaScript 邏輯都必須在這個時期綁上去。

升級成 React 18 要做什麼改變?

首先必須安裝 18 版本的 React:

npm install react@18 react-dom@18

Client-Side 需要做的改變

在這個版本,React 導入了一個新的 Root API,以前的 Legacy Root API 是這樣使用的:

ReactDOM.render(<App />, document.getElementById('root'));

新的 Root API,改成兩段式的— createRoot:

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

如果有自己處理 hydration 相關的部分,要注意也需要使用 hydrateRoot 這個新的 API:

const root = ReactDOM.hydrateRoot(
document.getElementById('root'),
<App />
);

舊的 ReactDOM.render 跟 ReactDOM.hydrate 依然會保留在 18 的 release 中,方便做測試跟升級,但行為將會等同於升級前的 React 17。

Server-Side 需要做的改變

如果有實作 SSR(Server-Side Rendering),必須使用新的 renderToPipeableStream API 來改寫以完整的支援 Suspense。

如果你使用 Next.js

必須升級到 Next.js 12,在 next.config.js 把 experimental.runtime 設定為 nodejs 來啟用相關功能

// next.config.js
module.exports = {
experimental: {
runtime: 'nodejs',
},
};

如果你使用 React Redux

用 next tag 安裝,即會安裝到使用 useSyncExternalStore 的 v8-beta:

npm install react-redux@next

如果你使用 @testing-library/react

用 alpha tag 安裝,即會安裝到使用 createRoot 的 v13-alpha:

npm install -D @testing-library/react@alpha

升級成 React 18 createRoot 會有什麼立即影響?

大部分 React 18 的功能都是交由使用者後續自由添加的功能,只有少數修改會在替換 Root API 後直接生效,值得注意一下:

  • 自動批次處理(Automatic Batching)
  • Suspense 的行為改變

自動批次處理(Automatic Batching)

在 18 以前的版本,setState 也是有 batching 的,例如以下這個範例,在處理 click 的過程中,雖然有 setCount 跟 setFlag,但 React 只會 re-render 一次:

function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);

function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// React 只會 re-render 一次
}

return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}

但是,18 以前的 Automatic Batching 沒辦法處理到下面這個狀況,因為它是在 promise 的 callback 裡面做更新:

function handleClick() {
fetchSomething().then(() => {
// React 17 以及更早的版本不會 batch 這些
setCount(c => c + 1); // 造成一次 re-render
setFlag(f => !f); // 造成一次 re-render
});
}

而在 React 18, 無論是來自 timeout、promise、native event handler、各種地方的更新都會自動被 batch。

Suspense 的行為改變

React 17 的 Suspense(又稱為 Legacy Suspense)跟 React 18 的 Suspense(又稱為 Concurrent Suspense)有一些行為上的不同。

這個不同來自於它們處理 sibling 的方式:

<Suspense fallback={<Loading />}>
<ComponentThatSuspends />
<Sibling />
</Suspense>

當 <ComponentThatSuspends> 需要暫停 render 時:

  • Legacy Suspense 會立刻把 <Sibling> mount 到 DOM 上,並觸發它的 effects/lifecycles。接著把它隱藏起來。
  • Concurrent Suspense 不會立刻把 <Sibling> mount 到 DOM 上。它的 effects/lifecycles 也不會在 <ComponentThatSuspends> 準備好之前觸發。

Concurrent Suspense 的行為更加可靠,也修好了一些之前長久存在的問題。

新 Streaming 架構 Server-Side Rendering

以前的 Server-Side Rendering 不能支援 <Suspense>,而且 Rendering、Hydration 都需要一步到位,造成了 waterfall,也讓初始的載入跟可互動時間都會受到比較慢的 component 影響:

而透過 Streaming 的新架構,下面這段使用了 <Suspense> 的程式碼在 React 18 將可以正常在 Server-Side Rendering 中執行,也不必等待比較緩慢的 <Comments> 都準備好,前端即可收到包含 <Spinner> 的 HTML:

而 HTTP 有支援分塊傳輸(Chunked Transfer),因此可以在 <Comments> 準備好之後,在 response 寫入一段新的 chunk,用 inline script 把 <Spinner> 的位置替換成已經 render 好的 <Comments>:

接下來就要進行 Hydration。

Selective Hydration

以往 Hydration 這個步驟必須一次完成,沒辦法一次對一部分做。而在 React 18 的 Selective Hydration 則可以結合前面 Suspense 的功能,更進一步避免 Waterfall。

我們來看一下整個流程(綠色代表完成 Hydration 的部分):

  1. 首先,因為 Suspense 我們拿到包含 <Spinner> 的 HTML
  2. <Comments> 還在準備時,React 這時可以在前端先對已經收到的部分做 Hydrate
  3. 後端終於搞定了,前端收到 inline script 用 <Comments> 換掉 <Spinner>
  4. 最後,React 把剩下的 Comments 部分也 Hydrate

前端後端同時在進行,減少了一部分的 Waterfall,改善了初始的載入跟可互動時間。

useId

React 在 18 提供了一個新的 Hook — useId,來避免 SSR 跟 Client 產生不一致的 ID:

function Checkbox() {
const id = useId();
return (
<>
<label htmlFor={id}>Do you like React?</label>
<input type="checkbox" name="react" id={id} />
</>
);
);

這解決了 React 17 及更早版本中已經長久存在的問題,但這在 React 18 中將會更重要,因為 Streaming SSR 可能不會按照順序提供 HTML。以前的一些解決方案(例如使用計數器產生 ID)在 18 中可能會失去作用,因為無法依賴一致的順序。

新推出的 Concurrent Feature

更早之前 React 在實驗的版本中曾經推出 Concurrent Mode 這個概念,用切換 Mode 來區分新舊的行為。但現在新的版本為了減少使用者 migrate 上的痛苦,已經不區分 Mode 了,改成讓使用者可以自由選用的 Concurrent Feature。

接下來要介紹的,首先是一個很重要的概念 — Transition。

Transition

不知道大家以前有沒有遇過,因為畫面上比較複雜的 update 跟 re-render,導致鍵盤等 input 卡死或不順的狀況,引入 Transition 的概念主要就是要解決這個問題。

讓我們來看一下 React Team 的 demo,在使用 Transition 的狀況下,UI 首先會先反應 bar 的拖動,接下來才是下面 chart 的更新:

在現行的 React 架構下,render 的工作是能被切分的,這也讓先處理 high priority 的部分變得非常合理,處理好再回過來處理較 low priority 的部分:

下面這段程式碼有使用 startTransition 這個新的 API:

只要把 low priority 的 state update 包進去 startTransition 就好,使用起來是蠻簡單的。

另外,如果想要處理 transition 的中間狀態,可以改用 useTransition 這個 hook 所提供的 startTransition:

在 pending 時可以選擇顯示 <Spinner> 或是原本的舊畫面,可以避免從舊畫面轉到新畫面的過程中出現不好的體驗。

<SuspenseList>

⚠️ 警告:<SuspenseList> 因其 API 尚未被文件化以及敲定,沒被 React Team 放進 18.0。

下一個要介紹的 API 是一個很有趣的 Component — <SuspenseList>
可以用來調整多個 <Suspense> 之間的關係。

我們來看一下 React Team 的 demo 影片,這個是在沒有 Suspense 的狀況下,圖片的載入狀況:

因為讓圖片自由載入,可以看到,中間會有那種圖片只出現一半的狀態。

而如果採用 <SuspenseList>,將可以控制群組顯示的方式。
這個是按照順序顯示的樣子:

這個是全部一起顯示的樣子:

<SuspenseList> 這個 API 裡面可以包多個 <Suspense>,並決定這些 children 的揭露方式:

也可以做群組整體的 fallback 控制,來避免秀出一整排的 loading。

useSyncExternalStore

在 React 18 要自行實作相容 Concurrent 的訂閱機制會很困難,當有部分的 state update 開始被包到 startTransition 裡面時,既有的一些訂閱機制可能會開始出現一些 Concurrent 造成的 bug,出現 Tearing(不一致)的狀況。

因此,React 推出了一個新的 Hook — useSyncExternalStore,提供一個安全且有效率的方法來從 mutable 的外部資源讀取資料:

import { useSyncExternalStore } from 'react';

const state = useSyncExternalStore(store.subscribe, store.getSnapshot);

這個 API 會在 render 期間監控資料變動來避免 render 的畫面出現 Tearing(不一致),如果有資料變動也會自動排程相關的更新。

getSnapshot 用於檢查訂閱的值自上次呈現後是否已被修改,應該要回傳 cached/memoized 的結果來避免效能問題。

React 提供了一個 shim,讓你有機會在升級 React 18 之前就先實作相關的機制,以保證未來的 Concurrent 相容性:

import { useSyncExternalStore } from 'use-sync-external-store/shim';

「Suspense for Data Fetching」有什麼研究中的項目?

「Suspense for Data Fetching」是個已經探討許多年的概念,就是把 API call 等等 data fetching 的部分也利用 React Suspense 的機制來處理。不過這部分牽連廣大,要考慮的東西相當多,要讓它慢慢成熟並推出真的是很不容易,我覺得蠻有可能會再花上超過一年以上的時間。

目前 React Team 公布的時程 ,是把這部分都放到了 React 18.x 去,所以不會拖延到 React 18.0 的發布時間。

雖然目前都還尚未定案,但跑在比較前沿的開發者是可以了解一下,探討學習一下它們解決的問題跟設計方式,這其中包括 React I/O library、Suspense Cache、Server Components 等部分。

React Server Components

Server Components 算是這其中最大咖,但也是一個在特別初期的概念,React 團隊目前只寫了相關的 RFC 跟 Demo,以及示範用的 Webpack 整合。雖然還在實驗階段,Vercel 的 Next.js、Shopify 的 Hydrogen 等等重量級的專案,已經開始最早期的 Preview,也看出他們對此的重視程度。

我之前也寫了一篇 Server Components 的介紹 —「 React 新概念 — Server Components」,有興趣的人可以看看。

如果這個東西真的推出,可能是 React 這幾年來最大的變革之一,前端跟後端的工作分配又可能會受到影響。

在談到 Server Components 之前,首先要先帶大家回顧一個問題:

如果 data fetching 是跟 component 放在一起,例如在 useEffect 的時機去抓資料,然後才 render children,那就會有 waterfall 請求的問題。因為要 render 出 children,才知道 children 要什麼資料,才能在 children 的 useEffect 再去抓 children 要的資料:

這就會造成 Client/Server 之間的多次往返,會有比較明顯的效能問題:

目前能看到的解決方法大概分以下幾種:

  1. 把 component 跟查詢分開,直接寫清楚需要的所有資料,一次查好傳下去。這個方法的問題是,人工檢查不好做、容易少查資料或多查資料造成 bug。
  2. 在 run time 或 compile time 預先組裝查詢,例如 GraphQL 有特殊的機制像是 fragment 可以用來組裝查詢,Relay 就是使用事先編譯的方式
  3. 跑到 server 上一次查完再送回去 client,也就是這邊要講的 Server Components。

把 component 搬到 server 上去跑,把 API/Database 查詢變成直接在 Server 上做,可以避開多次的 Client /Server 往返:

一個好的解決設計,通常可以一次解決很多問題。下面我們來看一下 Server Components 的一些其他好處。

首先是 bundle size,在 server 上執行的套件當然不需要下載到用戶端,所以只需要 bundle client component 需要的套件就好。不用 Server Components的情況下,這些套件全部都要包進去:

在 Facebook 內部的初步實驗專案中,幾個禮拜之間很快就減少了超過 30% 的 bundle size。

再來,因為是在 Node.js 上執行,理所當然的能直接使用 server 環境上的各種東西,包括 file system/database 等等:

不過,在 component 裡面非同步的寫法就要照 React 的方式走,要搭配 <Suspense> 跟 React I/O libraries 去做,可以看到這邊的 react-fs。

還有一個重要的好處是會自動 Code Splitting:

因為 Server Components 只會把 render 的結果回傳給前端,前端不會知道中間的邏輯是怎樣,經過了怎樣 if 的判斷,只會知道自己要顯示什麼所以需要下載什麼。這在做 A/B testing 時看起來也是極為方便的功能。

目前,測試 Server Components 的最佳方式是搭配 Next.js 的整合

// next.config.js
module.exports = {
experimental: {
concurrentFeatures: true,
serverComponents: true
}
}

不然,光要處理 Route 跟 Webpack 的部分,可能就讓你非常頭痛。

Suspense Cache

如果在 component 每次 render 時都製造新的 Thenable throw 出去, Suspense 是不會如想像中運作的,必須讓數次 render 之間能拿到同一個 Thenable,或是 Thenable resolve 後的結果。除此之外,也要避免同樣的查詢一而再地被發出去。

你可能會好奇那 React.lazy 為何不需要使用 Suspense Cache,是因為它已經在執行時把包含 Thenable 的資料放到了 component 上面,也就是這個 payload:

const lazyType: LazyComponent<T, Payload<T>> = {    
$$typeof: REACT_LAZY_TYPE,
_payload: payload,
_init: lazyInitializer,
};

不管是怎樣的 Suspense,總是要個地方存放這樣一個包含 Thenable 的資料,才能在之後的 re-render 取得對應的 Thenable、resolved data 或 error。

因此,React Team 目前實作了實驗性質的 API — unstable_getCacheForType(createInitialCache) 來提供由 React 掌控的 Cache,一般用法是這樣子:

function createCache() {  
return new Map();
}
const cache = unstable_getCacheForType(createCache);

它會用 create function 當作 key,來跟 React 取得 Cache。這個 Cache 就可以拿來儲存包括 React I/O Libraries 等等的查詢結果。這個 Suspense Cache,是為了 Suspense 跟 Concurrent 而生,不是用來取代其他層級的 cache。

有一個比較特別的地方是,unstable_getCacheForType 看起來也是一個 Hook,但卻不是用 use 開頭來命名。

另外還有一個很特別的 component — <Cache />,可以當作邊界定義 UI 的哪些部分可以不一致:

<>
<Cache>
<Toolbar>
<CurrentUserProfilePic />
</Toolbar>
</Cache>
<Cache>
<MessageThread>
<CurrentUserProfilePic />
<CurrentUserProfilePic />
</MessageThread>
</Cache>
</>

在上面這個範例,Toolbar 跟 MessageThread 的 CurrentUserProfilePic 就可以允許不一致。

React Team 似乎已經充分了討論這背後的 UX 原則,我們需要等到他們公開分享才能理解更多這背後的意義。

React I/O Libraries

從前面 Server Components 的範例中我們已經看到了 react-fs 這個套件,這就是一個 React I/O Library:

React I/O Libraries 能看到的資訊跟討論並不多,我大部分的理解是從研究原始碼開始的。到目前為止,曾經出現在 demo 而且可以在原始碼中找到的有以下三個套件:

這是 React Team 為了展示如何在 component 中獲取 API/File System/Database 資料而寫的套件,還在初期的階段,很有可能會修改。

我個人認為之後應該需要一些更好的方式來轉接 I/O,不然整個生態系要維護一大堆的 react-xxx 應該會維護的有點辛苦。

從原始碼可以看到,其中使用了上面提到的 Suspense Cache,並把下面這種結構放到 Cache 來儲存特定 I/O 的狀態:

type PendingRecord = {|
status: 0,
value: Wakeable
|};
type ResolvedRecord<V> = {|
status: 1,
value: V,
|};
type RejectedRecord = {|
status: 2,
value: mixed,
|};

當需要從中讀值時,如果狀態為 resolved 就直接把 value 回傳回去,而其他狀況則是把 value throw 出去。

參考資料

大部分的圖片、程式碼內容皆出自 reactwg/react-18 以及 React 的原始碼,有興趣的人可以在那邊仔細看過全部的討論。

--

--

C.T. Lin

Architect @ Dcard. Author of Electron React Boilerplate and Bottender. JavaScript Developer.