React 源码解析(二):Fiber

262 阅读14分钟

1. 引入:界面是怎么更新的?

上面这个例子出自 React Conf 2017,List 组件渲染 numbers 数组中的元素,当点击按钮时数组中的元素变成原来的平方:

// main.tsx
function Item({ number }: { number: number }) {
  return <div>{number}</div>
}

function List() {
  const [numbers, setNumbers] = useState([1, 2, 3])

  return (
    <>
      <button onClick={() => setNumbers(numbers.map((n) => n * n))}>^2</button>
      {numbers.map((number, index) => (
        <Item key={index} number={number} />
      ))}
    </>
  )
}

createRoot(document.getElementById('root')!).render(<List />)

我们知道,点击按钮时,会通过 setNumbers 更新 state 从而触发 List 组件的重新渲染(rerender),然后经过了一系列的过程,界面发生了更新。

但是从点击按钮到界面更新,这个过程具体发生了什么

从宏观上说,React 的更新过程是这样的:

  1. Scheduler 根据任务的优先级进行调度,高优先级任务优先进入 Reconciler。对于上面的例子,就只有一个任务:点击按钮触发组件更新
  2. render 阶段:Reconciler 对于更新后的状态,得到新的 Fiber 树,并对旧的 Fiber 树和新的 Fiber 树进行对比(diff),为发生了改变的 Fiber 节点打上标记,表示该节点对应的真实 DOM 需要更新。对于上面的例子,需要更新的 Fiber 节点是 List 以及 2 和 3 对应的 Item 和 div(1 对应的 Item 不需要更新是因为 1 的平方还是 1,值没有变化)

  1. commit 阶段:Renderer 根据 Reconciler 生成的 Fiber 树上的标记,对真实 DOM 进行更新

Scheduler 之前有写文章讲过,Renderer 之后再讲,这篇文章主要介绍在 Render 阶段,Reconciler 做了什么。

2. Fiber 架构:Fiber 节点和 Fiber 树

从上面我们知道,在 render 阶段,Reconciler 的主要任务是:

  1. 更新 Fiber 树
  2. 为需要更新对应 DOM 的 Fiber 节点打上标记

在介绍 Reconciler 的具体更新流程之前,我们有必要先了解一下什么是 Fiber 节点和 Fiber 树。尽管听着很复杂,但其实很简单:Fiber 节点就是一个 js 对象,Fiber 树就是 Fiber 节点通过指针连接得到的树。

2.1. Fiber 节点

我们先来看看 Fiber 节点:

// ReactFiber.old.js
function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance
  this.tag = tag;
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null;

  // Fiber
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;

  this.ref = null;

  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;

  this.mode = mode;

  // Effects
  this.flags = NoFlags;
  this.subtreeFlags = NoFlags;
  this.deletions = null;

  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  this.alternate = null;
}

属性很多,但全是很简单的数据结构,只是含义不清楚而已,我们一点点来看:

  1. instance 相关
// 节点类型相关
this.tag = tag;           // 标记 Fiber 类型:函数组件/类组件/原生组件(如div、span...)等
this.key = key;           // React 元素的 key
this.elementType = null;  // 元素类型,大部分情况同 type
this.type = null;         // 具体类型,如函数组件指函数本身,类组件指类本身,原生组件指 DOM 元素标签名
this.stateNode = null;    // Fiber 节点对应的真实 DOM 节点引用

这里比较重要的属性是 type,对于 React 组件,它是组件本身(如函数组件指函数本身),这个我们之后会再提到。

另一个比较重要的属性是 stateNode,它记录了 Fiber 节点对应的真实 DOM 节点引用。之所以要记录真实 DOM,是为了在 commit 阶段能根据需要修改的 Fiber 节点找到对应的 DOM 节点,从而对 DOM 进行更新。

  1. Fiber 树结构
// 链表结构
this.return = null;   // 父 Fiber
this.child = null;    // 第一个子 Fiber
this.sibling = null;  // 下一个兄弟 Fiber
this.index = 0;       // 同级节点的位置索引

我们知道 Fiber 节点通过指针连接,从而形成 Fiber 树。实际上涉及到的指针就是这里的 return、child 和 sibling。我们之后会知道,通过这种链表结构,全局仅需要记录一个 workInProgress 指针,即可实现可中断 & 恢复的架构。

  1. 更新相关信息
// 组件的 props/state
this.pendingProps = pendingProps;   // 新的 props
this.memoizedProps = null;          // 已渲染的 props
this.updateQueue = null;            // 更新队列
this.memoizedState = null;          // 已渲染的 state
this.dependencies = null;           // context/events 依赖

// 优先级相关
this.lanes = NoLanes;          // 当前 Fiber 的优先级
this.childLanes = NoLanes;     // 子树优先级

这部分我们主要关注 pendingProps、memoizedProps 和 updateQueue三个属性。

memoizedProps 和 pendingProps 分别是组件更新前后的 props,通过对比两者是否相同来决定是否需要更新 Fiber节点。

updateQueue 用来存储 Fiber 节点对应的 DOM 节点需要更新的属性以及更新的值,例如文章开头的例子,div2 对应的 updateQueue 就是 ['children', '4'],表示 div 的 children (也就是 innerText)需要更新为 4。

lanes 和 childLanes 两个属性涉及到 Lane 模型,这个之后再介绍,这里不看。

  1. 副作用标记信息
// 标记更新
this.flags = NoFlags;           // 副作用标记
this.subtreeFlags = NoFlags;    // 子树的副作用标记
this.deletions = null;          // 需要删除的子节点

我们之前说过 Reconciler 会为发生更新的 Fiber 节点打上标记,这里的 flags 和 subtreeFlags 属性就是用来标记更新的。我们可以来简单看几个例子:

// ReactFiberFlags.js
export const NoFlags = /*                      */ 0b000000000000000000; // 表示不需要执行任何操作
export const Placement = /*                    */ 0b000000000000000010; // 需要插入
export const Update = /*                       */ 0b000000000000000100; // 需要更新
export const Deletion = /*                     */ 0b000000000000001000; // 需要删除
export const ChildDeletion = /*                */ 0b000000000000010000; // 子节点需要删除
export const ContentReset = /*                 */ 0b000000000000100000; // 文本内容重置
export const Ref = /*                          */ 0b000000000001000000; // ref 更新

我们可以看到 flags 以 0b 开头,表示是二进制数。这种使用位来标记不同状态的方式可以节省内存,操作起来也更高效。比如:

// 检查是否包含某个标记
if (flags & Update) { ... }  // 按位与

// 添加标记
flags |= Update;  // 按位或。例如 Update | ChildDeletion 就表示 Fiber 节点需要更新和删除子节点

// 移除标记
flags &= ~Update;  // 按位与非

通过这种方式,可以方便地为 Fiber 节点打上更新标记(通过按位或 |),也可以在 commit 阶段方便地判断需要对 Fiber 节点对应的 DOM 进行何种更新(通过按位与 &)。

而之所以还需要存储子树的标记 subtreeFlags,是因为这样在 commit 阶段可以快速判断是否需要对某个 Fiber 节点的子树进行更新,从而直接跳过不需要更新的子树,也就是剪枝:

// ReactFiberCommitWork.old.js

// commit 阶段快速判断是否需要处理子树
if (parentFiber.subtreeFlags & MutationMask) {
  // 这个子树有需要处理的副作用
  let child = parentFiber.child;
  while (child !== null) {
    commitMutationEffectsOnFiber(child, root, lanes);
    child = child.sibling;
  }
}

// ReactFiberFlags.js
export const MutationMask =
  Placement |
  Update |
  ChildDeletion |
  ContentReset |
  Ref |
  Hydrating |
  Visibility;

上面这个例子中,只有 subtreeFlags 中包含 MutationMask(也就是包含 Placement、Update、ChildDeletion......中的任意一种标记)的时候,才会遍历子节点并执行 commitMutationEffectsOnFiber,否则就会直接跳过。

很多人写的 Reconciler 文章中会提到 effectTag 和 effectList。effectList 用来在 render 阶段收集所有的副作用,从而避免在 commit 阶段重新遍历整棵 Fiber 树,而是直接从 effectList 中取出副作用并执行更新。

但是后续的 React 代码中修改了这一部分:现在将 effectTag 重命名为了 flags,也删除了 effectList,所以现在 commit 阶段需要遍历整棵 Fiber 树来进行真实 DOM 的更新。

  1. 其他重要属性
this.alternate = null;  // 另一个树对应的 Fiber,下面会讲到
this.mode = mode;       // 渲染模式(同步/并发等)

2.2. Fiber 树

React 使用双缓存的技术更新界面,即存在最多两棵 Fiber 树。当前界面对应的 Fiber 树称为 current,正在内存中构建的称为 workInProgress,它们通过 alternate 属性连接:

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

在创建 Fiber 树时,还会创建 HostRoot(也叫 RootFiber),即整个应用的根 Fiber 节点。对于上面的例子,current 和 workInProcess 的 Fiber 树长这个样子:

接下来我们会基于这个例子讲解 Reconciler 更新 Fiber 树并为 Fiber 节点打上标记的具体流程。

3. Reconciler 的工作流程

3.1. 整体流程

render 阶段的起点是 performSyncWorkOnRoot 或 performConcurrentWorkOnRoot,这取决于本次更新是同步更新还是异步更新,它们分别会调用不同的方法:

// performSyncWorkOnRoot 会调用该方法
function workLoopSync() {
  // 不可中断
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

// performConcurrentWorkOnRoot 会调用该方法
function workLoopConcurrent() {
  // 可中断的 render
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

我们的例子(用户点击事件)是一个同步优先级任务,因此不走 Scheduler 的逻辑,直接调用 workLoopSync 一次全部执行完。

从 workLoopSync 开始,Reconciler 的处理流程如图所示:

该流程是一个递归结构:

  • 递:首先从 HostRoot 开始向下深度优先遍历,为每个节点调用 beginWork 函数。该函数会更新当前节点的子节点。
  • 归:当某个节点没有子节点时,为该节点调用 completeWork,之后对它的第一个 sibling 执行递阶段。如果该节点不存在 sibling,则对它的 return 执行归阶段。

比如对于这个例子:

执行的流程应该是:

1. HostRoot beginWork
2. List beginWork
3. button beginWork # button 没有子节点了,下面执行 button 的 completeWork
4. button completeWork # 之后对它的 sibling 执行 dfs
5. Item1 beginWork
6. div1 beginWork
7. div1 completeWork # div1 没有子节点,也没有 sibling,因此执行 return 的 completeWork
8. Item1 completeWork # 之后对它的 sibling 执行 dfs
9. Item2 beginWork
10. div2 beginWork
11. div2 completeWork
12. Item2 completeWork
13. Item3 beginWork
14. div3 beginWork
15. div3 completeWork
16. Item3 completeWork # Item3 没有子节点和 sibling,回到 return
17. List completeWork
18. HostRoot completeWork

我们可以看到,Reconciler 并没有使用递归,而是通过循环和链表结构,达成了 DFS 遍历 Fiber 树的效果。

3.2. performUnitOfWork

performUnitOfWork 的功能正如函数名一样,是执行一部分的工作,这里的最小单元就是 Fiber 节点:

function performUnitOfWork(unitOfWork: Fiber): void {
  const current = unitOfWork.alternate;

  let next = beginWork(current, unitOfWork, subtreeRenderLanes);

  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }

  ReactCurrentOwner.current = null;
}

在异步更新的情况下,这里会对当前 Fiber 节点执行 beginWork,如果有子节点,则让 workInProgress 指针指向子节点,然后结束,从而可以交出主线程,达到中断 render 的目的。

3.3. beginWork

beginWork 函数的作用是根据传入 Fiber 节点,更新其子节点。

所谓更新子节点,实际上就是要更新子节点的 pendingProps 和 flags。比如第二个 Item Fiber,在更新后 pendingProps 会从 { children: 2 }变为 { children: 4 },flags 会更新为 Update(workInProgress.flags |= Update

为方便理解,我简化了 beginWork 的代码:

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  // 判断是 mount 还是 update
  if (current !== null) {
    // update
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;

    if (oldProps !== newProps) {
      didReceiveUpdate = true;
    } else {
      didReceiveUpdate = false;
    }
  } else {
    // mount
    didReceiveUpdate = false;
  }

  switch (workInProgress.tag) {
    // 处理函数组件
    case FunctionComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
    // 处理原生组件(如 div、button 等 DOM 元素)
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);
    // ...省略其他类型
  }
}

首先通过判断 current 是否为空,判断 work 是 mount 还是 update。如果是 mount 则不需要更新,因此 didReceiveUpdate 置 false;如果是 update,且 props 发生了改变,则需要更新,否则不需要更新。

接着根据当前 Fiber 节点的类型,调用不同的 handler 来更新 Fiber 的子节点。对于我们的例子,我们只需要关心函数组件和原生组件的情况。

实际上,关键的步骤发生在处理 List 时,这里会更新它的子节点(button 和 Item),从而更新 Item 的 pendingProps:

由于 List 是函数组件,会调用 updateFunctionComponent 函数,因此我们着重看一下这个函数:

// ReactFiberBeginWork.old.js
function updateFunctionComponent(
  current,
  workInProgress,
  Component,
  nextProps: any,
  renderLanes,
) {
  let nextChildren = renderWithHooks(
    current,
    workInProgress,
    Component,
    nextProps,
    context,
    renderLanes,
  );

  // 如果不需要更新,则通过 bailout 跳过不必要的工作
  if (current !== null && !didReceiveUpdate) {
    bailoutHooks(current, workInProgress, renderLanes);
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }

  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

我们可以看到 updateFunctionComponent 接受的参数中包含 Component,看回 beginWork,我们发现传入的是函数组件 Fiber 的 type,也就是函数组件的函数,即 List 组件函数。而在 renderWithHooks 函数中,会通过:

let children = Component(props, secondArg);

的方式来调用 List 函数,实现组件的重新渲染,从而更新组件的 state,返回的 React Element 对象存储在 children 中。

在 reconcileChildren 函数中会通过 Diff 算法,将 current 和 workInProgress 进行比较,并根据传入的children React Element,最终得到更新后的子 Fiber 节点。这部分的逻辑较多,因此用一个图简单带过:

reconcileChildren
        │
        ├──> reconcileChildFibers (更新)   
        │         │
        │         ├──> case: REACT_ELEMENT_TYPE
        │         │     └──> reconcileSingleElement
        │         │
        │         ├──> case: REACT_PORTAL_TYPE  
        │         │     └──> reconcileSinglePortal  
        │         │
        │         ├──> case: REACT_LAZY_TYPE
        │         │     └──> recursively call reconcileChildFibers
        │         │
        │         ├──> case: Array
        │         │     └──> reconcileChildrenArray ───┐
        │         │                                    │
        │         └──> case: Iterator                  │
        │               └──> reconcileChildrenIterator │
        │                                              │
        └──> mountChildFibers(First mount)             |
                                                       │
                                                       ▼
                                        处理子节点的核心逻辑:
                                        1. mapRemainingChildren (建立 key map)
                                        2. placeChild (处理位置)
                                        3. createChild (创建新节点)
                                        4. deleteChild (删除旧节点)
                                        5. updateElement (更新已有节点)

reconcileChildFibers 会根据 children 类型不同调用不同的 handler,最常见的是数组类型,会走到 reconcileChildrenArray。reconcileChildrenArray 是最核心的 Diff 算法实现,包含:

  • 新旧节点的对比和复用
  • 节点位置移动的处理
  • 新增和删除节点

至此,beginWork 结束。我们在这部分以 List Fiber 为例,大致讲解了更新 List 的子 Fiber Item 的流程,实际上更新 div Fiber 的流程也是类似的。这部分总体流程如下:

beginWork 
    │
    ├──> 根据 workInProgress.tag 类型选择更新函数
    │
    ├─────────────────┬───────────────┐ 
    ▼                 ▼               ▼
FunctionComponent  HostComponent    其他类型...
    │                 │               │
    │                 │               │              
    ▼                 │               │
renderWithHooks       │               │
    │                 │               │
    ▼                 ▼               ▼
    └────────> reconcileChildren <────┘
                      │
                      ▼
      mountChildFibers/reconcileChildFibers

3.4. completeUnitOfWork

当某个 Fiber 节点没有 child 时,Reconciler 会通过 completeUnitOfWork 完成该 Fiber 节点:

function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork = unitOfWork;
  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;

    if ((completedWork.flags & Incomplete) === NoFlags) {
      let next = completeWork(current, completedWork, subtreeRenderLanes);

      // 如果当前 Fiber 节点产生了新的工作项
      if (next !== null) {
        workInProgress = next;
        return;
      }
    } else {
      // 处理错误恢复,fiber 未能完成渲染时的处理逻辑。这里忽略
    }

    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      // 处理 sibling
      workInProgress = siblingFiber;
      return;
    }
    completedWork = returnFiber;
  } while (completedWork !== null);

  // 已经处理完所有的 Fiber,把 workInProgressRootExitStatus 置为 RootCompleted
  if (workInProgressRootExitStatus === RootInProgress) {
    workInProgressRootExitStatus = RootCompleted;
  }
}

这部分代码的核心逻辑是:

  1. 调用 completeWork 完成该节点
  2. 如果该节点存在 sibling,则把 siblingFiber 赋值给 workInProgress 并返回,从而在下一个 workLoop 中对 sibling 节点调用 beginWork
  3. 如果不存在 sibling,则把 returnFiber 赋值给 completedWork,从而在下一个循环中对 return 节点调用 completeWork
  4. 当所有 Fiber 节点都处理完后,把 workInProgressRootExitStatus 置为 RootCompleted,表示 workInProgress 已经处理完毕,可以执行 commit 阶段

值得注意的是,这部分只有当处理 sibling 或 Fiber 节点产生新的工作项时才会返回,其余情况都会一直执行直到循环结束,从而也就无法中断。

3.5. completeWork

completeWork 函数会根据 workInProgress 的 tag 来调用不同的处理函数,这里仅以原生组件 HostComponent 为例,其余代码省略:

// ReactFiberCompleteWork.old.js
function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  const newProps = workInProgress.pendingProps;
  popTreeContext(workInProgress);

  switch (workInProgress.tag) {
    case HostComponent: {
      popHostContext(workInProgress);
      const rootContainerInstance = getRootHostContainer();
      const type = workInProgress.type;
      if (current !== null && workInProgress.stateNode != null) {
        // 真实 DOM 节点存在,根据 pendingProps 更新
        updateHostComponent(
          current,
          workInProgress,
          type,
          newProps,
          rootContainerInstance,
        );

        if (current.ref !== workInProgress.ref) {
          markRef(workInProgress);
        }
      } else {
        // 不存在 stateNode,创建对应的 DOM 实例
        const currentHostContext = getHostContext();
        const instance = createInstance(
            type,
            newProps,
            rootContainerInstance,
            currentHostContext,
            workInProgress,
          );

        appendAllChildren(instance, workInProgress, false, false);

        workInProgress.stateNode = instance;
      }
      // 属性冒泡
      bubbleProperties(workInProgress);
      return null;
    }
    // ...其他类型省略
  }
}

在 HostComponent 的处理逻辑中,首先会判断 Fiber 对应的真实 DOM 节点是否存在:

  • 如果不存在则创建对应的 DOM 实例,并通过 appendAllChildren 将当前 Fiber 节点的所有子节点对应的 DOM 节点附加到新创建的父 DOM 节点 instance 上
  • 如果存在则调用 updateHostComponent,根据 pendingProps(newProps)进行更新:
updateHostComponent = function(
  current: Fiber,
  workInProgress: Fiber,
  type: Type,
  newProps: Props,
  rootContainerInstance: Container,
) {
  const oldProps = current.memoizedProps;

  if (oldProps === newProps) {
    // props 没有发生改变,跳过更新
    return;
  }

  const instance: Instance = workInProgress.stateNode;
  const currentHostContext = getHostContext();
  
  const updatePayload = prepareUpdate(
    instance,
    type,
    oldProps, // 例: { children: 2 }
    newProps, // 例: { children: 4 }
    rootContainerInstance,
    currentHostContext,
  );

  // 返回的 updatePayload: [ 'children', '4' ]

  workInProgress.updateQueue = (updatePayload: any);
  if (updatePayload) {
    markUpdate(workInProgress);
  }
};

updateHostComponent 函数会根据新旧 props 进行对比,生成需要更改的信息 updatePayload,并将其存储在 Fiber 节点的 updateQueue 中,从而为 commit 阶段提供更新的信息。

最后我们来看一下 bubbleProperties,这个函数是用来进行属性冒泡的,其核心逻辑如下:

// ReactFiberCompleteWork.old.js
let child = completedWork.child;
while (child !== null) {
  subtreeFlags |= child.subtreeFlags;
  subtreeFlags |= child.flags;

  child.return = completedWork;

  child = child.sibling;
}

completedWork.subtreeFlags |= subtreeFlags;

这段代码会对 completedWork Fiber 的子节点的 flags 属性进行冒泡,父 Fiber 会收集所有子节点的 flags 以及它们的子树的 flags subtreeFlags。通过这种方式,可以在 commit 阶段对遍历 Fiber 树的过程进行剪枝,从而加快更新真实 DOM 的过程。

4. 总结

对于我们文章开头给出的例子,从点击按钮到界面更新,Reconciler 做了以下事情:

  1. 检测到 setNumbers 触发的状态更新,进入 workLoop
  2. 通过 beginWork 遍历 Fiber 树,更新 Fiber 节点,为需要更新的节点打上标记,并把待更新的 props 作为 pendingProps
  3. 通过 completeWork 对比新旧 props,将更新内容存储在 updateQueue 中,并将属性冒泡至父节点

当这些内容都执行结束后,将带有更新标记的 Fiber 节点交给 commit 阶段,最终更新真实 DOM,实现界面更新。