• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

React 动态加载 React.lazy + Suspense

武飞扬头像
御伽話
帮助76

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)

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

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

源码角度来看 React lazy 原理

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

ReactLazy

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

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

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 组件解析

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

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

真的是这样吗?

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

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

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 组件进行加载。用一个流程图可以很清晰地表示其中的逻辑:

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

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

阶段4:commit 阶段

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

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

其中 resolveRetryWeakable 方法如下:

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

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

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

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

和首次加载 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 的分析中,有这样一个方法我们之前没有提到:

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

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

原理总结

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 进行更多的能力拓展与动态场景探索。

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanfekee
系列文章
更多 icon
同类精品
更多 icon
继续加载