我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。
本文作者:佳嵐
Suspense
Suspense
元件我們並不陌生,中文名可以理解為暫停or懸停
, 在 React16 中我們通常在路由懶載入中配合 Lazy 元件一起使用 ,當然這也是官方早起版本推薦的唯一用法。
那它暫停了什麼? 進行非同步網路請求,然後再拿到請求後的資料進行渲染是很常見的需求,但這不可避免的需要先渲染一次沒有資料的頁面,資料返回後再去重新渲染。so , 我們想要暫停的就是第一次的無資料渲染。
通常我們在沒有使用Suspense
時一般採用下面這種寫法, 透過一個isLoading
狀態來顯示載入中或資料。這樣程式碼是不會有任何問題,但我們需要手動去維護一個isLoading
狀態的值。
const [data, isLoading] = fetchData("/api");
if (isLoading) {
return <Spinner />;
}
return <MyComponent data={data} />;
當我們使用Suspense
後,使用方法會變為如下, 我們只需將進行非同步資料獲取的元件進行包裹,並將載入中元件透過fallback
傳入
return (
<Suspense fallback={<Spinner />}>
<MyComponent />
</Suspense>
);
那 React 是如何知道該顯示MyComponent
還是Spinner
的?
答案就在於MyComponent
內部進行fetch
遠端資料時做了一些手腳。
export const App = () => {
return (
<div>
<Suspense fallback={<Spining />}>
<MyComponent />
</Suspense>
</div>
);
};
function Spining() {
return <p>loading...</p>;
}
let data = null;
function MyComponent() {
if (!data) {
throw new Promise((resolve) => {
setTimeout(() => {
data = 'kunkun';
resolve(true);
}, 2000);
});
}
return (
<p>
My Component, data is {data}
</p>
);
}
Suspense
是根據捕獲子元件內的異常來實現決定展示哪個元件的。這有點類似於ErrorBoundary
,不過ErrorBoundary
是捕獲 Error 時就展示回退元件,而Suspense
捕獲到的 Error 需要是一個Promise
物件(並非必須是 Promise 型別,thenable 的都可以)。
我們知道 Promise 有三個狀態,pending
、fullfilled
、rejected
,當我們進行遠端資料獲取時,會建立一個Promise
,我們需要直接將這個Promise
作為Error
進行丟擲,由 Suspense 進行捕獲,捕獲後對該thenable
物件的then
方法進行回撥註冊thenable.then(retry)
, 而 retry 方法就會開始一個排程任務進行更新,後面會詳細講。
知道了大致原理,這時還需要對我們的fetcher
進行一層包裹才能實際運用。
// MyComponent.tsx
const getList = wrapPromise(fetcher('http://api/getList'));
export function MyComponent() {
const data = getList.read();
return (
<ul>
{data?.map((item) => (
<li>{item.name}</li>
))}
</ul>
);
}
function fetcher(url) {
return new Promise((resove, reject) => {
setTimeout(() => {
resove([{ name: 'This is Item1' }, { name: 'This is Item2' }]);
}, 1000);
});
}
// Promise包裹函式,用來滿足Suspense的要求,在初始化時預設就會throw出去
function wrapPromise(promise) {
let status = 'pending';
let response;
const suspend = promise.then(
(res) => {
status = 'success';
response = res;
},
(err) => {
status = 'error';
response = err;
}
);
const read = () => {
switch (status) {
case 'pending':
throw suspend;
default:
return response;
}
};
return { read };
從上述程式碼我們可以注意到,透過const data = getList.read()
這種同步的方式我們就能拿到資料了。 注意: 上面這種寫法並非一種正規化,目前官方也沒有給出推薦的寫法
為了與Suspense
配合,則我們的請求可能會變得很不優雅
,官方推薦是直接讓我們使用第三方框架提供的能力使用Suspense
請求資料,如 useSWR
等
下面時useSWR
的示例,簡明瞭很多,並且對於Profile
元件,資料獲取的寫法可以看成是同步的了。
import { Suspense } from 'react'
import useSWR from 'swr'
function Profile () {
const { data } = useSWR('/api/user', fetcher, { suspense: true })
return <div>hello, {data.name}</div>
}
function App () {
return (
<Suspense fallback={<div>loading...</div>}>
<Profile/>
</Suspense>
)
}
Suspense
的另一種用法就是與懶載入lazy元件
配合使用,在完成載入前展示Loading
<Suspense fallback={<GlobalLoading />}>
{lazy(() => import('xxx/xxx.tsx'))}
</Suspense>
由此得出,透過lazy
返回的元件也應該包裹一層類似如上的 Promise,我們看看 lazy 內部是如何實現的。
其中ctor
就是我們傳入的() => import('xxx/xxx.tsx')
, 執行lazy
也只是幫我們封裝了層資料結構。ReactLazy.js
export function lazy<T>(
ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {
const payload: Payload<T> = {
// We use these fields to store the result.
_status: Uninitialized,
_result: ctor,
};
const lazyType: LazyComponent<T, Payload<T>> = {
$$typeof: REACT_LAZY_TYPE,
_payload: payload,
_init: lazyInitializer,
};
return lazyType;
}
React 會在Reconciler
過程中去實際執行,在協調的render
階段beginWork
中可以看到對lazy單獨處理的邏輯。 ReactFiberBeginWork.js
function mountLazyComponent(
_current,
workInProgress,
elementType,
renderLanes,
) {
const props = workInProgress.pendingProps;
const lazyComponent: LazyComponentType<any, any> = elementType;
const payload = lazyComponent._payload;
const init = lazyComponent._init;
// 在此處初始化lazy
let Component = init(payload);
// 下略
}
那我們再來看看init
幹了啥,也就是封裝前的lazyInitializer
方法,整體跟我們之前實現的 fetch 封裝是一樣的。
ReactLazy.js
function lazyInitializer<T>(payload: Payload<T>): T {
if (payload._status === Uninitialized) {
const ctor = payload._result;
// 這時候開始進行遠端模組的匯入
const thenable = ctor();
thenable.then(
moduleObject => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const resolved: ResolvedPayload<T> = (payload: any);
resolved._status = Resolved;
resolved._result = moduleObject;
}
},
error => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const rejected: RejectedPayload = (payload: any);
rejected._status = Rejected;
rejected._result = error;
}
},
);
}
if (payload._status === Resolved) {
const moduleObject = payload._result;
}
return moduleObject.default;
} else {
// 第一次執行肯定會先丟擲異常
throw payload._result;
}
}
Suspense 底層是如何實現的?
其底層細節非常之多,在開始之前,我們先回顧下 React 的大致架構
Scheduler: 用於排程任務,我們每次setState
可以看成是往其中塞入一個Task
,由Scheduler
內部的優先順序策略進行判斷何時排程執行該Task
Reconciler: 協調器,進行 diff 演算法,構建 fiber 樹
Renderer: 渲染器,將 fiber 渲染成 dom 節點
Fiber 樹的結構, 在 reconciler 階段,採用深度優先
的方式進行遍歷,往下遞即呼叫beginWork
的過程,往上回溯即呼叫ComplteWork
的過程
我們先直接進入Reconciler
中分析下Suspense
的fiber
節點是如何被建立的
beginWork
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
switch (workInProgress.tag) {
case HostText:
return updateHostText(current, workInProgress);
case SuspenseComponent:
return updateSuspenseComponent(current, workInProgress, renderLanes);
// 省略其他型別
}
}
- 在
beginWork
中會根據**不同的元件型別**
執行不同的建立方法, 而Suspense
對應的會進入到updateSuspenseComponent
function updateSuspenseComponent(current, workInProgress, renderLanes) {
const nextProps = workInProgress.pendingProps;
let showFallback = false;
// 標識該Suspense是否已經捕獲過子元件的異常了
const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;
if (
didSuspend
) {
showFallback = true;
workInProgress.flags &= ~DidCapture;
}
// 第一次元件載入
if (current === null) {
const nextPrimaryChildren = nextProps.children;
const nextFallbackChildren = nextProps.fallback;
// 第一次預設不展示fallback,因為要先走到children後才會產生異常
if (showFallback) {
const fallbackFragment = mountSuspenseFallbackChildren(
workInProgress,
nextPrimaryChildren,
nextFallbackChildren,
renderLanes,
);
const primaryChildFragment: Fiber = (workInProgress.child: any);
primaryChildFragment.memoizedState = mountSuspenseOffscreenState(
renderLanes,
);
return fallbackFragment;
}
else {
return mountSuspensePrimaryChildren(
workInProgress,
nextPrimaryChildren,
renderLanes,
);
}
} else {
// 如果是更新,操作差不多,此處略
}
}
- 第一次
updateSuspenseComponent
時 ,我們會把mountSuspensePrimaryChildren
的結果作為下一個需要建立的fiber
, 因為需要先去觸發異常。 - 實際上
mountSuspensePrimaryChildren
會為我們的PrimaryChildren
在包上一層OffscreenFiber
。
function mountSuspensePrimaryChildren(
workInProgress,
primaryChildren,
renderLanes,
) {
const mode = workInProgress.mode;
const primaryChildProps: OffscreenProps = {
mode: 'visible',
children: primaryChildren,
};
const primaryChildFragment = mountWorkInProgressOffscreenFiber(
primaryChildProps,
mode,
renderLanes,
);
primaryChildFragment.return = workInProgress;
workInProgress.child = primaryChildFragment;
return primaryChildFragment;
}
什麼是OffscreenFiber/Component
?
透過其需要的 mode 引數值,我們可以大膽的猜測,應該是一個能控制是否顯示子元件的元件,如果hidden
,則會透過 CSS 樣式隱藏子元素。
在這之後的 Fiber 樹結構
當我們向下執行到MyComponent
時,由於丟擲了錯誤,當前的reconciler
階段會被暫停
讓我們再回到 Reconciler 階段的起始點可以看到有Catch
語句。renderRootConcurrent
function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
// 省略..
do {
try {
workLoopConcurrent();
break;
} catch (thrownValue) {
handleError(root, thrownValue);
}
} while (true);
// 省略..
}
performConcurrentWorkOnRoot(root, didTimeout) {
// 省略..
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes);
// 省略..
}
我們再看看錯誤處理函式handleError
中做了些什麼 handleError
function handleError(root, thrownValue): void {
// 這時的workInProgress指向MyComponent
let erroredWork = workInProgress;
try {
throwException(
root,
erroredWork.return,
erroredWork,
thrownValue,
workInProgressRootRenderLanes,
);
completeUnitOfWork(erroredWork);
}
function throwException(root: FiberRoot, returnFiber: Fiber, sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes)
{
// 給MyComponent打上未完成標識
sourceFiber.flags |= Incomplete;
if (
value !== null &&
typeof value === 'object' &&
typeof value.then === 'function'
) {
// wakeable就是我們丟擲的Promise
const wakeable: Wakeable = (value: any);
// 向上找到第一個Suspense邊界
const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
if (suspenseBoundary !== null) {
// 打上標識
suspenseBoundary.flags &= ~ForceClientRender;
suspenseBoundary.flags |= ShouldCapture;
// 註冊監聽器
attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
return;
}
}
主要做了三件事
- 給丟擲錯誤的元件打上
Incomplete
標識 - 如果捕獲的錯誤是 thenable 型別,則認定為是 Suspense 的子元件,向上找到最接近的一個
Suspense
邊界,並打上ShouldCapture
標識 - 執行
attachRetryListener
對 Promise 錯誤監聽,當狀態改變後開啟一個排程任務重新渲染 Suspense
在錯誤處理的事情做完後,就不應該再往下遞了,開始呼叫completeUnitOfWork
往上歸, 這時由於我們給 MyComponent 元件打上了Incomplete
標識,這個標識表示由於異常等原因渲染被擱置,那我們是不是就要開始往上找能夠處理這個異常的元件?
我們再看看completeUnitOfWork
幹了啥
function completeUnitOfWork(unitOfWork: Fiber): void {
// 大致邏輯
let completedWork = unitOfWork;
if ((completedWork.flags & Incomplete) !== NoFlags) {
const next = unwindWork(current, completedWork, subtreeRenderLanes);
if (next) {
workInProgress = next;
return
}
// 給父節點打上Incomplete標記
if (returnFiber !== null) {
returnFiber.flags |= Incomplete;
returnFiber.subtreeFlags = NoFlags;
returnFiber.deletions = null;
}
}
}
可以看到最終打上Incomplete
標識的元件都會進入unwindWork
流程 , 並一直將祖先節點打上Incomplete
標識,直到unwindWork
中找到一個能處理異常的邊界元件,也就ClassComponent
, SuspenseComponent
, 會去掉ShouldCapture
標識,加上DidCapture
標識
這時,對於Suspense
來說需要的DidCapture
已經拿到了,下面就是重新從Suspense
開始走一遍beginWork
流程
再次回到 Suspense 元件, 這時由於有了DidCapture
標識,則展示fallback
對於fallback
元件的fiber
節點是透過mountSuspenseFallbackChildren
生成的
function mountSuspenseFallbackChildren(
workInProgress,
primaryChildren,
fallbackChildren,
renderLanes,
) {
const primaryChildProps: OffscreenProps = {
mode: 'hidden',
children: primaryChildren,
};
let primaryChildFragment = mountWorkInProgressOffscreenFiber(
primaryChildProps,
mode,
NoLanes,
);
let fallbackChildFragment = createFiberFromFragment(
fallbackChildren,
mode,
renderLanes,
null,
);
primaryChildFragment.return = workInProgress;
fallbackChildFragment.return = workInProgress;
primaryChildFragment.sibling = fallbackChildFragment;
workInProgress.child = primaryChildFragment;
return fallbackChildFragment;
}
它主要做了三件事
- 將
PrimaryChild
即Offscreen
元件透過css隱藏 - 將
fallback
元件又包了層Fragment
返回 - 將
fallbackChild
作為sibling
連結至PrimaryChild
到這時渲染 fallback 的 fiber 樹已經基本構建完了,之後進入commit
階段從根節點rootFiber
開始深度遍歷該fiber樹
進行 render。
等待一段時間後,primary
元件資料返回,我們之前在handleError
中新增的監聽器attachRetryListener
被觸發,開始新的一輪任務排程。注:原始碼中排程回撥實際在 Commit 階段才新增的。
這時由於Suspense
節點已經存在,則走的是updateSuspensePrimaryChildren
中的邏輯,與之前首次載入時 monutSuspensePrimaryChildren
不同的是多了刪除的操作, 在 commit 階段時則會刪除fallback
元件, 展示primary
元件。updateSuspensePrimaryChildren
if (currentFallbackChildFragment !== null) {
// Delete the fallback child fragment
const deletions = workInProgress.deletions;
if (deletions === null) {
workInProgress.deletions = [currentFallbackChildFragment];
workInProgress.flags |= ChildDeletion;
} else {
deletions.push(currentFallbackChildFragment);
}
}
至此,Suspense 的一生我們粗略的過完了,在原始碼中對 Suspense 的處理非常多,涉及到優先順序相關的本篇都略過。
Suspense 中使用了Offscreen
元件來渲染子元件,這個元件的特性是能根據傳入 mode 來控制子元件樣式的顯隱,這有一個好處,就是能儲存元件的狀態,有些許類似於 Vue 的keep-alive
。其次,它擁有著最低的排程優先順序,比空閒時優先順序還要低,這也意味著當 mode 切換時,它會被任何其他排程任務插隊打斷掉。
useTransition
useTransition
可以讓我們在不阻塞 UI 渲染的情況下更新狀態。useTransition
和 startTransition
允許將某些更新標記為低優先順序更新
。預設情況下,其他更新被視為緊急更新
。React 將允許更緊急的更新(例如更新文字輸入)來中斷不太緊急的更新(例如展示搜尋結果列表)。
其核心原理其實就是將startTransition
內呼叫的狀態變更方法都標識為低優先順序的lane
(lane優先順序參考)去更新。
const [isPending, startTransition] = useTransition()
startTransition(() => {
setData(xxx)
})
一個輸入框的例子
function Demo() {
const [value, setValue] = useState();
const [isPending, startTransition] = useTransition();
return (
<div>
<h1>useTramsotopm Demo</h1>
<input
onChange={(e) => {
startTransition(() => {
setValue(e.target.value);
});
}}
/>
<hr />
{isPending ? <p>載入中。。</p> : <List value={value} />}
</div>
);
}
function List({ value }) {
const items = new Array(5000).fill(1).map((_, index) => {
return (
<li>
<ListItem index={index} value={value} />
</li>
);
});
return <ul>{items}</ul>;
}
function ListItem({ index, value }) {
return (
<div>
<span>index: </span>
<span>{index}</span>
<span>value: </span>
<span>{value}</span>
</div>
);
}
當我每次進行輸入時,會觸發 List 進行大量更新,但由於我使用了startTransition
對List
的更新進行延後
,所以Input
輸入框不會出現明顯示卡頓現象
演示地址https://stackblitz.com/edit/stackblitz-starters-kmkcjs?file=src%2Ftransition%2FList.tsx
由於更新被滯後了,所以我們怎麼知道當前有沒有被更新呢?
這時候第一個返回引數isPending
就是用來告訴我們當前是否還在等待中。
但我們可以看到,input
元件目前是非受控元件
,如果改為受控元件
,即使使用了startTransition
一樣會出現卡頓,因為 input 響應輸入事件進行狀態更新應該是要同步的。
所以這時候下面介紹的useDeferredValue
作用就來了。
useDeferredValue
useDeferredValue
可讓您推遲更新部分 UI, 它與useTransition
做的事差不多,不過useTransition
是在狀態更新層,推遲狀態更新來實現非阻塞,而useDeferredValue
則是在狀態已經更新後,先使用狀態更新前的值進行渲染,來延遲因狀態變化而導致的元件重新渲染。
它的基本用法
function Page() {
const [value, setValue] = useState('');
const deferredValue = useDeferredValue(setValue);
}
我們再用useDeferredValue
去實現上面輸入框的例子
function Demo() {
const [value, setValue] = useState('');
const deferredValue = useDeferredValue(value);
return (
<div>
<h1>useDeferedValue Demo</h1>
<input
value={value}
onChange={(e) => {
setValue(e.target.value)
}}
/>
<hr />
<List value={deferredValue} />
</div>
);
}
我們將input
作為受控元件
,對於會因輸入框值而造成大量渲染
的List
,我們使用deferredValue
。
其變化過程如下
- 當輸入變化時,
deferredValue
首先會是變化前的舊值進行重新渲染,由於值沒有變,所以 List 沒有重新渲染,也就沒有出現阻塞情況,這時,input 的值能夠實時響應到頁面上。 - 在這次舊值渲染完成後,deferredValue 變更為新的值,React 會在後臺開始對新值進行重新渲染,
List
元件開始 rerender,且此次 rerender 會被標識為低優先順序渲染
,能夠被中斷
- 如果此時又有輸入框輸入,則中斷此次後臺的重新渲染,重新走1,2的流程
我們可以列印下deferredValue
的值看下
初始情況輸入框為1,列印了兩次1
輸入2時,再次列印了兩次1,隨後列印了兩次2
參考
最後
歡迎關注【袋鼠雲數棧UED團隊】\~\
袋鼠雲數棧 UED 團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎 star