React Fiber源码笔记(七):双缓存机制

494 阅读6分钟

前言:最近离职准备面试,把之前写的笔记整理一下发出来,本人能力有限,如有错误的地方尽情指正
博客链接:pionpill

对应源码: github.com/facebook/re…

这节讲一下 React 如何明目张胆地偷走你的内存。

Fiber 架构那篇我们讲过,Stack 架构无法中断更新过程,因为在用户界面上出现一个介于新老 DOM 之间的状态时不可行的。但是 Fiber 架构可以将任务放在多个帧里异步执行,这就很奇怪了。Fiber 架构异步操作必然替换数据,但是数据没有显示在真实 DOM 上。中间态藏在你的内存里了!

Fiber 在更新 DOM 时会创建两棵 DOM 树。一棵是页面上显示的,用户正在使用的;另一棵是正在处理的,在异步操作过程中持续更新的。等到新的 DOM 树处理完成,就会替换老的 DOM,所有内容就被一下更新了。这种在内存中构建并直接替换的技术叫做双缓存技术。

双缓存 Fiber 树

我们将在屏幕上显示的 Fiber 树称为 current Fiber 树;在内存中构建的 Fiber 树称为 workInProgress Fiber 树。对应的 Fiber 节点命名相同。

两棵树上 Fiber 节点的 alternate 属性有如下关系:

currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;

React 应用的根节点通过 current 指针在不同 Fiber 树的 rootFiber 间切换来实现 Fiber 树的切换。

workInProgress Fiber 树构建完成后就会交给 Renderer 渲染,当前根节点的指针指向 workInProgress Fiber 树,同时 workInProgress Fiber 树变成 current Fiber 树。

简单说一下在 mount,update 过程中 DOM 的构建与替换流程。

在 React 项目中,我们一般会在入口文件中写一段类似的代码:

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    ........
  </React.StrictMode>
);

首次执行 ReactDomrender 方法会创建 rootFiberNoderootFiber:

  • rootFiberNode: 整个应用的根节点,只会创建一次,且不会被消除。
  • rootFiber: 当前组件树的根节点,会被替换更新。

在我们的浏览器还没有挂载任何 DOM 时,会有如下 Fiber 树结构:

rootFiberNode --current--> rootFiber

此时 rootFiber 还没有任何子节点。当有新的 DOM 结构要被创建时,根据组件返回的 JSX 在内存中依次创建 Fiber 节点并连接成 Fiber 树,此时就会构建一棵 workInProgress Fiber 树。

构建 workInProgress Fiber 树时会复用 current Fiber 树中已有的 Fiber 节点(通过 Diff, 会专门开一章研究 Diff),所以占用的内存也不多。

rootFiberNode --> rootFiber
                      \--alternate--> rootFiber --> App --> div --> ... 

上面当前 rootFiber 指向的 alternate rootFiber 构建完成后就会替换原有的 rootFiber。

在 update 阶段,则会有两棵 Fiber 树:

rootFiberNode --> rootFiber ----> App ----> div ---> ...
                      |            |         |        |
                 altRootFiber -> altApp -> altDic -> alt...

同样在构建完成后替换。

Fiber 树构建过程

Fiber 树的构建起始于两个方法: performSyncWorkOnRootperformConcurrentWorkOnRoot(一个同步一个异步)。

这两个方法都会开启 render 与 commit 流程,虽然同步和异步处理有些许不同,但整体上会调用以下几个核心方法:

  • render 方法: 同步的 renderRootSync 与异步的 renderRootConcurrent
  • commit 方法: 都会调 commitRoot 方法,异步逻辑的 finishConcurrentRender 方法里会调这个方法

同步构建过程

我们先简单看一下 performSyncWorkOnRoot✨约1348行):

export function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes): null {
  // 这里的 executionContext 是指当前执行上下文的标志位
  // RenderContext 与 CommitContext 分别表示渲染和提交上下文
  // 这里用了位运算,判断是否正处于 render 或 commit 状态
  if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
    throw new Error('Should not already be working.');
  }

  // 冲刷(之前没执行完的)被动副作用,这个方法返回一个布尔值
  // 被动副作用是指不会直接触发组件重新渲染的操作,但是会改变组件状态或产生副作用
  // 例如:事件处理程序,订阅函数,定时器回调
  const didFlushPassiveEffects = flushPassiveEffects();
  if (didFlushPassiveEffects) {
    // 进入这里表示还有被动副作用没做完,所以会执行异步任务处理逻辑并跳出去
    ensureRootIsScheduled(root);
    return null;
  }

  // renderRootSync 这个方法很关键,后面细说
  // 这个函数的执行标志着进入 render 阶段
  let exitStatus = renderRootSync(root, lanes);

  // LegacyRoot 表示当前 tag 是否之前尝试过再次 render
  // 这里的逻辑是: 如果首次出错, 则再尝试 render 一次。 
  if (root.tag !== LegacyRoot && exitStatus === RootErrored) {
    const originallyAttemptedLanes = lanes;
    const errorRetryLanes = getLanesToRetrySynchronouslyOnError(
      root,
      originallyAttemptedLanes,
    );
    if (errorRetryLanes !== NoLanes) {
      lanes = errorRetryLanes;
      exitStatus = recoverFromConcurrentError(
        root,
        originallyAttemptedLanes,
        errorRetryLanes,
      );
    }
  }

  // 到这一步, Fiber 树已经渲染完成了
  const finishedWork: Fiber = (root.current.alternate: any);
  root.finishedWork = finishedWork;
  root.finishedLanes = lanes;

  // 进入 commit 阶段
  commitRoot(
    root,
    workInProgressRootRecoverableErrors,
    workInProgressTransitions,
    workInProgressDeferredLane,
  );

  // 这里 render 和 commit 阶段都结束了,再次调这个方法处理异步任务
  ensureRootIsScheduled(root);
  return null;
}

同步逻辑还是比较简单的:

同步构建逻辑

在 react 应用的初始加载过程中,会执行同步渲染模式来创建 FiberTree,因此我们看调用栈时会发现刚进入界面时一般都会有几个长任务。

异步构建过程

看一下 performConcurrentWorkOnRoot✨约870行):

export function performConcurrentWorkOnRoot(
  root: FiberRoot,
  didTimeout: boolean,
): RenderTaskFn | null {
  if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
    throw new Error('Should not already be working.');
  }

  const originalCallbackNode = root.callbackNode;
  const didFlushPassiveEffects = flushPassiveEffects();
  if (didFlushPassiveEffects && root.callbackNode !== originalCallbackNode) {
    return null;
  }

  // 决定下面要做的事
  let lanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
  );
  if (lanes === NoLanes) {
    // 没有要做的事,退出
    return null;
  }

  // 判断是否开启时间切片
  const shouldTimeSlice =
    // 不包含阻塞的 Lane
    !includesBlockingLane(root, lanes) &&
    // 不包含过期的 Lane
    !includesExpiredLane(root, lanes) &&
    // 调度的回调函数未过期
    (disableSchedulerTimeoutInWorkLoop || !didTimeout);

  // 进入同步 render 或异步 render 阶段
  let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    : renderRootSync(root, lanes);

  if (exitStatus !== RootInProgress) {
    let renderWasConcurrent = shouldTimeSlice;
    do {
      if (exitStatus === RootDidNotComplete) {
        // 当前 render 没有完成
        markRootSuspended(root, lanes, NoLane);
      } else {
        // 判断并执行异步逻辑
        const finishedWork: Fiber = (root.current.alternate: any);
        if ( renderWasConcurrent && !isRenderConsistentWithExternalStores(finishedWork) ) {
          exitStatus = renderRootSync(root, lanes);
          renderWasConcurrent = false;
          continue;
        }

        // 现在完成了一棵渲染树, 等待提交
        root.finishedWork = finishedWork;
        root.finishedLanes = lanes;
        finishConcurrentRender(root, exitStatus, finishedWork, lanes);
      }
      break;
    } while (true);
  }

  ensureRootIsScheduled(root);
  return getContinuationForRoot(root, originalCallbackNode);
}

react18 开始默认采用并发渲染模式,但是需要满足以下几个条件:

const shouldTimeSlice =
  // 不包含阻塞的 Lane
  !includesBlockingLane(root, lanes) &&
  // 不包含过期的 Lane
  !includesExpiredLane(root, lanes) &&
  // 调度的回调函数未过期
  (disableSchedulerTimeoutInWorkLoop || !didTimeout);

为了满足这些条件,开发者需要使用并发相关的 hook,否则没法开启时间切片。

然后我们要看一下 finishConcurrentRender 方法,首先了解以下 root 的几个状态:

type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
const RootInProgress = 0; // 正在渲染中
const RootFatalErrored = 1; // 渲染异常
const RootErrored = 2; // 渲染异常
const RootSuspended = 3; // 挂起
const RootSuspendedWithDelay = 4; // 延迟挂起
const RootCompleted = 5; // 默认:Fiber树创建完成
const RootDidNotComplete = 6; // 未完成

然后看一下这个方法(✨约1084行):

function finishConcurrentRender(
  root: FiberRoot,
  exitStatus: RootExitStatus,
  finishedWork: Fiber,
  lanes: Lanes,
) {
  // 只有 Fiber 树创建完成才继续走
  switch (exitStatus) {
    case xxx: 
    case RootCompleted: {
      break;
    }
    default: {
      throw new Error('Unknown root exit status.');
    }
  }
  // 调 commitRoot
  commitRootWhenReady(
    root,
    finishedWork,
    workInProgressRootRecoverableErrors,
    workInProgressTransitions,
    lanes,
    workInProgressDeferredLane,
  );
}

总之 finishConcurrentRender 最后还是会调 commitRoot 方法,只不过会检查一下 root 的状态,判断是否有其他高优先级的任务要处理(代码没贴,有点复杂)。

我们理一下异步构建的流程:

异步构建逻辑