react-Suspense的工作原理解析

React
347
0
0
2023-01-13

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 都生成,并且关系如下:

imgimage.png

  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 中有这样一段相关代码:

参考 React面试题详细解答

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 绑定回调

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 组件的目的。整体流程为:

imgimage.png

利用 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