react 原理剖析 – Suspense 是怎么作业的?
Suspense 根本运用
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… 。