react 原理分析 – Suspense 是如何工作的?

react 原理分析 – Suspense 是如何工作的?

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 组件,与源码保持一致。

  1. 当 react 在 beginWork 的过程中遇到一个 Suspense 组件时,会首要将 primary 组件作为其子节点,依据 react 的遍历算法,下一个遍历的组件便是未加载完结的 primary 组件。
  2. 当遍历到 primary 组件时,primary 组件会抛出一个反常。该反常内容为组件 promise,react 捕获到反常后,发现其是一个 promise,会将其 then 办法增加一个回调函数,该回调函数的作用是触发 Suspense 组件的更新。而且将下一个需求遍历的元素从头设置为 Suspense,因而在一次 beginWork 中,Suspense 会被拜访两次。
  3. 又一次遍历到 Suspense,本次会将 primary 以及 fallback 都生成,而且联系如下:

react 原理剖析 - Suspense 是怎么作业的?
尽管 primary 作为 Suspense 的直接子节点,可是 Suspense 会在 beginWork 阶段直接回来 fallback。使得直接越过 primary 的遍历。因而此刻 primary 必定没有加载完结,所以也没必要再遍历一次。本次烘托完毕后,屏幕上会展现 fallback 的内容

  1. 当 primary 组件加载完结后,会触发过程 2 中 then,使得在 Suspense 上调度一个更新,因为此刻加载现已完结,Suspense 会直接烘托加载完结的 primary 组件,并删去 fallback 组件。

这 4 个过程看起来仍是比较复杂。相关于普通的组件首要有两个不同的流程:

  1. primary 会组件抛出反常,react 捕获反常后持续 beginWork 阶段。
  2. 整个 beginWork 节点,Suspense 会被拜访两次

不过根本逻辑仍是比较简略,便是:

  1. 抛出反常
  2. react 捕获,增加回调
  3. 展现 fallback
  4. 加载完结,履行回调
  5. 展现加载完结后的组件

整个 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;
  }
}

因而履行这个办法大致能够分为两个状况:

  1. 未加载完结时抛出反常
  2. 加载完结后回来组件

到这儿,整个 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。整个流程图如下:

react 原理剖析 - Suspense 是怎么作业的?

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。 整个流程图如下:

react 原理剖析 - Suspense 是怎么作业的?

primary 组件加载完结时的烘托

加载完结之后会触发 Suspense 的更新,此刻为:

beginWork – first pass – DidCapture 不存在: 将 primary 组件作为子节点,假如 fallback 组件存在,则将其增加到 Suspense 组件的 deletions 中。因为此刻 primary 组件加载完结,拜访子节点不会抛出反常。处理的函数相同为 updateSuspensePrimaryChildren,这儿就不再贴出来。

能够看出,primary 组件加载完结后就不会抛出反常,因而不会进入到 second pass,那么就不会有铲除 deletions 的操作,因而本次完结后 fallback 仍然在删去列表中,终究会被删去。达到了切换到 primary 组件的意图。整体流程为:

react 原理剖析 - Suspense 是怎么作业的?

使用 Suspense 自己完结数据加载

在咱们了解了 lazy + Suspense 的原理之后,能够自己使用 Suspense 来进行数据加载,其无非便是三种状况:

  1. 初始化:查询数据,抛出 promise
  2. 加载中: 直接抛出 promise
  3. 加载完结:设置 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… 。