Suspense的介绍和原理(上篇)

Suspense的介绍和原理

仓库地址 本节代码分支

系列文章:

  1. React实现系列一 - jsx
  2. 剖析React系列二-reconciler
  3. 剖析React系列三-打标记
  4. 剖析React系列四-commit
  5. 剖析React系列五-update流程
  6. 剖析React系列六-dispatch update流程
  7. 剖析React系列七-事件系统
  8. 剖析React系列八-同级节点diff
  9. 剖析React系列九-Fragment的部分实现逻辑
  10. 剖析React系列十- 调度<合并更新、优先级>
  11. 剖析React系列十一- useEffect的实现原理
  12. 剖析React系列十二-调度器的实现
  13. 剖析React系列十三-react调度
  14. useTransition的实现
  15. useRef的实现

Suspense介绍

suspense是React 16.6新出的一个功能,用于异步加载组件,可以让组件在等待异步加载的时候,渲染一些fallback的内容,让用户有更好的体验。

这一章节可以让我们了解基本的Suspense的实现原理。

Suspense的基本使用

1. 异步加载组件

下面我们使用lazy加载OtherComponent组件,当组件数据加载完成后,渲染OtherComponent组件,如果加载过程中,我们可以渲染Loading...的内容。

js 复制代码
const OtherComponent = React.lazy(() => import('./OtherComponent'));

function App() {
    return (
        <div>
        <Suspense fallback={<div>Loading...</div>}>
            <OtherComponent />
        </Suspense>
        </div>
    );
}        

由于OtherComponent组件中,数据需要异步加载,所以在数据加载完成之前,我们需要一个界面去表示正在加载数据的过程,这个界面就是fallback

Suspense的原理

在了解实现原理之前,首先来看看,我们需要实现的例子内容。

正常情况下,React会有很多场景使用到Suspense,我们不可能全部都实现。比如:

  1. Razy组件懒加载
  2. transition fallback
  3. use的hook

新版的React文档中的一些demo是通过usehook实现的,由于我们之前已经实现了hook的逻辑部分。所以我们就实现一下如下demo的原理。

javascript 复制代码
function App() {
  const [num, setNum] = useState(0);
  return (
    <div>
      <button onClick={() => setNum(num + 1)}>change id: {num}</button>
      <Suspense fallback={<div>loading...</div>}>
        <Cpn id={num} timeout={2000} />
      </Suspense>
    </div>
  );
}

export function Cpn({ id, timeout }) {
  const [num, updateNum] = useState(0);
  const { data } = use(fetchData(id, timeout));

  if (num !== 0 && num % 5 === 0) {
    cachePool[id] = null;
  }
  return (
    <ul onClick={() => updateNum(num + 1)}>
      <li>ID: {id}</li>
      <li>随机数: {data}</li>
      <li>状态: {num}</li>
    </ul>
  );
}

这里列举了AppCpn2个部分内容,我们要实现子组件使用use包裹一个Promise的请求,然后被挂起,展示fallback的内容,等待请求完成后,再渲染子组件。

整体流程

在深入细节之前,我们先了解一下整个suspense的执行流程。

  1. beginWork是进入上述4种情况的任意一种
  2. completeWork是对比current Offscreen mode wip Offscreen mode,如果发现下面的情况,就需要标记Visibility effectTag, 用于commit的隐藏和显示操作:
    • modehidden 变为 visible
    • modevisible 变成 mode
    • current === null && hidden`
  3. commitWork时处理visibility effectTag

上面是一个整体的流程说明。 如果回到我们要分析的例子,它的大致流程是这样的:

  1. 首先进入正常的render流程(默认首次进入Cpn渲染)
  2. 遇到use执行,抛出错误,unwind进入suspense节点,进入挂起状态
  3. 进入挂起流程对应的render
  4. 进入挂起流程对应的commit阶段(渲染loading
  5. 请求返回后,进入正常流程对应的render阶段
  6. 进入正常流程对应commit阶段(渲染Cpn

suspense的细节

在我们平时使用suspense的时候,有一些细节方面,不晓得大家有没有注意到。

  1. 首次渲染的时候,React在请求回到之前是直接渲染fallback中的内容。并不会渲染子组件的内容。下面一段话,来自官方文档,在请求返回之前,子组件不会有任何状态被保存。

React does not preserve any state for renders that got suspended before they were able to mount for the first time. When the component has loaded, React will retry rendering the suspended tree from scratch.

  1. 渲染子组件后,如果我们再次loading中,渲染后的子组件并不会被销毁,而是通过css样式隐藏。
  2. 当子组件再次渲染的时候,它原来的状态是可以保留的。比如上面的例子,我们点击change id按钮,虽然重新渲染了,但是子组件的num状态是保留的, 一直都是数字8。

如果我们按照正常的一个Child组件来实现的话。在挂起的时候渲染fallback, 在正常的时候渲染Cpn组件。这样的话,不能满足我们的第二、第三点。

它不能够保存状态,所以不适合我们的Suspense实现。

suspense的fiber结构

为了保留相应的状态,实现css控制隐藏元素,我们肯定是需要把fallback子组件cpn都渲染到fiber树上的。这样我们就可以通过css样式来控制隐藏和显示。

同时也可以保存状态。所以如果只有一个Child-fiber是肯定不行的。

react中是如下的结构来标识suspensefiber tree

新增了一种类型Offscreen, 用于标识真正的子元素的显示和隐藏。

整体流程类似这样

  1. 初始化渲染的时候,由于children组件的数据没有返回,所以Offscreenmode设置为hidden
  2. 通过sibling渲染Fragment的结构。当数据返回后,再次开始调度,重新渲染Offscreen
  3. 如果发生子组件更新渲染行为,会再次走挂起流程,等待数据返回后,再次更新内容。

所以在beginWork开始调和的时候,我们需要分4种情况进行处理:

  • 初始化(mount)

    1. 挂起流程 mountSuspenseFallbackChildren
    2. 正常流程 mountSuspensePrimaryChildren
  • 更新(update)

    1. 挂起流程 updateSuspenseFallbackChildren
    2. 正常流程 updateSuspensePrimaryChildren

beginWork流程

从上面的fiber结构图来看,在调和阶段,针对SuspenseComponent的类型,我们需要区分mountupdate的情况。

javascript 复制代码
function beginWork = (wip: FiberNode, renderLane: Lane) => {
    // 针对suspense的处理
    case SuspenseComponent:
      return updateSuspenseComponent(wip);
}

主要逻辑都在updateSuspenseComponent中,我们来分析一下它的具体内容。

  1. 整体是做一个整合,根据wip.alternate来判断是否为mount或者update
  2. 根据showFallback判断是否需要展示fragment -> fallback的分支
javascript 复制代码
function updateSuspenseComponent(wip: FiberNode) {
  const current = wip.alternate;
  const nextProps = wip.pendingProps;

  let showFallback = false; // 是否显示fallback
  const didSuspend = (wip.flags & DidCapture) !== NoFlags; // 是否挂起
  if (didSuspend) {
    // 显示fallback
    showFallback = true;
    wip.flags &= ~DidCapture; // 清除DidCapture
  }

  const nextPrimaryChildren = nextProps.children; // 主渲染的内容
  const nextFallbackChildren = nextProps.fallback;

  pushSuspenseHandler(wip);

  if (current === null) {
    // mount
    if (showFallback) {
      // 挂起
      return mountSuspenseFallbackChildren(
        wip,
        nextPrimaryChildren,
        nextFallbackChildren
      );
    } else {
      // 正常
      return mountSuspensePrimaryChildren(wip, nextPrimaryChildren);
    }
  } else {
    // update
    if (showFallback) {
      // 挂起
      return updateSuspenseFallbackChildren(
        wip,
        nextPrimaryChildren,
        nextFallbackChildren
      );
    } else {
      // 正常
      return updateSuspensePrimaryChildren(wip, nextPrimaryChildren);
    }
  }
}

首次进入mountSuspensePrimaryChildren

在开始beginWork到达suspense的时候,首次进入的时候showFallback = false。我们会进入正常的调和流程mountSuspensePrimaryChildren

我们看看具体的mountSuspensePrimaryChildren的执行。

javascript 复制代码
/**
 * 正常流程的mount阶段
 * @param wip
 * @param primaryChildren
 */
function mountSuspensePrimaryChildren(wip: FiberNode, primaryChildren: any) {
  const primaryChildProps: OffscreenProps = {
    mode: "visible",
    children: primaryChildren,
  };
  const primaryChildFragment = createFiberFromOffscreen(primaryChildProps);

  wip.child = primaryChildFragment;
  primaryChildFragment.return = wip;

  return primaryChildFragment;
}
  1. 将真正需要渲染的子组件(cpn) primaryChildren作为offscreen fiber子fiber。同时赋值mode = visible
  2. 需要创建一个offscreen对应的fiber。 并将suspense.child指向创建的offscreen

然后我们继续调和到offscreenupdateOffscreenComponent就是常规的调和子ReactElement, 当执行到primaryChildren(cpn)的时候,进入函数组件(cpn)的执行。

当执行到hook use的时候,我们来分析一下如何运行的

函数组件的use的hook

use接受1个参数,类型为Thenable或者ReactContext。我们主要是探讨当接收到一个promise的时候,内部应该是怎么处理。

javascript 复制代码
/**
 * use hook (接受promise / context)
 */
function use<T>(usable: Usable<T>): T {
  if (usable !== null && typeof usable === "object") {
    if (typeof (usable as Thenable<T>).then === "function") {
      // thenable
      const thenable = usable as Thenable<T>;
      return trackUsedThenable(thenable);
    } else if ((usable as ReactContext<T>).$$typeof === REACT_CONTEXT_TYPE) {
      // context
      const context = usable as ReactContext<T>;
      return readContext(context);
    }
  }
  throw new Error("不支持的use参数");
}

如果是一个promise,会执行trackUsedThenable的逻辑。

trackUsedThenable主要是用于包装接受的promise

  1. 新增一个状态status (包含pendingrejected, fulfilled
  2. 添加请求返回后的,then的逻辑,等promise处理后,变更status的值。
  3. 将包装后的promise(thenable)进行包装,提供全局获取的方式(赋值suspendedThenable
  4. 抛出一个错误,中断当前的beginWork的调和流程
javascript 复制代码
export function trackUsedThenable<T>(thenable: Thenable<T>) {
  switch (thenable.status) {
    case "fulfilled":
      return thenable.value;
    case "rejected":
      throw thenable.reason;
    default:
      if (typeof thenable.status === "string") {
        thenable.then(noop, noop);
      } else {
        // untracked

        //pending
        const pending = thenable as unknown as PendingThenable<T, void, any>;
        pending.status = "pending";
        pending.then(
          (val) => {
            if (pending.status === "pending") {
              // @ts-ignore
              const fulfilled: FulfilledThenable<T, void, any> = pending;
              fulfilled.status = "fulfilled";
              fulfilled.value = val;
            }
          },
          (err) => {
            // @ts-ignore
            const rejected: RejectedThenable<T, void, any> = pending;
            rejected.status = "rejected";
            rejected.reason = err;
          }
        );
      }
      break;
  }
  suspendedThenable = thenable;
  throw SuspenseException;
}

这里我们抛出了一个错误,去中断当前的调和过程。

处理抛出的错误

在之前的章节中,我们讲过workLoop文件的renderRoot递归中,有try catch的错误处理。

javascript 复制代码
/**
 * 并发和同步更新的入口(render阶段)
 * @param root
 * @param lane
 * @param shouldTimeSlice
 */
function renderRoot(root: FiberRootNode, lane: Lane, shouldTimeSlice: boolean) {
  do {
    try {
      if (wipSuspendedReason !== NotSuspended && workInProgress !== null) {
        // 有错误,进入unwind流程
        const throwValue = wipThrowValue;
        wipSuspendedReason = NotSuspended;
        wipThrowValue = null;
        // unwind操作
        throwAndUnwindWorkLoop(root, workInProgress, throwValue, lane);
      }
      shouldTimeSlice ? workLoopConcurrent() : workLoopSync();
      break;
    } catch (e) {
      handleThrow(root, e);
    }
  } while (true);
}

所以会进入handleThrow的错误处理流程。

javascript 复制代码
function handleThrow(root: FiberRootNode, throwValue: any) {
  // 是suspense抛出的错误
  if (throwValue === SuspenseException) { 
    throwValue = getSuspenseThenable();
    wipSuspendedReason = SuspendedOnData;
  }
  wipThrowValue = throwValue;
}
  1. 标记wipSuspendedReason的原因是suspense
  2. 获取trackUsedThenable中包裹的suspendedThenable,并将其赋值给wipThrowValue

处理后,会继续执行renderRoor的递归流程。由于wipSuspendedReason的赋值,所以会执行到throwAndUnwindWorkLoop函数。

throwAndUnwindWorkLoop流程的处理

throwAndUnwindWorkLoop它接受根节点、当前的fiber、包装后的thenable、优先级lane

javascript 复制代码
/**
 * unWind流程的具体操作
 * @param root
 * @param unitOfWork  当前的fiberNode(抛出错误的位置)
 * @param thrownValue 请求的promise
 * @param lane
 */
function throwAndUnwindWorkLoop(
  root: FiberRootNode,
  unitOfWork: FiberNode,
  thrownValue: any,
  lane: Lane
) {
  // 重置FC 的全局变量
  resetHookOnWind();
  // 请求返回后重新触发更新
  throwException(root, thrownValue, lane);
  // unwind
  unwindUnitOfWork(unitOfWork);
}

它主要是分了三个函数分别处理以下内容:

  1. 由于中断了渲染,需要重置一些函数组件的变动。
  2. 请求返回后重新触发更新
  3. unwind流程

请求返回后触发更新

思考我们在use的请求返回后,我们肯定是需要重新开始调度的。

类似于信号一样,它请求返回后,需要ping下告诉我,我应该开始新的调度,用于渲染返回后的数据,展示给用户真正的内容。

throwException函数的主要逻辑就是进行相应的逻辑处理。但是还有一些额外的缓存处理,比如重复的请求只需要ping一次,这个不是我们关心的重点

javascript 复制代码
export function throwException(root: FiberRootNode, value: any, lane: Lane) {
  // Error Boundary

  // thenable
  if (
    value !== null &&
    typeof value === "object" &&
    typeof value.then === "function"
  ) {
    const wakeable: Wakeable<any> = value;

    const suspenseBoundary = getSuspenseHandler();
    if (suspenseBoundary) {
      suspenseBoundary.flags |= ShouldCapture;
    }

    attachPingListener(root, wakeable, lane);
  }
}

同时需要获取最近的suspense fiber。同时将它标记为ShouldCapture (render阶段,捕获到一些东西(Error Bound / 抛出的挂载的数据)),ShouldCapture标记会在unwind中用于找到对应的suspense fiber

attachPingListener的主要逻辑是用于请求返回后,开始新的调度。

我简化后的代码如下:

javascript 复制代码
function attachPingListener(
  root: FiberRootNode,
  wakeable: Wakeable<any>,
  lane: Lane
) {
    function ping() {
      // fiberRootNode
      markRootPinged(root, lane);
      markRootUpdated(root, lane);
      ensureRootIsScheduled(root); // 开启新的调度
    }

    wakeable.then(ping, ping);
  }
}

unwind流程

在之前我们已经学习到了2种的流程,现在多了一种unwind流程。

  • beginWork:往下深度优先遍历 (
  • completeWork:往上深度优先遍历(到rootfiber
  • unwind:往上遍历祖辈

涉及到unwind流程主要是有2种:

  • Suspense
  • Error Boundary 我们现在只讨论suspense, 它主要是执行usefiber(抛出错误的fiberNode位置)向上递归到最近的suspense fiber
javascript 复制代码
/**
 * 一直向上查找,找到距离它最近的Suspense fiberNode
 * @param unitOfWork
 */
function unwindUnitOfWork(unitOfWork: FiberNode) {
  let incompleteWork: FiberNode | null = unitOfWork;

  // 查找最近的suspense
  do {
    const next = unwindWork(incompleteWork);
    if (next !== null) {
      workInProgress = next;
      return;
    }

    const returnFiber = incompleteWork.return as FiberNode;
    if (returnFiber !== null) {
      returnFiber.deletions = null;
    }
    incompleteWork = returnFiber;
  } while (incompleteWork !== null);

  // 使用了use 但是没有定义suspense -> 到了root
  wipRootExitStatus = RootDidNotComplete;
  workInProgress = null;
}
php 复制代码
/**
 * unwind的每一个fiberNode 的具体操作
 * @param wip
 */
export function unwindWork(wip: FiberNode) {
  const flags = wip.flags;
  switch (wip.tag) {
    case SuspenseComponent:
      popSuspenseHandler();
      if (
        (flags & ShouldCapture) !== NoFlags &&
        (flags & DidCapture) === NoFlags
      ) {
        // 找到了距离我们最近的suspense
        wip.flags = (flags & ~ShouldCapture) | DidCapture; // 移除ShouldCapture、 添加DidCapture
        return wip;
      }
      return null;
      break;
    case ContextProvider:
      const context = wip.type._context;
      popProvider(context);
      return null;
    default:
      return null;
  }
}

经过unwind的流程后,将workInProgress = suspense fiber , 这样继续执行renderRoot的时候,会回溯到suspense fiber再次进行调和。

同时我们将当前的suspense fiber.flagsShouldCapture的改成DidCapture, 确定是需要进行了回溯unwind流程

小结

经过一系列的操作后,我们又开始从suspense fiber开始beginWork进行调和。但是有一些flag的变化

  1. 新增了DidCapture,标记是因为unwind回到的suspense fiber

重新调和suspense

再次执行updateSuspenseComponent的情况下,由于DidCapture的存在,所以会执行到showFallback = true,然后执行我们渲染fallback的逻辑。mountSuspenseFallbackChildren

渲染fallbak的执行

mountSuspenseFallbackChildren的逻辑主要是如下:

  1. 生成fragment -> fallbackfiber。并将offscreensibling指向fragment
  2. 重点 是返回了 fallbackChildFragment, 但是suspense.child指向primaryChildFragment(子元素)
javascript 复制代码
/**
 * 挂起状态的mount阶段
 * @param wip
 * @param primaryChildren
 * @param fallbackChildren
 */
function mountSuspenseFallbackChildren(
  wip: FiberNode,
  primaryChildren: any,
  fallbackChildren: any
) {
  const primaryChildProps: OffscreenProps = {
    mode: "hidden",
    children: primaryChildren,
  };
  const primaryChildFragment = createFiberFromOffscreen(primaryChildProps);
  const fallbackChildFragment = createFiberFromFragment(fallbackChildren, null);


  primaryChildFragment.return = wip;
  fallbackChildFragment.return = wip;
  primaryChildFragment.sibling = fallbackChildFragment;
  wip.child = primaryChildFragment;

  return fallbackChildFragment;
}

大致的fiber结构如下图:

mount的时候由于离屏Dom, 不需要我们标记Placement, 也会被插入到页面中渲染。

这样继续调和后,达到completeWork -> commit之后就会渲染fallback的内容到界面之中。

至此,在数据没有返回的时候,我们的页面渲染就完成了。

由于篇幅过长,等数据请求回来后,当我们监听到数据返回重新渲染的逻辑,我们下一篇文章介绍,。

相关推荐
EricWang135810 分钟前
[OS] 项目三-2-proc.c: exit(int status)
服务器·c语言·前端
September_ning10 分钟前
React.lazy() 懒加载
前端·react.js·前端框架
web行路人20 分钟前
React中类组件和函数组件的理解和区别
前端·javascript·react.js·前端框架
番茄小酱00121 分钟前
Expo|ReactNative 中实现扫描二维码功能
javascript·react native·react.js
超雄代码狂42 分钟前
ajax关于axios库的运用小案例
前端·javascript·ajax
长弓三石1 小时前
鸿蒙网络编程系列44-仓颉版HttpRequest上传文件示例
前端·网络·华为·harmonyos·鸿蒙
小马哥编程1 小时前
【前端基础】CSS基础
前端·css
嚣张农民1 小时前
推荐3个实用的760°全景框架
前端·vue.js·程序员
周亚鑫1 小时前
vue3 pdf base64转成文件流打开
前端·javascript·pdf
Justinc.2 小时前
CSS3新增边框属性(五)
前端·css·css3