理解 React 的下一步:Concurrent Mode 與 Suspense

C.T. Lin
13 min readNov 26, 2019

--

Photo by Hello I'm Nik 🇬🇧 on Unsplash

10 月剛在 Las Vegas 結束的 React Conf 2019,帶來許多關於 Concurrent ModeSuspense for Data Fetching 的消息,如果你對於這些議題感到好奇,但還沒有時間去吸收,那這篇文章或許值得一讀。

當然除了繼續往下把這篇文看完以外,如果你有充足的時間,推薦也花一點時間把官方的五篇文章看過。

以下會按照這樣的編排來介紹:

  • 什麼是 Concurrent Mode?
  • Suspense for Data Fetching
  • 什麼是 Transition?
  • 決定 Suspense 的揭露方式
  • 要如何試用 Concurrent Mode?

什麼是 Concurrent Mode?

說到這個就不得不提到 React 16 時,React Team 曾經把 React 整個框架重寫過,這個計畫「Fiber」耗時一年多才完成,其實就是為了 Concurrent Mode 所鋪的路。我還特別翻到以前在 Modern Web 2017 演講的投影片,三年也是好快就過了!(當時看到 Fiber Ready 相當感動阿)

http://isfiberreadyyet.com/

那 Fiber 是怎麼一回事呢?

為了讓 Render / Reconciliation 的過程更為彈性,React Team 決定把這個一次搞定 Render 整個 Tree 的步驟切成一個個更小的步驟,讓這整個過程可以暫停、可以放棄也可以 Concurrent 的執行。

而 Fiber 就是 Render / Reconciliation 時的最小單位。(如果對 Reconciler 完全沒概念的話推薦 Sophie Alpert 的這個介紹

或許大部分的 React 開發者都遇過在輸入框打字時,因為 State 改變造成Render 而擋住了輸入框的立即更新,這個互動通常會讓使用者覺得卡卡的,這個就是中斷、暫停 Render 能解決的問題。

熟悉 Git 版本控制的人不妨直接用 Git 來思考 React 的運作方式,React 可以在不同的 Branch 上 Concurrent 去處理不同 State 變動造成的 Render,而這些 Render 的結果可以被 Merge,也可以直接被棄用。

簡而言之,React 就是要針對不同的裝置能力(CPU)跟網路速度(IO)提供最優化的使用者體驗。

在 React Conf 的 Keynote,Tom Occhino 提到使用者體驗才是 React 的使命,我很喜歡這句原話:

A great developer experience only matters if it’s in service of delivering a great user experience. - Tom Occhino

Suspense for Data Fetching

Suspense 是一個讓還沒準備好可以 Render 的 UI 可以顯示為 Loading 狀態的功能,那為什麼這邊要特別強調是 for Data Fetching 呢?因為 React 早先已經有支援 Suspense 了,但只有包括程式碼緩載入的部分:

const ProfilePage = React.lazy(() => import('./ProfilePage')); 

// Show a spinner while the profile is loading
<Suspense fallback={<Spinner />}>
<ProfilePage />
</Suspense>

而 Suspense for Data Fetching 則堪稱是這個系列的最終章,可以說是從 2016 年開始 Fiber 計畫後最後一個明確的目標

在這邊需要先來了解一下官方所提出的三種抓資料的方式

Fetch-on-Render

使用 componentDidMount 或是 useEffect 去抓資料就屬於這種,這是理論上效率、體驗最差的,Render 後才去呼叫 API,例如下面這樣:

useEffect(() => {
fetchSomething().then(result => setState(result));
}, []);

而且會因為一層一層的 Render,造成抓資料時的 Waterfall。

Fetch-Then-Render

這是 Facebook 的 Relay 框架或者是說 GraphQL 體系比較容易做到的事,首先必須讓資料被靜態的定義好。(如果不太懂 GraphQL 可以完全略過這段或是加入 GraphQL Taiwan 詢問)

例如使用 GraphQL 的 Fragment,這樣你才能在 Render 前就知道 Component 需要什麼資料。並且讓 Fragment 被 Compose 起來,就能避免抓資料時的 Waterfall。

Render-as-You-Fetch (using Suspense)

這應該會是未來推薦的做法,在 Render 之前儘早的開始抓資料,並立刻的開始 Render 下一個頁面,這時資料若處於未 Ready 的狀態,那就會 throw Promise 並進入 Suspense 的狀態,等到 Promise Resolve 後,React 會進行 Retry(這時候資料已經 Ready 了)。

function ProfilePage() {
return (
<Suspense fallback={<h1>Loading profile...</h1>}>
<ProfileDetails />
</Suspense>
);
}

function ProfileDetails() {
// Try to read user info, although it might not have loaded yet
const user = resource.user.read();
return <h1>{user.name}</h1>;
}

Resource 是個未來還很有可能會改的東西,基本上可以先不用了解,只要知道他這樣 read,可能會 throw Promise 出去給 React 接這樣就夠了。

另外帶來的好處 — 解決 Race Condition

以前傳統的方式在 componentDidMount 或是 useEffect 去抓資料時的時候,Render 跟抓資料的 Promise 本身是脫鉤的:

useEffect(() => {
fetchUser(id).then(u => setUser(u));
}, [id]);

這樣的程式若在 Promise 還沒 Resolve 的情況下就進行下一次的 Render,就會造成 Race Condition,因為這個 Effect 沒有被好好的 Cleanup,做乾淨點是要去取消 Fetch 以及它所造成的 setState 效果,但這要寫清楚又很麻煩,很容易出錯。

在 Suspense for Data Fetching 的情況下,這個抓資料的 Promise 跟 Render 是掛鉤一起的,就不會有這個 Effect 沒完成需要取消的狀況了。

什麼是 Transition?

Transition 就是指切換頁面的那個 Transition。

為什麼要特別提到這個呢?因為這在使用者體驗上其實扮演舉足輕重的角色。

不知道大家有沒有類似的經驗,在一個已經 Render 很完整的一個頁面,點了一個按鈕跳頁後,那瞬間回到一個 Loading 狀態,資料來了後東西才又顯示出來,這中間花的時間有長有短,短得有的甚至就是一個閃爍。

以官方提供的範例來說,原本好好的 Home Page 一但切到 Profile Page,原本的畫面就不見了,剩下一個大大的 Loading:

在這邊我們需要討論一個狀況,如果我們在跳頁時,讓原本的畫面暫留一下子,來刻意地跳過中間那個有點糟的 Loading 狀態,那會不會更好呢?

用 useTransition 來改善換頁的體驗

React 提供了一個方式來處理這個問題,就是利用新的內建 Hook useTransition()

import React, { useState, useTransition } from 'react';function App() {
const [resource, setResource] = useState(initialResource);
const [startTransition, isPending] = useTransition({
timeoutMs: 3000,
});
// ...

我們簡單的來看一下這個 Hook 的參數與回傳值:

  • startTransition 是個 Function,可以用來告訴 React 哪些 State Update 可以延後生效。
  • isPending 是個 Boolean 值,代表 Transition 是否正在進行。這是要用來在原先的頁面顯示 Loading 提示,不然停在原本的頁面也會讓使用者以為網頁失去回應。
  • timeoutMs 則是設定一個 Pending 的時間上限,超過了時間無論畫面有多糟都是直接進行 State Update。

可以假設我們原本是這樣在 onClick 裡面去 setState 的:

<button
onClick={() => {
const nextUserId = getNextId(resource.userId);
setResource(fetchProfileData(nextUserId));
}}
>

可以把裡面的 State Update 用 startTransition 包起來,表示這段延後生效也沒關係:

<button
onClick={() => {
startTransition(() => {
const nextUserId = getNextId(resource.userId);
setResource(fetchProfileData(nextUserId));
});
}}
>

或許大家看到這邊會很疑惑,startTransition 到底在幹什麼?

沒關係,我剛看完也是滿臉問號、一頭霧水,直到我去翻了一下原始碼

我的理解就是,包在 startTransition 裡面的這段 Code 會被立刻執行,包括這個 fetchProfileData 的部分,但這個 State Update 會被用特別的 Priority放進 Scheduler。

看不懂也沒關係,我們可以直接來看看它的效果,記得要回去看一下上面那張 GIF 比較一下:

這個功能帶來的結論就是下面這張圖,我們要用 useTransition 來 Hold 住畫面(Pending)避免走向直接切換畫面所造成的 UI 倒退的狀況(Receded)

https://reactjs.org/docs/concurrent-mode-patterns.html#the-three-steps

決定 Suspense 的揭露方式

有時候,我們會有超過一個以上的 Suspense 在畫面上,如果秀出超過一個 Loading,有時候會蠻尷尬的,這時候可以用 SuspenseList 把它們包起來,並指定 tailcollapsed,這樣 Loading 就只會出現一個了:

<SuspenseList tail="collapsed">
<Suspense fallback={<h1>Loading...</h1>}>
</Suspense>
<Suspense fallback={<h1>Loading...</h1>}>
</Suspense>
</SuspenseList>

另一個有趣的 prop 是 revealOrder,可以用來決定揭露的順序。

來看一下 React Conf 上的 Demo,這是一個一般的版本,所有圖片參差的出現:

from https://www.youtube.com/watch?v=uXEEL9mrkAQ

下面這個是 revealOrder forwards 的效果,圖片會從左到右,從上到下的顯示:

這個則是 revealOrder together 的效果,所有圖片一起出現:

看完這個就能知道要怎樣用這個功能來改善使用者體驗了。

要如何試用 Concurrent Mode?

Concurrent Mode 目前不存在於 stable 的 release 之中,要試用的話必須安裝 experimental 的版本:

npm install react@experimental react-dom@experimental

除此之外,你還需要把 ReactDOM.render 改成 ReactDOM.createRoot(...).render

import ReactDOM from 'react-dom';

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

如果覺得這個改動太大了,他有提供了一個介於中間的 Blocking Mode,可以用來漸進式的 Migrate 到 Concurrent Mode,雖然缺乏 useTrainsitionuseDefferedValue 等等功能但比 Lagacy Mode 更接近 Concurrent Mode。(用 ReactDOM.createBlockingRoot(...).render即可使用)

以下是功能的對照表:

出自 https://reactjs.org/docs/concurrent-mode-adoption.html

沒錯不要懷疑,React 就是這麼的狠,把你我正在 Production 上使用的版本直接稱之為 Legacy Mode。

總結

Concurrent Mode 到目前為止都還在實驗階段,但可以看到 React Team 不惜花個四五年也要完成它的決心。至於要等到大部分的套件、Component 都能相容 Concurrent Mode 也是另一個長期抗戰。

雖然不是每個開發者都需要去關注使用者不同裝置上的載入、換頁的體驗,但是這種事關 Reconciliation 的行為改變的原理,還是會推薦研究比較深入的 React 開發者必須了解一下。

--

--

C.T. Lin

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