react Suspense工作原理
l1n3x 人气:0Suspense 基本应用
Suspense 目前在 react 中一般配合 lazy 使用,当有一些组件需要动态加载(例如各种插件)时可以利用 lazy 方法来完成。其中 lazy 接受类型为 Promise<() => {default: ReactComponet}>
的参数,并将其包装为 react 组件。ReactComponet 可以是类组件函数组件或其他类型的组件,例如:
const Lazy = React.lazy(() => import("./LazyComponent")) <Suspense fallback={"loading"}> <Lazy/> // lazy 包装的组件 </Suspense>
由于 Lazy 往往是从远程加载,在加载完成之前 react 并不知道该如何渲染该组件。此时如果不显示任何内容,则会造成不好的用户体验。因此 Suspense 还有一个强制的参数为 fallback,表示 Lazy 组件加载的过程中应该显示什么内容。往往 fallback 会使用一个加载动画。当加载完成后,Suspense 就会将 fallback 切换为 Lazy 组件的内容。一个完整的例子如下:
function LazyComp(){ console.info("sus", "render lazy") return "i am a lazy man" } function delay(ms){ return new Promise((resolve, reject) => { setTimeout(resolve, ms) }) } // 模拟动态加载组件 const Lazy = lazy(() => delay(5000).then(x => ({"default": LazyComp}))) function App() { const context = useContext(Context) console.info("outer context") return ( <Suspense fallback={"loading"}> <Lazy/> </Suspense> ) }
这段代码定义了一个需要动态加载的 LazyComp 函数式组件。会在一开始显示 fallback 中的内容 loading,5s 后显示 i am a lazy man。
Suspense 原理
虽然说 Suspense 往往会配合 lazy 使用,但是 Suspense 是否只能配合 lazy 使用?lazy 是否又必须配合Suspense? 要搞清楚这两个问题,首先要明白 Suspense 以及 lazy 是在整个过程中扮演的角色,这里先给出一个简单的结论:
- Suspense: 可以看做是 react 提供用了加载数据的一个标准,当加载到某个组件时,如果该组件本身或者组件需要的数据是未知的,需要动态加载,此时就可以使用 Suspense。Suspense 提供了加载 -> 过渡 -> 完成后切换这样一个标准的业务流程。
- lazy: lazy 是在 Suspense 的标准下,实现的一个动态加载的组件的工具方法。
从上面的描述即可以看出,Suspense 是一个加载数据的标准,lazy 只是该标准下实现的一个工具方法。那么说明 Suspense 除配合了 lazy 还可以有其他应用场景。而 lazy 是 Suspense 标准下的一个工具方法,因此无法脱离 Suspense 使用。接下来通过 lazy + Suspense 方式来给大家分析具体原理,搞懂了这部分,我们利用 Suspense 实现自己的数据加载也不是难事。
基本流程
在深入了解细节之前,我们先了解一下 lazy + Suspense 的基本原理。这里需要一些 react 渲染流程的基本知识。为了统一,在后续将动态加载的组件称为 primary 组件,fallback 传入的组件称为 fallback 组件,与源码保持一致。
- 当 react 在 beginWork 的过程中遇到一个 Suspense 组件时,会首先将 primary 组件作为其子节点,根据 react 的遍历算法,下一个遍历的组件就是未加载完成的 primary 组件。
- 当遍历到 primary 组件时,primary 组件会抛出一个异常。该异常内容为组件 promise,react 捕获到异常后,发现其是一个 promise,会将其 then 方法添加一个回调函数,该回调函数的作用是触发 Suspense 组件的更新。并且将下一个需要遍历的元素重新设置为 Suspense,因此在一次 beginWork 中,Suspense 会被访问两次。
- 又一次遍历到 Suspense,本次会将 primary 以及 fallback 都生成,并且关系如下:
虽然 primary 作为 Suspense 的直接子节点,但是 Suspense 会在 beginWork 阶段直接返回 fallback。使得直接跳过 primary 的遍历。因此此时 primary 必定没有加载完成,所以也没必要再遍历一次。本次渲染结束后,屏幕上会展示 fallback 的内容
- 当 primary 组件加载完成后,会触发步骤 2 中 then,使得在 Suspense 上调度一个更新,由于此时加载已经完成,Suspense 会直接渲染加载完成的 primary 组件,并删除 fallback 组件。
这 4 个步骤看起来还是比较复杂。相对于普通的组件主要有两个不同的流程:
- primary 会组件抛出异常,react 捕获异常后继续 beginWork 阶段。
- 整个 beginWork 节点,Suspense 会被访问两次
不过基本逻辑还是比较简单,即是:
- 抛出异常
- react 捕获,添加回调
- 展示 fallback
- 加载完成,执行回调
- 展示加载完成后的组件
整个 beginWork 遍历顺序为:
Suspense -> primary -> Suspense -> fallback
源码解读 - primary 组件
整个 Suspend 的逻辑相对于普通流程实际上是从 primary 组件开始的,因此我们也从 react 是如何处理 primary 组件开始探索。找到 react 在 beginWork 中处理处理 primary 组件的逻辑的方法 mountLazyComponent
,这里我摘出一段关键的代码:
const props = workInProgress.pendingProps; const lazyComponent: LazyComponentType<any, any> = elementType; const payload = lazyComponent._payload; const init = lazyComponent._init; let Component = init(payload); // 如果未加载完成,则会抛出异常,否则会返回加载完成的组件
其中最关键的部分莫过于这个 init 方法,执行到这个方法时,如果没有加载完成就会抛出 Promise 的异常。如果加载完成就直接返回完成后的组件。我们可以看到这个 init 方法实际上是挂载到 lazyComponent._init
方法,lazyComponent 则就是 React.lazy() 返回的组件。我们找到 React.lazy() :
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, };
这里的 lazyType 实际上就是上面的 lazyComponent。那么这里的 _init
实际上来自于另一个函数 lazyInitializer:
function lazyInitializer<T>(payload: Payload<T>): T { if (payload._status === Uninitialized) { console.info("sus", "payload status", "Uninitialized") const ctor = payload._result; const thenable = ctor(); // 这里的 ctor 就是我们返回 promise 的函数,执行之后得到一个加载组件的 promise // 加载完成后修改状态,并将结果挂载到 _result 上 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 === Uninitialized) { // In case, we're still uninitialized, then we're waiting for the thenable // to resolve. Set it as pending in the meantime. const pending: PendingPayload = (payload: any); pending._status = Pending; pending._result = thenable; } } // 如果已经加载完成,则直接返回组件 if (payload._status === Resolved) { const moduleObject = payload._result; console.info("sus", "get lazy resolved result") return moduleObject.default; // 注意这里返回的是 moduleObject.default 而不是直接返回 moduleObject } else { // 否则抛出异常 console.info("sus, raise a promise", payload._result) throw payload._result; } }
因此执行这个方法大致可以分为两个状态:
- 未加载完成时抛出异常
- 加载完成后返回组件
到这里,整个 primary 的逻辑就搞清楚了。下一步则是搞清楚 react 是如何捕获并且处理异常的。
源码解读 - 异常捕获
react 协调整个阶段都在 workLoop 中执行,代码如下:
do { try { workLoopSync(); break; } catch (thrownValue) { handleError(root, thrownValue); } } while (true);
可以看到 catch 了 error 后,整个处理过程在 handleError 中完成。当然,如果是如果 primary 组件抛出的异常,这里的 thrownValue 就为一个 priomise。在 handleError 中有这样一段相关代码:
throwException( root, erroredWork.return, erroredWork, thrownValue, workInProgressRootRenderLanes, ); completeUnitOfWork(erroredWork);
核心代码需要继续深入到 throwException:
// 首先判断是否是为 promise if ( value !== null && typeof value === 'object' && typeof value.then === 'function' ) { const wakeable: Wakeable = (value: any); resetSuspendedComponent(sourceFiber, rootRenderLanes); // 获取到 Suspens 父组件 const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber); if (suspenseBoundary !== null) { suspenseBoundary.flags &= ~ForceClientRender; // 给 Suspens 父组件 打上一些标记,让 Suspens 父组件知道已经有异常抛出,需要渲染 fallback markSuspenseBoundaryShouldCapture( suspenseBoundary, returnFiber, sourceFiber, root, rootRenderLanes, ); // We only attach ping listeners in concurrent mode. Legacy Suspense always // commits fallbacks synchronously, so there are no pings. if (suspenseBoundary.mode & ConcurrentMode) { attachPingListener(root, wakeable, rootRenderLanes); } // 将抛出的 promise 放入Suspens 父组件的 updateQueue 中,后续会遍历这个 queue 进行回调绑定 attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes); return; } }
可以看到 throwException 逻辑主要是判断抛出的异常是不是 promise,如果是的话,就给 Suspens 父组件打上 ShoulCapture 的 flags,具体用处下面会讲到。并且把抛出的 promise 放入 Suspens 父组件的 updateQueue 中。
throwException 完成后会执行一次 completeUnitOfWork,根据 ShoulCapture 打上 DidCapture 的 flags。 并将下一个需要遍历的节点设置为 Suspense,也就是下一次遍历的对象依然是 Suspense。这也是之前提到的 Suspens 在整个 beginWork 阶段会遍历两次。
源码解读 - 添加 promise 回调
在 Suspense 的 update queue 中,在 commit 阶段会遍历这个 updateQueue 添加回调函数,该功能在 commitMutationEffectsOnFiber
中。找到关于 Suspense 的部分,会有以下代码:
if (flags & Update) { try { commitSuspenseCallback(finishedWork); } catch (error) { captureCommitPhaseError(finishedWork, finishedWork.return, error); } attachSuspenseRetryListeners(finishedWork); } return;
主要逻辑在 attachSuspenseRetryListeners 中:
function attachSuspenseRetryListeners(finishedWork: Fiber) { const wakeables: Set<Wakeable> | null = (finishedWork.updateQueue: any); if (wakeables !== null) { finishedWork.updateQueue = null; let retryCache = finishedWork.stateNode; if (retryCache === null) { retryCache = finishedWork.stateNode = new PossiblyWeakSet(); } wakeables.forEach(wakeable => { // Memoize using the boundary fiber to prevent redundant listeners. const retry = resolveRetryWakeable.bind(null, finishedWork, wakeable); // 判断一下这个 promise 是否已经绑定过一次了,如果绑定过则可以忽略 if (!retryCache.has(wakeable)) { retryCache.add(wakeable); if (enableUpdaterTracking) { if (isDevToolsPresent) { if (inProgressLanes !== null && inProgressRoot !== null) { // If we have pending work still, associate the original updaters with it. restorePendingUpdaters(inProgressRoot, inProgressLanes); } else { throw Error( 'Expected finished root and lanes to be set. This is a bug in React.', ); } } } // 将 retry 绑定 promise 的 then 回调 wakeable.then(retry, retry); } }); } }
attachSuspenseRetryListeners 整个逻辑就是绑定 promise 回调,并将绑定后的 promise 放入缓存,以免重复绑定。这里绑定的回调为 resolveRetryWakeable.bind(null, finishedWork, wakeable)
,在这个方法中又调用了 retryTimedOutBoundary
方法:
if (retryLane === NoLane) { // TODO: Assign this to `suspenseState.retryLane`? to avoid // unnecessary entanglement? retryLane = requestRetryLane(boundaryFiber); } // TODO: Special case idle priority? const eventTime = requestEventTime(); const root = markUpdateLaneFromFiberToRoot(boundaryFiber, retryLane); if (root !== null) { markRootUpdated(root, retryLane, eventTime); ensureRootIsScheduled(root, eventTime); }
看到 markUpdateLaneFromFiberToRoot 逻辑就比较清晰了,即在 Suspense 的组件上调度一次更新。也就是说,当动态组件的请求完成后,会执行 resolveRetryWakeable -> retryTimedOutBoundary,并且最终让 Suspense 进行一次更新。
源码解读-Suspense
之所以是将 Suspense 放在最后来分析,是因为对 Suspense 的处理涉及到多个状态,这些状态在之前的步骤中或许会被修改,因此在了解其他步骤之后再来看 Suspense 或许更容易理解。对于 Suspense 来说,在 workLoop 中可能会有 3 种不同的处理方式。每一次 beginWork Suspense 又会被访问两次,在源码中称为 first pass 和 second pass 。这两次会根据在 Suspense 的 flags 上是否存在 DidCapture 来进行不同操作。整个处理逻辑都在 updateSuspenseComponent
中。
首次渲染
beginWork - first pass,此时 DidCapture 不存在,Suspense 将 primary 组件作为子节点,访问子节点后会抛出异常。catch 时会设置 DidCapture 到 flags 上。对应的函数为 mountSuspensePrimaryChildren:
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; // 子节点为 primaryChildFragment,下一次访问会抛出异常 return primaryChildFragment; }
beginWork - second pass,由于此时 DidCapture 存在,会将 primary 组件作为子节点,并将 fallback 组件作为 primary 组件的兄弟节点。但是直接返回 primary 组件,跳过 fallback 组件。对应的函数为 mountSuspenseFallbackChildren:
function mountSuspenseFallbackChildren( workInProgress, primaryChildren, fallbackChildren, renderLanes, ) { const mode = workInProgress.mode; const progressedPrimaryFragment: Fiber | null = workInProgress.child; const primaryChildProps: OffscreenProps = { mode: 'hidden', children: primaryChildren, }; let primaryChildFragment; let fallbackChildFragment; primaryChildFragment.return = workInProgress; fallbackChildFragment.return = workInProgress; primaryChildFragment.sibling = fallbackChildFragment; workInProgress.child = primaryChildFragment; // 注意这里的子节点是 primaryChildFragment return fallbackChildFragment; // 但返回的却是 fallbackChildFragment,目的是为了跳过 primaryChild 的遍历 }
commit: 将挂载到 updateQueue 上的 promise 绑定回调,并清除 DidCapture。整个流程图如下:
primary 组件加载完成前的渲染
在首次渲染以及 primary 组件加载完成的期间,还可能会有其他组件更新而触发触发渲染,其逻辑为:
beginWork - first pass - DidCapture 不存在: 将 primary 组件作为子节点,如果 fallback 组件存在,则将其添加到 Suspense 组件的 deletions 中。访问子节点后会抛出异常。catch 时会设置 DidCapture 到 flags 上。 对应的函数为 updateSuspensePrimaryChildren:
function updateSuspensePrimaryChildren( current, workInProgress, primaryChildren, renderLanes, ) { const currentPrimaryChildFragment: Fiber = (current.child: any); const currentFallbackChildFragment: Fiber | null = currentPrimaryChildFragment.sibling; const primaryChildFragment = updateWorkInProgressOffscreenFiber( currentPrimaryChildFragment, { mode: 'visible', children: primaryChildren, }, ); if ((workInProgress.mode & ConcurrentMode) === NoMode) { primaryChildFragment.lanes = renderLanes; } primaryChildFragment.return = workInProgress; primaryChildFragment.sibling = null; // 如果 currentFallbackChildFragment 存在,需要添加到 deletions 中 if (currentFallbackChildFragment !== null) { const deletions = workInProgress.deletions; if (deletions === null) { workInProgress.deletions = [currentFallbackChildFragment]; workInProgress.flags |= ChildDeletion; } else { deletions.push(currentFallbackChildFragment); } } workInProgress.child = primaryChildFragment; return primaryChildFragment; }
beginWork - second pass - DidCapture 存在: 将 primary 组件作为子节点,将 fallback 组件作为 primary 组件的兄弟节点。并且清除deletions。因为此时 primary 组件还未加载完成,所以需要确保 fallback 组件不会被删除。对于的函数为:
function updateSuspenseFallbackChildren( current, workInProgress, primaryChildren, fallbackChildren, renderLanes, ) { const progressedPrimaryFragment: Fiber = (workInProgress.child: any); primaryChildFragment = progressedPrimaryFragment; primaryChildFragment.childLanes = NoLanes; primaryChildFragment.pendingProps = primaryChildProps; if (enableProfilerTimer && workInProgress.mode & ProfileMode) { primaryChildFragment.actualDuration = 0; primaryChildFragment.actualStartTime = -1; primaryChildFragment.selfBaseDuration = currentPrimaryChildFragment.selfBaseDuration; primaryChildFragment.treeBaseDuration = currentPrimaryChildFragment.treeBaseDuration; } // 清除 deletions,确保 fallback 可以展示 workInProgress.deletions = null; let fallbackChildFragment; if (currentFallbackChildFragment !== null) { fallbackChildFragment = createWorkInProgress( currentFallbackChildFragment, fallbackChildren, ); } else { fallbackChildFragment = createFiberFromFragment( fallbackChildren, mode, renderLanes, null, ); fallbackChildFragment.flags |= Placement; } fallbackChildFragment.return = workInProgress; primaryChildFragment.return = workInProgress; primaryChildFragment.sibling = fallbackChildFragment; workInProgress.child = primaryChildFragment; // 同样的操作,workInProgress.child 为 primaryChildFragment return fallbackChildFragment; // 但是返回 fallbackChildFragment }
commit: 清除 DidCapture。 整个流程图如下:
primary 组件加载完成时的渲染
加载完成之后会触发 Suspense 的更新,此时为:
beginWork - first pass - DidCapture 不存在: 将 primary 组件作为子节点,如果 fallback 组件存在,则将其添加到 Suspense 组件的 deletions 中。由于此时 primary 组件加载完成,访问子节点不会抛出异常。处理的函数同样为 updateSuspensePrimaryChildren,这里就不再贴出来。
可以看出,primary 组件加载完成后就不会抛出异常,因此不会进入到 second pass,那么就不会有清除 deletions 的操作,因此本次完成后 fallback 仍然在删除列表中,最终会被删除。达到了切换到 primary 组件的目的。整体流程为:
利用 Suspense 自己实现数据加载
在我们明白了 lazy + Suspense 的原理之后,可以自己利用 Suspense 来进行数据加载,其无非就是三种状态:
- 初始化:查询数据,抛出 promise
- 加载中: 直接抛出 promise
- 加载完成:设置 promise 返回的数据
按照这样的思路,设计一个简单的数据加载功能:
// 模拟请求 promise function mockApi(){ return delay(5000).then(() => "data fetched") } // 处理请求状态变更 function fetchData(){ let status = "uninit" let data = null let promise = null return () => { switch(status){ // 初始状态,发出请求并抛出 promise case "uninit": { const p = mockApi() .then(x => { status = "resolved" data = x }) status = "loading" promise = p throw promise }; // 加载状态,直接抛出 promise case "loading": throw promise; // 如果加载完成直接返回数据 case "resolved": return data; default: break; } } } const reader = fetchData() function TestDataLoad(){ const data = reader() return ( <p>{data}</p> ) } function App() { const [count, setCount] = useState(1) useEffect(() => { setInterval(() => setCount(c => c > 100 ? c: c + 1), 1000) }, []) return ( <> <Suspense fallback={"loading"}> <TestDataLoad/> </Suspense> <p>count: {count}</p> </> ) }
结果为一开始显示 fallback 中的 loading,数据加载完成后显示 data fetched。你可以在这里进行在线体验:codesandbox.io/s/suspiciou…
关于更多使用 Suspense 进行数据加载这方面的内容,可以参考 react 的官方文档: 17.reactjs.org/docs/concur… 。
加载全部内容