10 月剛在 Las Vegas 結束的 React Conf 2019,帶來許多關於 Concurrent Mode、Suspense 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 相當感動阿)
那 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 計畫後最後一個明確的目標。
在這邊需要先來了解一下官方所提出的三種抓資料的方式:
- Approach 1: Fetch-on-Render
- Approach 2: Fetch-Then-Render
- Approach 3: Render-as-You-Fetch (using Suspense)
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)。
決定 Suspense 的揭露方式
有時候,我們會有超過一個以上的 Suspense
在畫面上,如果秀出超過一個 Loading,有時候會蠻尷尬的,這時候可以用 SuspenseList
把它們包起來,並指定 tail 為 collapsed,這樣 Loading 就只會出現一個了:
<SuspenseList tail="collapsed">
<Suspense fallback={<h1>Loading...</h1>}>
</Suspense>
<Suspense fallback={<h1>Loading...</h1>}>
</Suspense>
</SuspenseList>
另一個有趣的 prop 是 revealOrder,可以用來決定揭露的順序。
來看一下 React Conf 上的 Demo,這是一個一般的版本,所有圖片參差的出現:
下面這個是 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,雖然缺乏 useTrainsition
、useDefferedValue
等等功能但比 Lagacy Mode 更接近 Concurrent Mode。(用 ReactDOM.createBlockingRoot(...).render
即可使用)
以下是功能的對照表:
沒錯不要懷疑,React 就是這麼的狠,把你我正在 Production 上使用的版本直接稱之為 Legacy Mode。
總結
Concurrent Mode 到目前為止都還在實驗階段,但可以看到 React Team 不惜花個四五年也要完成它的決心。至於要等到大部分的套件、Component 都能相容 Concurrent Mode 也是另一個長期抗戰。
雖然不是每個開發者都需要去關注使用者不同裝置上的載入、換頁的體驗,但是這種事關 Reconciliation 的行為改變的原理,還是會推薦研究比較深入的 React 開發者必須了解一下。