Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fiber Reconciler #11

Open
bouquetrender opened this issue Dec 23, 2021 · 0 comments
Open

Fiber Reconciler #11

bouquetrender opened this issue Dec 23, 2021 · 0 comments
Labels

Comments

@bouquetrender
Copy link
Owner

bouquetrender commented Dec 23, 2021

在 React 中,Reconciler 是 React 内部的一个模块,负责 reconciliation(协调)的部分,也就是比较新旧组件树,找出差异并进行相应更新的过程。而 Fiber 则是全新的可中断,增量式的架构,用于实现协调过程,这使得视图可以在不销毁和重建组件实例的情况下重新渲染。

React 会创建一颗虚拟 DOM 树(virtual DOM),作为真实 DOM 树的副本。在界面更新之前,React 会递归比较两棵树的每一个节点,得出变更结论,再进行界面渲染。

React 16 版本之前,内部是利用 Stack Reconciler(堆栈协调)去递归遍历组件树对比差异,diff 过程无法中断,如果组件树层级过深,同时运行其他任务操作的时候,主线程会被阻塞,此刻如果界面有布局改变、交互动画等等渲染,用户就会察觉到卡顿,于是 React 16 引入了新的 Fiber 架构进行增量渲染。

Fiber 架构中引入了新的数据结构 fiber 节点,这是一个单链表结构,fiber 节点树根据 React 元素树生成,并用来驱动真实 DOM 的渲染。

Scheduler(调度器)是协调的一部分,负责管理任务优先级和执行顺序的模块,会将 diff 递归任务拆分成 chunk 块,每个任务都有优先级标记,这样即使线程中有一个长时间执行的渲染任务,也可以暂停终止,并会执行其他优先级更高的任务,这个过程称为 Scheduling 调度。优先级高的任务完成后,再继续恢复原来的任务执行。

由于 Fiber 调度方式可使任务暂停、终止和复用的特殊性,组件渲染有会被中断的情况,一旦生命周期被中断后,render 以及之前生命周期函数会被再次调用,所以 shouldComponentUpdate 不应该写 side effects 代码,例如修改全局变量、传参、DOM,发起请求等等。

当 ReactDOM.render 启动或调用 setState() 的时候开始创建/更新 fiber 树,并走初始渲染/更新渲染流程。

初始渲染

以下描述,大写 F 的 Fiber 指的是协调器本身,小写 f 的 fiber 指的是一个工作单位(the basic unit of work)。

初始渲染,Fiber 会创建一个根 fiber 节点名为 HostRoot,然后从根 React 元素开始遍历,并为其创建一个 fiber 节点,接着向下找到子节点,为子元素创建 fiber 节点。持续下去直到最后一个子元素,然后检查子元素是否有兄弟节点,如果有就遍历兄弟元素,再到兄弟元素的子元素,如果没有兄弟节点就会返回到父节点。

假设 JSX 代码是这样的:

function App() { 
  return (
    <div className="wrapper"> 
      <div className="list"> 
        <span className="content">content 1</span> 
        <span className="content">content 2</span> 
      </div>
      <div className="btn">
        <button className="add">Add</button>
      </div>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById('root'));  // HostRoot

那么最终的 fiber tree 就是下图的链表结构:

fiber-tree

一个基本的 fiber 节点对象结构如下:

export type Fiber = {
    // 标识 Fiber 类型的标签。
    tag: TypeOfWork,
 
    // 该子级的唯一标识符。
    key: null | string,
 
    // 用于在协调(reconciliation)时保留该子级的身份的 element.type 的值。
    elementType: any,
 
    // 与该 Fiber 相关联的已解析的函数/类。
    type: any,
 
    // 与该 Fiber 相关联的本地状态。
    stateNode: any,
 
    // 剩余字段属于 Fiber 对象
 
    // 在处理完该 Fiber 后要返回的 Fiber。
    // 在概念上相当于父级。
    // 它本质上相当于栈帧的返回地址。
    return: Fiber | null,
 
    // 单链表树结构。
    child: Fiber | null,
    sibling: Fiber | null,
    index: number,
 
    // 用于附加此节点的最后一个 ref。
    ref: null | (((handle: mixed) => void) & {_stringRef: ?string, ...}) | RefObject,
 
    // Input 是传入以处理该 Fiber 的数据。参数。Props。
    pendingProps: any, // 当我们重载标签时,此类型将更具体。
    memoizedProps: any, // 用于创建输出的 props。
 
    // 一个状态更新和回调的队列。
    updateQueue: mixed,
 
    // 用于创建输出的状态
    memoizedState: any,
 
    mode: TypeOfMode,
 
    // Effect
    effectTag: SideEffectTag,
    subtreeTag: SubtreeTag,
    deletions: Array<Fiber> | null,
 
    // 单链表快捷访问具有副作用的下一个 Fiber。
    nextEffect: Fiber | null,
 
    // 该子树中具有副作用的第一个和最后一个 Fiber。
    // 这样当我们在该 Fiber 内部重用工作时,可以重用链表的一部分。
    firstEffect: Fiber | null,
    lastEffect: Fiber | null,
 
    // 这是一个池化版本的 Fiber。每个更新的 Fiber 最终会有一个成对出现的 Fiber。
    // 在需要时,我们可以清理成对以节省内存。
    alternate: Fiber | null,
};

多个 fiber 节点通过自身类型定义里 return、child、sibling 的指向,形成链表关系,并最终生成 current tree。fiber 节点的结构和详细说明可以在React 源码中 type 类型定义找到。

这是对初始渲染的简单描述,在了解更新渲染之前,首先要了解 requestAnimationFrame。

requestAnimationFrame(rAF)

在 Fiber 中,rAF 负责优先级高的函数在下一个动画帧(before the next animation frame)之前调用。

当我们在调用 window.requestAnimationFrame() 是为了让浏览器执行一个动画,并且要求浏览器在下次重绘之前,调用传入的回调函数去更新动画,回调函数会在浏览器下一次重绘之前执行。

如果屏幕刷新率为 60Hz,那么每执行一次 rAF,一帧的时间则是 16.6ms。每一帧的生命周期里,都包含了 Input Events 用户交互行为、JS 执行、rAF 调用,Layout 布局计算以及 Paint 重绘。

frame-lifecycle

从生命周期可得知,rAF 的回调时机会在 Layout 布局计算之前调用。

由于用户切到其他标签页的情况下 rAF 会暂停调用,所以 Fiber 做了一个 polyfill,如果页面失焦则 setTimeout 会替代 rAF 的工作,页面聚焦后再通过 rAF 取消 setTimeout。

被弃用的 requestIdleCallback(rIC)

在未被弃用前,rIC 主要是负责优先级低或者非必要的函数任务调用,如果一帧在 16ms 内已经执行完了当前任务,在帧结束时之前的空闲时间则会调用优先级低的任务。

以下 rIC 的用法,在代码段中 lowPriorityWork 是一个回调函数,将在帧结束时的空闲时间内被调用。

function lowPriorityWork(deadline) {
  while (deadline.timeRemaining() > 0 && workList.length > 0)
    performUnitOfWork();

  if (workList.length > 0) requestIdleCallback(lowPriorityWork);
}

当 lowPriorityWork 被调用时候,会传入一个 deadline 对象,对象的 timeRemaining 函数返回最近的剩余空闲时间,如果大于 0 则执行 performUnitOfWork 去做一些优先级更高的工作。如果没有空闲时间则在下一帧继续调用 lowPriorityWork。

被弃用的原因,React 核心开发者 Dan 解释 因为 rIC 触发过晚导致浪费 CPU 时间,所以改用了 5ms 一次性的循环。我在网上查阅相关资料发现一个 github issues 其中提到 rIC 一秒只会调用 20 次,这远远低于 rAF 的调用次数。

requestIdleCallback is called only 20 times per second - Chrome on my 6x2 core Linux machine, it's not really useful for UI work. —— from Releasing Suspense

更新渲染

回到更新,对于每次更新,Fiber 都会创建 workInProgress tree,这就是 diff 的过程。与初始渲染不同,Fiber 不会再次为每个元素创建新的 fiber 节点,而是根据 current tree 建立 workInProgress tree。Fiber 从 current tree 根部开始遍历,处理每个 fiber。

current_workInProgress

fiber 工作单位的分配优先级,以及对 fiber 进行暂停、终止和复用是由 Scheduler 模块负责的,以下是所有优先级的定义:

  • NoWork 0 没有待处理的工作
  • SynchronousPriority 1 文本输入
  • TaskPriority 2 需要在当前调度结束时完成
  • AnimationPriority 3 需要在下一帧之前完成
  • HighPriority 4 需要尽快完成的交互
  • LowPriority 5 数据更新
  • OffscreenPriority 6 不会显示但以防将来会显示的任务

目前是可中断的协调阶段(reconciliation phase),协调阶段的结果除了会产生 workInProgress tree 外还会产生一个 Effect List。effect 指的是变更操作,像是对宿主组件进行插入、更新或者删除,调用类组件节点的生命周期方法。这些 Fiber Node 会被标记上一个 effect tag。

协调阶段对于用户来说是不可见的,这些都是 Fiber 背后的工作。这个阶段结束后,Fiber 就会准备提交更新,进入到下一个用户可见且不可中断的阶段:提交阶段(commit phase)。

在提交阶段,workInProgress tree 会成为 current tree,React 遍历 Effect List 并一次性更新到 DOM 上。以上就是虚拟树 diff 过程中对 Fiber Reconciler 的基本阐述。

Hooks (并非本篇重点)

React 是 16.8 版本中引入了一个新的特性,这里写一下 Hooks 的原理和与 Fiber 的配合。

export type Hook = {
  memoizedState: any,
  baseState: any,
  baseQueue: Update<any, any> | null,
  queue: any,
  next: Hook | null,
};

源码 中可以看到React 对 hooks 的管理和定义是一个链表结构,按函数组件中 hooks 的执行顺序依次将 hooks 节点添加到链表中。

每一个 hooks 都会有 mount 和 update 阶段,分别对应着源码中 HooksDispatcherOnMountHooksDispatcherOnUpdate 的定义。在 mount 阶段就会调用 mountWorkInProgressHook 方法将当前 hooks 赋值给 hooks 链表的末尾 workInProgressHook.next = hook

useState 和 useReducer 的 hooks 节点会有一个 queue 循环链表用来记录更新。

每次调用 dispatchAction 方法(也就是 setState 方法)时就会创建一个新的 update 对象到 queue 链表。链表存放着所有历史更新操作记录。在 update 阶段就会去遍历 queue 链表,执行每一次更新计算(这也这是为什么 setState 调用两次会有两次更新),最后计算出最新的状态并返回。

这也就是为什么不能在判断语句条件语句中写 hooks,因为 hooks 原理是链表结构。

在调用 useEffect 时,在 mount 和 update 阶段 fiber 节点会创建一个 updateQueue 链表来存放本次渲染需要执行的 effect。并且由 Tag 来定义 effect 的行为,Tag 是二进制数,可以在源码 ReactHookEffectTags.js查看 Tag 的定义。在源码中,这些 Tag 值在源码中常见用例是使用管道符(|)将位添加到单个值中,使用与运算符(&)检查一个 Tag 是否实现了特定的行为。

在 update 阶段,如果 deps 依赖没有改变 effect 没有触发那么就会是一个 NoFlags 的 Tag,提交阶段就会调过这个 effect。

以上是一些 hooks 源码的补充内容。

所有 hooks 链表都是存储在 fiber 节点的 memoizedState(记忆化状态)里。

使用 memoizedState 的原因在于,Fiber 可以在组件树中复用相同组件的实例,假设在当前页面上的不同模块下都引入了一个公用组件 A,组件 A 里写了一些 hooks。那么 Fiber 可以在这两个组件实例复用同一个 fiber 节点,两个组件实例会共享同一个memoizedState,因为不是在组件实例上所以即使共享,state 也不会冲突。这种方法使得 React 在协调阶段更加高效,减少性能开销。

总结

在初始阶段,React会创建一棵初始的Fiber树,作为current树。这个初始的Fiber树通常是通过调用ReactDOM.render()方法生成的,并且它对应的是整个应用的初始状态。

当组件进行更新时,React会创建一个新的Fiber树,命名为workInProgress树。这个workInProgress树是一个全新的Fiber树,与current树无关,用于计算出新的虚拟DOM树。在reconciliation过程中,React会在workInProgress树中构建新的Fiber节点,并根据当前节点的状态计算出需要渲染的内容和对应的DOM节点。

当计算完成后,React会通过diff算法比较workInProgress树和current树的差异,找出需要更新的部分,然后将这些更新应用到页面上。如果更新成功,React会将workInProgress树赋值给current树,这样就完成了一次更新。此时,current树就是最新的Fiber树,代表了应用的最新状态。

在这个过程中,没有涉及到alternate备份树。alternate树是在workInProgress树计算完成后,用于备份current树的一棵Fiber树,用于恢复当前的渲染状态。如果在更新过程中出现了错误,React会使用这个alternate树来回滚到之前的渲染状态,并重试更新。因此,alternate树主要是用于保证React在更新过程中的安全性和健壮性。

外部链接:

An Introduction to React Fiber - The Algorithm Behind React

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant