likes
comments
collection
share

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

作者站长头像
站长
· 阅读数 53

本系列作为 SPA 单页应用相关技术栈的探索与解析,先从 React 动态加载角度入手,探索市面当前流行的方案的实现原理。

上篇:源码级解析,搞懂 React 动态加载(上)

上文介绍的 react-loadable 使用 React 16.5.2 版本,而从 16.6.0 版本开始,React 原生提供了 code-splitting 与组件动态加载的方案,引入了 lazy 函数,同时也支持使用 Suspense 与 ErrorBoundary 来进行异常处理与 fallback 展示。本文我们来看看如何使用 React.lazy 与 Suspense 实现组件动态加载,同时结合源码更深入理解原理。

React.lazy 和 Suspense 是什么?

React.lazy 支持动态引入组件,需要接收一个 dynamic import 函数,函数返回的应为 promise 且需要默认导出需要渲染的组件。同时,React.lazy() 组件需要在 React.Suspense 组件下进行渲染,Suspense 又支持传入 fallback 属性,作为动态加载模块完成前组件渲染的内容。

回到系列文章开头的例子:

import Loading from './components/loading';
const MyComponent: React.FC<{}> = () => {
  const [Bar, setBar] = useState(null);
  const firstStateRef = useRef({});
  if (firstStateRef.current) {
    firstStateRef.current = undefined;
    import(/* webpackChunkName: "bar" */ './components/Bar').then(Module => {
      setBar(Module.default);
    });
  }
  if (!Bar) return <Loading />;
  return <Bar />;
}

我们可以通过如下方式进行改造:

import React, { lazy, Suspense } from 'react';
import Loading from './components/loading';
const Bar = lazy(() => import('./components/Bar'));
const MyComponent = (
  <Suspense fallback={<Loading />}>
      <Bar />
  </Suspense>
);

打印 Bar 元素属性,可以看到和常规的虚拟 DOM 元素结构相似,只是 $$typeof 属性被标记为 (react.lazy)

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

为了更明显地展示,我们设定 Bar 组件加载所需要的时间为 2 秒,fallback 的 Loading 组件内容为文字 “Loading...”:

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

那么 React.lazy 是如何实现 fallback 组件与动态加载组件的交替展示?又为什么需要在 Suspense 组件的包裹下才能正常运作呢?接下来我们通过摘录与分析一些源码的核心片段来解读。

源码角度来看 React lazy 原理

注:本部分我们用 React v18.2.0 分支版本源码进行解析

ReactLazy

要理解如何实现动态加载,以及 Suspense 的作用,我们先从 React.lazy() 函数源码入手:

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

lazy(ctor) 函数返回了一个 Object 结构,React 将其定义为一种特殊的虚拟DOM结构——LazyComponent,与其他类型的虚拟DOM数据结构不同,LazyComponent 带有一个 _init 函数,作为组件初始化函数:

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

LazyComponent 的加载逻辑中,核心原则就是:

  • 加载完成后直接返回组件模块本身
  • 加载失败抛出错误
  • 首次加载或加载中的组件将 promise 对象以throw Error的方式抛出

还记得 Suspense 高阶组件吗?理解了上述逻辑,我们很容易可以推测出,Suspense 组件主要处理了抛出的 promise 与传入的 fallback,首先渲染 fallback,promise resolve 之后加载动态组件。

我们已经实现了一个简易的基于 Suspense + React.lazy 的动态加载方案。然而,在 React reconciler 与 Fiber 架构下,实际的源码实现却十分复杂。

Fiber 架构中的 lazy & Suspense

我们知道,在 reconciler + Fiber 架构中,React 组件的渲染包含了协调和commit两个主要阶段。我们首先明确一个概念:

  1. 协调阶段:构建/修改 Fiber 树结构,通过 renderRootConcurrent 开启循环,通过其中的 beginWork 方法调度。
  2. commit 阶段:commitMutationEffects 方法,提交 Fiber 数据结构的修改。

为了方便表述,我们将使用 lazy 动态加载的组件称为 primary 组件,fallback 参数的组件成为 fallback 组件。

阶段1:Suspense 组件解析 —— first pass

renderRootConcurrent 方法主体逻辑如下:

function renderRootSync(root: FiberRoot, lanes: Lanes) {
  // 省略部分流程代码
  do {
    try {
      workLoopSync();
      break;
    } catch (thrownValue) {
      handleError(root, thrownValue);
    }
  } while (true);
  
  // 省略部分流程代码
}

当外层组件 Suspense 渲染时, 执行 workLoopSync 中的 beginWork() 方法,加载SuspenseComponent。所使用到的 updateSuspenseComponent 方法逻辑较为繁琐,抽象一下核心逻辑:

let showFallback = false;
const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;

if (didSuspend || shouldRemainOnFallback()) {
  showFallback = true;
  workInProgress.flags &= ~DidCapture;
}

// 首次加载(current === null)
if (showFallback) {
  const fallbackFragment = mountSuspenseFallbackChildren();
  workInProgress.memoizedState = SUSPENDED_MARKER;
  return fallbackFragment;
} else {
  return mountSuspensePrimaryChildren();
}

// Update 阶段(current !== null)
if (showFallback) {
  const fallbackChildFragment = updateSuspenseFallbackChildren();
  // 省略一些逻辑
  return fallbackChildFragment;
} else {
  const primaryChildFragment = updateSuspensePrimaryChildren();
  // 省略一些逻辑
  return primaryChildFragment;
}

首次协调时,workInProgress 节点不含有 DidCapture 的 flags,所以进入 mountSuspensePrimaryChildren() 逻辑中。Suspense 此时将 primary 组件作为子节点:

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;
  // 设置子节点为 primary 组件,即 lazy() 加载的 LazyComponent
  workInProgress.child = primaryChildFragment;
  return primaryChildFragment;
}

此时,等待下一次协调 执行 workLoopSync 中的 beginWork()方法,检测到当前 workInProgress 节点为 primary 组件,也就是 LazyComponent,调用 mountLazyComponent 方法挂载组件。

阶段2:lazy() 的 primary 组件解析

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

这里的 lazyComponent._init 方法,就是前面的 lazyInitializer 了。

然而,我们知道首次调用 lazyInitializer 时,组件应当尚未加载,根据前面的分析 promise 将会以 throw Error 的形式抛出。后面的逻辑理应停止,协调阶段的循环就此完结。

真的是这样吗?

细心的小伙伴一定发现,前文 renderRootSync 方法循环中的 catch block 处理了动态加载的场景:lazyInitializer 抛出的 promise 向上传递,直到 workLoopSync 方法外被 catch 住,尚未加载完成的组件 promise 传入了 handleError 函数中。

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

这里的 throwException 函数获取了抛出的 promise,把当前节点设置为 Incomplete 状态,找到父节点的 Suspense 组件并标记为 ShouldCapture 状态,这意味着下次在 scheduler 处理该 Suspense 节点时,会对其 fallback 组件进行加载。同时把 promise 通过 weakable 的形式添加到 Suspense 组件的 updateQueue 中等待执行。

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

throwException 的操作完成后,Fiber + 协调架构会执行 completeUnitOfWork 并将 workInProgress 节点设置为父节点的 Suspense。

阶段3:Suspense 组件解析 —— second pass

第二次进入协调时, workInProgress 节点此时存在了 DidCapture 标识,主要进入 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;
  
  // 省略获取 primary 组件 和 fallback 组件逻辑

  // 设置 primary 和 fallback 组件的父节点为 Suspense 节点
  primaryChildFragment.return = workInProgress;
  fallbackChildFragment.return = workInProgress;
  // 设置 primary 的下一个节点为 fallback
  primaryChildFragment.sibling = fallbackChildFragment;
  // 设置 workInProgress 的子节点为 primary
  workInProgress.child = primaryChildFragment;
  // 返回 fallback 组件,作为下一个要处理的节点
  return fallbackChildFragment;
}

可以看出,second pass 的作用就是将 primary 和 fallback 串了起来,然后给 Suspense 再次挂上。这是因为后续等 primary 组件加载完成后,保持这样的 Fiber 结构可以继续流转至 primary 组件进行加载。用一个流程图可以很清晰地表示其中的逻辑:

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

虽然此时对于 Suspense 的处理中,选择了 fallback 组件加载,但是 workInProgress 仍然在 Suspense 处,且后续会继续回到 primary 组件中。updateSuspenseComponent 方法返回 fallback 组件,进而 React 实际渲染的组件为 fallback 组件。

至此,Suspense 组件的首次加载流程结束,进入 Fiber commit 阶段,并等待 primary 组件的加载完成。

阶段4:commit 阶段

commit 阶段主要就是为 primary 组件的加载增添回调函数事件,遍历 Suspense 组件的 updateQueue 中的 weakable,等待加载 weakable 加载完毕后触发回调函数,设置 Suspense 组件下次更新 commitMutationEffectsOnFiber 方法对于 Suspense 组件的主体逻辑如下:

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

其中核心的逻辑就是获取 updateQueue 列表,并对其中所有 weakable 进行去重绑定回调。

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

其中 resolveRetryWeakable 方法如下:

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

看到这里,就相对比较清晰了:当动态组件的请求完成后,会执行绑定的 resolveRetryWeakable 回调函数,将 Suspense 节点标记为下次需要更新。当 primary 组件加载完毕后,再次触发 Suspense 节点的更新。

阶段5:primary 组件加载过程中/完成后的 Suspense 渲染

回看 updateSuspenseComponent 函数中,另一种场景下,current 字段不为 null,逻辑大体相似,只不过调用的方法由 mountSuspensePrimaryChildren/mountSuspenseFallbackChildren 变为了 updateSuspensePrimaryChildren/updateSuspenseFallbackChildren。这里我们直接进入分析流程,在首次加载 primary 后,Suspense 的工作流为:

1)first pass: 调用 updateSuspensePrimaryChildren 将 primary 节点设置为下一个要访问的节点。同时把 fallback 挂在 Suspense 的 deletions 数组上:

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

和首次加载 primary 组件相比,updateSuspensePrimaryChildren 主要区别在于将当前 Suspense 组件的 fallback 添加到 Suspense 上的 deletions 数组中:

if (currentFallbackChildFragment !== null) {
  const deletions = workInProgress.deletions;
  if (deletions === null) {
    workInProgress.deletions = [currentFallbackChildFragment];
    workInProgress.flags |= ChildDeletion;
  } else {
    deletions.push(currentFallbackChildFragment);
  }
}

2)访问 primary 节点: 调用 mountLazyComponent,进而决定是否需要 second pass:

  • 如果 primary 已经加载完成:正常返回 primary 组件,不需要 second pass。
  • 如果 primary 加载中或失败:handleError 接收到抛出的 promise,开启 second pass 继续回到 Suspense,调用 updateSuspenseFallbackChildren 方法。相比首次挂载 fallback 属性,这里还额外删除了 Suspense 节点上的 deletions 数组:
workInProgress.deletions = null;

至于为什么要在此处删除 deletions,让我们进入 commit 阶段来分析。

3)commit 阶段: 在上面对于 commitMutationEffectsOnFiber 的分析中,有这样一个方法我们之前没有提到:

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

其中的核心逻辑之一是获取节点上的 deletions 数组,并逐个卸载:

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

此时,如果 updateSuspenseComponent 返回的是 primary (即已经加载完成),则 Suspense 上正在展示的 fallback 组件会被删除,之后渲染 primary 组件。如果返回的是 fallback (加载失败/未完成),由于需要继续展示 fallback 组件先在 second pass 中清除 deletions,才能保证继续渲染 fallback 组件直至 primary 加载完成。

原理总结

Suspense 的整体逻辑十分复杂,细节极多,流程也相对较为曲折。笔者凭借自己的理解总结如下流程图:

源码级解析,搞懂 React 动态加载(中) —— React.lazy + Suspense

  1. React 协调(ReactWorkLoop)过程中,检测到当前 WIP 节点为 Suspense,进入 beginWork 方法中的 updateSuspenseComponent 方法

    • first pass:加载 primary 组件,mountLazyComponent 方法将 primary 组件的 promise 以 Error 的形式抛出
    • ReactWorkLoop 中 catch 语句的 handleError 接到 promise,设置 WIP 节点为 Suspense,并给 Suspense 设置上 DidCapture 标识。
    • second pass:加载 fallback 组件,并给 primary 组件设置加载完成后的回调。
  2. Suspense 访问完毕后,进入 commit 阶段,为 primary 组件加载过程的 promise 增添 retry 函数,使其加载完毕后重新走 Suspense 渲染的逻辑,同时页面渲染 fallback 组件

  3. primary 组件加载完成前,如果触发 Suspense 重新渲染,回到 updateSuspenseComponent 中:

    • first pass:加载 primary 组件,同时将当前的 fallback 加入 Suspense 节点的 deletions 数组待 commit 阶段处理。mountLazyComponent 方法将 primary 组件的 promise 以 Error 的形式抛出。
    • ReactWorkLoop 中 catch 语句的 handleError 接到 promise,设置 WIP 节点为 Suspense,并给 Suspense 设置上 DidCapture 标识。
    • second pass:加载 fallback 组件,并给 primary 组件设置加载完成后的回调。同时删除 Suspense 节点的 deletions 数组,保证 fallback 正常渲染。
    • commit阶段:正常展示 fallback 并重置节点状态。
  4. primary 组件加载完成后,如果触发 Suspense 重新渲染,回到 updateSuspenseComponent 中:

    • first pass:加载 primary 组件,同时将当前的 fallback 加入 Suspense 节点的 deletions 数组待 commit 阶段处理。mountLazyComponent 将加载组件返回。
    • commit阶段:卸载 deletions 数组中的 fallback 组件并渲染 primary。

其实本文仅针对 React.lazy + Suspense 的关键步骤进行了解析,而整体的动态加载技术与 React Fiber 架构密不可分,场景处理和细节逻辑复杂度也非常高。对于更全面的 Suspense 解析与 Fiber 流程分析我在这里埋个坑,以后再填hhhh

在本篇分析中,我们了解了与 React-loadable 完全不同的,通过 React.lazy 抛出错误,再通过 Suspense 的错误处理与 React reconciler 的 workLoop 机制这一工作流。这意味着我们也可以利用 Suspense 进行更多的能力拓展与动态场景探索。

在下一篇分析中,我们将继续探索另一款动态加载方案 @loadable/component ,作为仍在业界流行的方案之一,让我们继续通过源码探究其实现原理。

参考资料 & 文章

Code-Splitting – React.

Suspense for Data Fetching (Experimental) – React.

React 源码解析系列 - React 的 render 异常处理机制

React 源码解析系列 - React 的 render 阶段(一):基本流程介绍

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