技术咨询、项目合作、广告投放、简历咨询、技术文档下载 点击这里 联系博主

# Fiber的理解

# 前言

本文作为自己深入学习 React Fiber(React v16.8.6)的理解,本篇仅介绍大致流程,Fiber 详细源码本文不作细节描述。

# Fiber 出现的背景

首先要知道的是,JavaScript 引擎和页面渲染引擎两个线程是互斥的,当其中一个线程执行时,另一个线程只能挂起等待。

在这样的机制下,如果 JavaScript 线程长时间地占用了主线程,那么渲染层面的更新就不得不长时间地等待,界面长时间不更新,会导致页面响应度变差,用户可能会感觉到卡顿

而这正是 React 15 的 Stack Reconciler 所面临的问题,即是 JavaScript 对主线程的超时占用问题。Stack Reconciler 是一个同步的递归过程,使用的是 JavaScript 引擎自身的函数调用栈,它会一直执行到栈空为止,所以当 React 在渲染组件时,从开始到渲染完成整个过程是一气呵成的。如果渲染的组件比较庞大,js 执行会占据主线程较长时间,会导致页面响应度变差。

而且所有的任务都是按照先后顺序,没有区分优先级,这样就会导致优先级比较高的任务无法被优先执行。

# Fiber 是什么

Fiber 的中文翻译叫纤程,与进程、线程同为程序执行过程,Fiber 就是比线程还要纤细的一个过程。纤程意在对渲染过程实现进行更加精细的控制。

从架构角度来看,Fiber 是对 React 核心算法(即调和过程)的重写。

从编码角度来看,FiberReact 内部所定义的一种数据结构,它是 Fiber 树结构的节点单位,也就是 React 16 新架构下的"虚拟 DOM"。

一个 fiber 就是一个 JavaScript 对象,Fiber 的数据结构如下:

type Fiber = {
  // 用于标记fiber的WorkTag类型,主要表示当前fiber代表的组件类型如FunctionComponent、ClassComponent等
  tag: WorkTag,
  // ReactElement里面的key
  key: null | string,
  // ReactElement.type,调用`createElement`的第一个参数
  elementType: any,
  // The resolved function/class/ associated with this fiber.
  // 表示当前代表的节点类型
  type: any,
  // 表示当前FiberNode对应的element组件实例
  stateNode: any,

  // 指向他在Fiber节点树中的`parent`,用来在处理完这个节点之后向上返回
  return: Fiber | null,
  // 指向自己的第一个子节点
  child: Fiber | null,
  // 指向自己的兄弟结构,兄弟节点的return指向同一个父节点
  sibling: Fiber | null,
  index: number,

  ref: null | (((handle: mixed) => void) & { _stringRef: ?string }) | RefObject,

  // 当前处理过程中的组件props对象
  pendingProps: any,
  // 上一次渲染完成之后的props
  memoizedProps: any,

  // 该Fiber对应的组件产生的Update会存放在这个队列里面
  updateQueue: UpdateQueue<any> | null,

  // 上一次渲染的时候的state
  memoizedState: any,

  // 一个列表,存放这个Fiber依赖的context
  firstContextDependency: ContextDependency<mixed> | null,

  mode: TypeOfMode,

  // Effect
  // 用来记录Side Effect
  effectTag: SideEffectTag,

  // 单链表用来快速查找下一个side effect
  nextEffect: Fiber | null,

  // 子树中第一个side effect
  firstEffect: Fiber | null,
  // 子树中最后一个side effect
  lastEffect: Fiber | null,

  // 代表任务在未来的哪个时间点应该被完成,之后版本改名为 lanes
  expirationTime: ExpirationTime,

  // 快速确定子树中是否有不在等待的变化
  childExpirationTime: ExpirationTime,

  // fiber的版本池,即记录fiber更新过程,便于恢复
  alternate: `Fiber` | null,
}

在 2020 年 5 月,以 expirationTime 属性为代表的优先级模型被 lanes 取代。

# Fiber 如何解决问题的

Fiber 把一个渲染任务分解为多个渲染任务,而不是一次性完成,把每一个分割得很细的任务视作一个"执行单元",React 就会检查现在还剩多少时间,如果没有时间就将控制权让出去,故任务会被分散到多个帧里面,中间可以返回至主进程控制执行其他任务,最终实现更流畅的用户体验。

即是实现了"增量渲染",实现了可中断与恢复,恢复后也可以复用之前的中间状态,并给不同的任务赋予不同的优先级,其中每个任务更新单元为 React Element 对应的 Fiber 节点。

# Fiber的核心目标及特性

# 核心目标

核心目标:扩大其适用性,包括动画,布局和手势。分为 5 个具体目标:

  • 把可中断的工作拆分成小任务

  • 对正在做的工作调整优先次序、重做、复用上次(做了一半的)成果

  • 在父子任务之间从容切换(yield back and forth),以支持 React 执行过程中的布局刷新

  • 支持 render()返回多个元素

  • 更好地支持 error boundary

# 关键特性

Fiber 的关键特性如下:

  • 增量渲染(把渲染任务拆分成块,匀到多帧)

  • 更新时能够暂停,终止,复用渲染任务

  • 给不同类型的更新赋予优先级

  • 并发方面新的基础能力

增量渲染用来解决掉帧的问题,渲染任务拆分之后,每次只做一小段,做完一段就把时间控制权交还给主线程,而不像之前长时间占用。这种策略叫做 cooperative scheduling(合作式调度),操作系统的 3 种任务调度策略之一(Firefox 还对真实 DOM 应用了这项技术)

另外,React 自身的killer feature是 virtual DOM,2 个原因:

  • coding UI 变简单了(不用关心浏览器应该怎么做,而是把下一刻的 UI 描述给 React 听)

  • 既然 DOM 能 virtual,别的(硬件、VR、native App)也能

React 实现上分为 2 部分:

  • reconciler 寻找某时刻前后两版 UI 的差异。包括之前的 Stack reconciler 与现在的 Fiber reconciler

  • renderer 插件式的,平台相关的部分。包括 React DOM、React Native、React ART、ReactHardware、ReactAframe、React-pdf、ReactThreeRenderer、ReactBlessed 等等

这一波是对 reconciler 的彻底改造,对 killer feature 的增强

# Fiber 实现原理

实现的方式是requestIdleCallback这一 API,但 React 团队 polyfill 了这个 API,使其对比原生的浏览器兼容性更好且拓展了特性。

window.requestIdleCallback()方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间 timeout,则有可能为了在超时前执行函数而打乱执行顺序。

// 一个简单的使用案例
requestIdleCallback(myWork)
// 一个任务队列
let tasks = [
  function t1(){
    console.log('执行任务1')
  },
  function t2() {
    console.log('执行任务2')
  }
]
// deadline是requestIdleCallback返回的一个对象
function myWork(deadline) {
  console.log(`当前帧剩余时间: ${deadline.timeRemaining()}`)
  // 方法timeRemaining返回的是当前帧的剩余时间
  if(deadline.timeRemaining() > 0 && tasks.length) {
    // 可以在这里做一些事情了
    const task = tasks.shift()
    task()
  }
  // 如果还有任务没有被执行,那就放到下一帧调度中去继续执行,类似递归
  if(tasks.length) {
    requestIdleCallback(myWork)
  }
}

requestIdleCallback回调的执行的前提条件是当前浏览器处于空闲状态。

requestIdleCallback的作用是在浏览器一帧的剩余空闲时间内执行优先度相对较低的任务。首先 React 中任务切割为多个步骤,分批完成。在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间再进行页面的渲染。等浏览器忙完之后有剩余时间,再继续之前 React 未完成的任务,是一种合作式调度。

简而言之,由浏览器给我们分配执行时间片,我们要按照约定在这个时间内执行完毕,并将控制权还给浏览器。

React 16 的Reconciler基于 Fiber 节点实现,被称为 Fiber Reconciler。

作为静态的数据结构来说,每个 Fiber 节点对应一个 React element,保存了该组件的类型(函数组件/类组件/原生组件等等)、对应的 DOM 节点等信息。

作为动态的工作单元来说,每个 Fiber 节点保存了本次更新中该组件改变的状态、要执行的工作。

每个 Fiber 节点有个对应的 React element,多个 Fiber 节点是如何连接形成树呢?靠如下三个属性:

// 指向父级Fiber节点
this.return = null;
// 指向子Fiber节点
this.child = null;
// 指向右边第一个兄弟Fiber节点
this.sibling = null;

# Fiber 架构核心

Fiber 架构可以分为三层:

  • Scheduler 调度器 —— 调度任务的优先级,高优任务优先进入 Reconciler
  • Reconciler 协调器 —— 负责找出变化的组件
  • Renderer 渲染器 —— 负责将变化的组件渲染到页面上

相比 React15,React16 多了Scheduler(调度器),调度器的作用是调度更新的优先级。

在新的架构模式下,工作流如下:

  • 每个更新任务都会被赋予一个优先级。
  • 当更新任务抵达调度器时,高优先级的更新任务(记为 A)会更快地被调度进 Reconciler 层;
  • 此时若有新的更新任务(记为 B)抵达调度器,调度器会检查它的优先级,若发现 B 的优先级高于当前任务 A,那么当前处于 Reconciler 层的 A 任务就会被中断,调度器会将 B 任务推入 Reconciler 层。
  • 当 B 任务完成渲染后,新一轮的调度开始,之前被中断的 A 任务将会被重新推入 Reconciler 层,继续它的渲染之旅,即“可恢复”。

Fiber 架构的核心即是"可中断"、"可恢复"、"优先级"

# Scheduler 调度器

这个需要上面提到的requestIdleCallback,React 团队实现了功能更完备的 requestIdleCallback polyfill,这就是 Scheduler。除了在空闲时触发回调的功能外,Scheduler 还提供了多种调度优先级供任务设置。

React Scheduler 使用 MessageChannel 的原因为:生成宏任务,实现:

  1. 将主线程还给浏览器,以便浏览器更新页面。
  2. 浏览器更新页面后继续执行未完成的任务。

为什么不使用微任务呢?

  1. 微任务将在页面更新前全部执行完,所以达不到「将主线程还给浏览器」的目的。

为什么不使用 setTimeout(fn, 0) 呢?

  1. 递归的 setTimeout() 调用会使调用间隔变为 4ms,导致浪费了 4ms。

为什么不使用 rAF() 呢?

不属于微任务也不属于宏任务,是属于任务。是一个在重绘之前执行的一个操作,而重绘操作一般是宏任务之前、微任务之后执行

  1. 如果上次任务调度不是 rAF() 触发的,将导致在当前帧更新前进行两次任务调度。
  2. 页面更新的时间不确定,如果浏览器间隔了 10ms 才更新页面,那么这 10ms 就浪费了。

# Reconciler 协调器

在 React 15 中是递归处理虚拟 DOM 的,React 16 则是变成了可以中断的循环过程,每次循环都会调用shouldYield判断当前是否有剩余时间。

function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    // workInProgress表示当前工作进度的树。
    workInProgress = performUnitOfWork(workInProgress);
  }
}

React 16 是如何解决中断更新时 DOM 渲染不完全的问题呢?

在 React 16 中,ReconcilerRenderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟 DOM 打上的标记。

export const Placement = /*             */ 0b0000000000010;
export const Update = /*                */ 0b0000000000100;
export const PlacementAndUpdate = /*    */ 0b0000000000110;
export const Deletion = /*              */ 0b0000000001000;
  • Placement表示插入操作
  • PlacementAndUpdate表示替换操作
  • Update表示更新操作
  • Deletion表示删除操作

整个SchedulerReconciler的工作都在内存中进行,所以即使反复中断,用户也不会看见更新不完全的 DOM。只有当所有组件都完成Reconciler的工作,才会统一交给Renderer

# Renderer 渲染器

Renderer根据Reconciler为虚拟 DOM 打的标记,同步执行对应的 DOM 操作。

# Fiber 架构对生命周期的影响

  1. render 阶段:纯净且没有副作用,可能会被 React 暂停、终止或重新启动。
  2. pre-commit 阶段:可以读取 DOM。
  3. commit 阶段:可以使用 DOM,运行副作用,安排更新。
// 第1阶段 render/reconciliation
componentWillMount;
componentWillReceiveProps;
shouldComponentUpdate;
componentWillUpdate;

// 第2阶段 commit
componentDidMount;
componentDidUpdate;
componentWillUnmount;

其中 pre-commit 和 commit 从大阶段上来看都属于 commit 阶段。

在 render 阶段,React 主要是在内存中做计算,明确 DOM 树的更新点;而 commit 阶段,则负责把 render 阶段生成的更新真正地执行掉。

新老两种架构对 React 生命周期的影响主要在 render 这个阶段,这个影响是通过增加 Scheduler 层和改写 Reconciler 层来实现的。

在 render 阶段,一个庞大的更新任务被分解为了一个个的工作单元,这些工作单元有着不同的优先级,React 可以根据优先级的高低去实现工作单元的打断和恢复。

之前写过一篇文章关于为什么 React 一些旧生命周期函数打算废弃的原因:谈谈对 React 新旧生命周期的理解 (opens new window)

而这次从 Firber 机制 render 阶段的角度看这三个生命周期,这三个生命周期的共同特点是都处于 render 阶段:

componentWillMount
componentWillUpdate
componentWillReceiveProps

由于 render 阶段是允许暂停、终止和重启的,这就导致 render 阶段的生命周期都有可能被重复执行,故也是废弃他们的原因之一。

# Fiber 更新过程

虚拟 DOM 更新过程分为 2 个阶段:

  • render/reconciliation 协调阶段(可中断/异步):通过 Diff 算法找出所有节点变更,例如节点新增、删除、属性变更等等, 获得需要更新的节点信息,对应早期版本的 Diff 过程。
  • commit 提交阶段(不可中断/同步):将需要更新的节点一次过批量更新,对应早期版本的 patch 过程。

# 协调阶段 reconciliation/render

在协调阶段: 以 fiber tree 为蓝本,把每个 fiber 作为一个工作单元,自顶向下逐节点构造workInProgress tree(构建中的新 fiber tree)

该阶段开始于performSyncWorkOnRootperformConcurrentWorkOnRoot方法的调用。这取决于本次更新是同步更新还是异步更新。

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

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

它们唯一的区别是是否调用shouldYield。如果当前浏览器帧没有剩余时间,shouldYield会中止循环,直到浏览器有空闲时间后再继续遍历。

workInProgress代表当前已创建的 workInProgress fiber。

performUnitOfWork方法将触发对 beginWork 的调用,进而实现对新 Fiber 节点的创建。若 beginWork 所创建的 Fiber 节点不为空,则 performUniOfWork 会用这个新的 Fiber 节点来更新 workInProgress 的值,为下一次循环做准备。

通过循环调用 performUnitOfWork 来触发 beginWork,新的 Fiber 节点就会被不断地创建。当 workInProgress 终于为空时,说明没有新的节点可以创建了,也就意味着已经完成对整棵 Fiber 树的构建。

我们知道 Fiber Reconciler 是从 Stack Reconciler 重构而来,通过遍历的方式实现可中断的递归,所以performUnitOfWork的工作可以分为两部分:"递"和"归"。

"递阶段"

首先从 rootFiber 开始向下深度优先遍历。为遍历到的每个 Fiber 节点调用beginWork方法。

function beginWork(
  current: `Fiber` | null, // 当前组件对应的Fiber节点在上一次更新时的Fiber节点
  workInProgress: `Fiber`, // 当前组件对应的Fiber节点
  renderExpirationTime: ExpirationTime // 优先级相关
): `Fiber` | null {
  // ...省略函数体
}

该方法会根据传入的 Fiber 节点创建子 Fiber 节点,并将这两个 Fiber 节点连接起来。

当遍历到叶子节点(即没有子组件的组件)时就会进入"归"阶段。

"归阶段"

在"归"阶段会调用completeWork处理 Fiber 节点。

completeWork 将根据 workInProgress 节点的 tag 属性的不同,进入不同的 DOM 节点的创建、处理逻辑。

completeWork 内部有 3 个关键动作:

  • 创建 DOM 节点(CreateInstance)
  • 将 DOM 节点插入到 DOM 树中(AppendAllChildren)
  • 为 DOM 节点设置属性(FinalizeInitialChildren)

当某个 Fiber 节点执行完completeWork,如果其存在兄弟 Fiber 节点(即fiber.sibling !== null),会进入其兄弟 Fiber 的"递"阶段。

如果不存在兄弟 Fiber,会进入父级 Fiber 的"归"阶段。

"递"和"归"阶段会交错执行直到"归"到 rootFiber。至此,协调阶段的工作就结束了。

# commit 提交阶段

commit 阶段的主要工作(即 Renderer 的工作流程)分为三部分:

  • before mutation 阶段,这个阶段 DOM 节点还没有被渲染到界面上去,过程中会触发 getSnapshotBeforeUpdate,也会处理 useEffect 钩子相关的调度逻辑。
  • mutation 阶段,这个阶段负责 DOM 节点的渲染。在渲染过程中,会遍历 effectList,根据 flags(effectTag)的不同,执行不同的 DOM 操作。
  • layout 阶段,这个阶段处理 DOM 渲染完毕之后的收尾逻辑。比如调用 componentDidMount/componentDidUpdate,调用 useLayoutEffect 钩子函数的回调等。除了这些之外,它还会把 fiberRoot 的 current 指针指向 workInProgress Fiber 树。

# Fiber tree 与 workInProgress tree

双缓冲技术(double buffering),就像redux 里的nextListeners (opens new window),以 fiber tree 为主,workInProgress tree 为辅

双缓冲具体指的是workInProgress tree构造完毕,得到的就是新的fiber tree,然后喜新厌旧(把 current 指针指向 workInProgress tree,丢掉旧的 fiber tree)就好了

这样做的好处:

  • 能够复用内部对象(fiber)

  • 节省内存分配、GC 的时间开销

每个 fiber 上都有个alternate属性,也指向一个 fiber,创建 workInProgress 节点时优先取alternate,没有的话就创建一个:

let workInProgress = current.alternate;
if (workInProgress === null) {
  //...这里很有意思
  workInProgress.alternate = current;
  current.alternate = workInProgress;
} else {
  // We already have an alternate.
  // Reset the effect tag.
  workInProgress.effectTag = NoEffect;

  // The effect list is no longer valid.
  workInProgress.nextEffect = null;
  workInProgress.firstEffect = null;
  workInProgress.lastEffect = null;
}

如注释指出的,fiber 与 workInProgress 互相持有引用,“喜新厌旧”之后,旧 fiber 就作为新 fiber 更新的预留空间,达到复用 fiber 实例的目的

P.S.源码里还有一些有意思的技巧,比如tag 的位运算 (opens new window)

# 优先级策略

每个工作单元运行时有 6 种优先级:

  • synchronous 与之前的 Stack reconciler 操作一样,同步执行

  • task 在 next tick 之前执行

  • animation 下一帧之前执行

  • high 在不久的将来立即执行

  • low 稍微延迟(100-200ms)执行也没关系

  • offscreen 下一次 render 时或 scroll 时才执行

synchronous 首屏(首次渲染)用,要求尽量快,不管会不会阻塞 UI 线程。animation 通过requestAnimationFrame来调度,这样在下一帧就能立即开始动画过程;后 3 个都是由requestIdleCallback回调执行的;offscreen 指的是当前隐藏的、屏幕外的(看不见的)元素

高优先级的比如键盘输入(希望立即得到反馈),低优先级的比如网络请求,让评论显示出来等等。另外,紧急的事件允许插队

这样的优先级机制存在2 个问题

  • 生命周期函数怎么执行(可能被频频中断):触发顺序、次数没有保证了

  • starvation(低优先级饿死):如果高优先级任务很多,那么低优先级任务根本没机会执行(就饿死了)

生命周期函数的问题有一个官方例子:

low A
componentWillUpdate()
---
high B
componentWillUpdate()
componentDidUpdate()
---
restart low A
componentWillUpdate()
componentDidUpdate()

第 1 个问题正在解决(还没解决),生命周期的问题会破坏一些现有 App,给平滑升级带来困难,Fiber 团队正在努力寻找优雅的升级途径

第 2 个问题通过尽量复用已完成的操作(reusing work where it can)来缓解,听起来也是正在想办法解决

这两个问题本身不太好解决,只是解决到什么程度的问题。比如第一个问题,如果组件生命周期函数掺杂副作用太多,就没有办法无伤解决。这些问题虽然会给升级 Fiber 带来一定阻力,但绝不是不可解的(退一步讲,如果新特性有足够的吸引力,第一个问题大家自己想办法就解决了)

# Fiber reconciliation 阶段中的 diff 算法

React16 的 diff 策略采用从链表头部开始比较的算法,是层次遍历,算法是建立在一个节点的插入、删除、移动等操作都是在节点树的同一层级中进行的。

对于 Diff, 新老节点的对比,我们以新节点为标准,然后来构建整个currentInWorkProgress,对于新的 children 会有四种情况:

  • TextNode(包含字符串和数字)
  • 单个 React Element(通过该节点是否有 $$typeof 区分)
  • 数组
  • 可迭代的 children,跟数组的处理方式差不多

# 代码部分

reconcileChildFibers 就是 Diff 部分的主体代码,这个函数超级长,是一个包装函数,下面所有的 diff 代码都在这里面;

参数介绍

  • returnFiber 是即将 Diff 的这层的父节点。
  • currentFirstChild是当前层的第一个 Fiber 节点。
  • newChild 是即将更新的 vdom 节点(可能是 TextNode、可能是 ReactElement,可能是数组),不是 Fiber 节点
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
  lanes: Lanes
): Fiber | null {
  const isUnkeyedTopLevelFragment =
    typeof newChild === "object" &&
    newChild !== null &&
    newChild.type === REACT_FRAGMENT_TYPE &&
    newChild.key === null;
  if (isUnkeyedTopLevelFragment) {
    newChild = newChild.props.children;
  }

  // Handle object types
  const isObject = typeof newChild === "object" && newChild !== null;

  if (isObject) {
    // 省略,此处为React Elementlei'x
  }
  // 如果是文本类型的则进行文本节点的diff
  if (typeof newChild === "string" || typeof newChild === "number") {
    return placeSingleChild(
      reconcileSingleTextNode(
        returnFiber,
        currentFirstChild,
        "" + newChild,
        lanes
      )
    );
  }
  // 如 果是十足类型的
  if (isArray(newChild)) {
    return reconcileChildrenArray(
      returnFiber,
      currentFirstChild,
      newChild,
      lanes
    );
  }

  if (getIteratorFn(newChild)) {
    return reconcileChildrenIterator(
      returnFiber,
      currentFirstChild,
      newChild,
      lanes
    );
  }

  // 更新删除掉了所有节点,执行删除
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

# 针对文本节点

  1. 第一种情况。xxx 是一个 TextNode,那么就代表这这个节点可以复用,有复用的节点,对性能优化很有帮助。既然新的 child 只有一个 TextNode,那么复用节点之后,就把剩下的 节点就可以删掉了,那么 div 的 child 就可以添加到 workInProgress 中去了;

  2. 第二种情况。当前节点 不是一个 TextNode,那么就代表这个节点不能复用,所以就从 currentFirstChild 开始删掉剩余的节点;

注意:删除节点不会真的从链表里面把节点删除,只是打一个 delete 的 tag,当 commit 的时候才会真正的去删除。

function reconcileSingleTextNode(...): Fiber {
  //旧的节点也是一个 text节点 则可以复用
  if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
    //删除兄弟
    deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
    //复用
    const existing = useFiber(currentFirstChild, textContent);
    existing.return = returnFiber;
    return existing;
  }
  // 否则创建新的fiber节点,将旧的节点和旧节点的兄弟都删除
  deleteRemainingChildren(returnFiber, currentFirstChild);
  const created = createFiberFromText(...);
  created.return = returnFiber;
  return created;
}

# 针对 React Element

**如何判断这个节点是否可以复用呢?**从哪个下面的代码可以看出:

  1. key 相同。
  2. 节点的类型相同。

如果以上两点相同,就代表这个节点只是变化了内容,不需要创建新的节点,可以复用的。

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement,
  lanes: Lanes
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  //初始渲染这个currentFirstChild直接传的null,所以更新时候才会diff
  while (child !== null) {
    // TODO: If key === null and child.key === null, then this only applies to
    // the first item in the list.
    if (child.key === key) {
      const elementType = element.type;
      if (elementType === REACT_FRAGMENT_TYPE) {
        if (child.tag === Fragment) {
          // 相同的老的节点的兄弟节点清空  为了本次可以复用
          deleteRemainingChildren(returnFiber, child.sibling);
          // 如果是fragment复用的是children,对于 Fragment 节点和一般的 Element 节点创建的方式不同,因为 Fragment 本来就是一个无意义的节点,他真正需要创建 Fiber 的是它的 children,而不是它自己
          const existing = useFiber(child, element.props.children);
          existing.return = returnFiber;
          return existing;
        }
      } else {
        // 如果类型相同
        if (
          child.elementType === elementType ||
          false ||
          (enableLazyElements &&
            typeof elementType === "object" &&
            elementType !== null &&
            elementType.$$typeof === REACT_LAZY_TYPE &&
            resolveLazy(elementType) === child.type)
        ) {
          // 相同的老的节点的兄弟节点清空  为了本次可以复用
          deleteRemainingChildren(returnFiber, child.sibling);
          const existing = useFiber(child, element.props);
          existing.ref = coerceRef(returnFiber, child, element);
          existing.return = returnFiber;
          return existing;
        }
      }
      // key相同但是节点类型不同,无法复用并且兄弟节点也不可复用,全部删除
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // key都不相同 则无法服用,但是其兄弟可能还会复用上次的
      deleteChild(returnFiber, child);
    }
    //既然是单个节点的调和  为何还用while遍历所有兄弟节点呢?
    /**
     * old: a b
     * new: b
     * 如果老节点存在兄弟节点(老节点和新节点不一致),刚好和现在的节点类型一致,这样也可复用
     */
    child = child.sibling;
  }
  //上面如果执行完没有可复用的 则进入这里进行创建,对于 Fragment 节点和一般的 Element 节点创建的方式不同,因为 Fragment 本来就是一个无意义的节点,他真正需要创建 Fiber 的是它的 children,而不是它自己,所以 createFiberFromFragment 传递的不是 element,而是 element.props.children
  if (element.type === REACT_FRAGMENT_TYPE) {
    const created = createFiberFromFragment(
      element.props.children,
      returnFiber.mode,
      lanes,
      element.key
    );
    created.return = returnFiber;
    return created;
  } else {
    const created = createFiberFromElement(element, returnFiber.mode, lanes);
    created.ref = coerceRef(returnFiber, currentFirstChild, element);
    created.return = returnFiber;
    return created;
  }
}

单个节点的复用逻辑比较清晰,初次渲染直接 created更新时判断 key 是否一致,再判断节点类型是否一致,如果条件满足则复用旧的节点。不满足时稍微复杂一些,有如下三个注意点:

  • key 相同但 type 不同代表更新的单个节点和旧的节点(以及旧节点的兄弟节点)肯定无法复用了,所以执行的方式是删除旧的和兄弟节点(deleteRemainingChildren)。
old:    div > p p p
new:    div > span

  • key 不同 :代表更新的单个节点和旧的某个节点无法复用,但是有可能旧的兄弟节点可被复用,所以执行的方式是只删除旧的节点(deleteChild)。
old: p1  p2  p3
new: p2

  • while 循环: 通过上面整个例子可以知道为何单个节点还要遍历,这就是 react 的一种优化措施,key 不同,但是有可能兄弟节点还是可复用的,所以继续 child.sibling

# Diff Array 的比较

1. 第一次循环: 相同位置(index)进行比较

这是第一次遍历新数组,通过调用 updateSlot 来对比新老元素,对比新老节点的代码都是在这个函数里。这个循环会把所有的从前面开始能复用的节点,都复用到。比如上面我们画的图,如果两个链表里面的 **???**节点,不相同,那么 newFiber 为 null,这个循环就会跳出。

跳出来了,就会有两种情况。

  • 新节点已经遍历完毕
  • 老节点已经遍历完毕
// 第一次遍历的代码:


//第一次遍历  同位置的比较
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
  if (oldFiber.index > newIdx) {
    // oldFiber在本循环的最下面会被赋值为 nextOldFiber,不断的寻找兄弟节点
    //[null, a] => [b, a]
    nextOldFiber = oldFiber;
    oldFiber = null;
  } else {
    //正常的情况下 为了下轮循环,拿到兄弟节点下面赋值给oldFiber
    nextOldFiber = oldFiber.sibling;
  }
  //这里面根据key 判断是否可以复用节点(准确的说,有可能节点类型会不同,其他属性和值相同)
  const newFiber = updateSlot(...);
  //节点无法复用 跳出循环 下方详解
  if (newFiber === null) {
    if (oldFiber === null) {
      oldFiber = nextOldFiber;
    }
    break;
  }
  //更新
  if (shouldTrackSideEffects) {
    if (oldFiber && newFiber.alternate === null) {
      //删除,下方详解
      deleteChild(returnFiber, oldFiber);
    }
  }
  //本次遍历会给新增的节点打 插入的标记
  lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
  ...
  //重新给 oldFiber 赋值继续遍历
  oldFiber = nextOldFiber;
}


2. 新节点已经遍历完毕(节点删除情况)

如果新节点已经遍历完毕的话,也就是没有要更新的了,这种情况一般就是从原来的数组里面删除了元素,那么直接把剩下的老节点删除了就行了。还是拿上面的图的例子举例,老的链表里???还有很多节点,而新的链表???已经没有节点了,所以老的链表???不管是有多少节点,都不能复用了,所以没用了,直接删除。

if (newIdx === newChildren.length) {
  // 新的 children 长度已经够了,所以把剩下的删除掉
  deleteRemainingChildren(returnFiber, oldFiber);
  return resultingFirstChild;
}

注意这里是直接 return 了哦,没有继续往下执行了。

3. 老节点已经遍历完毕(节点新增情况)

如果老的节点在第一次循环的时候就被复用完了,新的节点还有,很有可能就是新增了节点的情况。那么这个时候只需要根据把剩余新的节点直接创建 Fiber 就行了。

if (oldFiber === null) {
  // 如果老的节点已经被复用完了,对剩下的新节点进行操作
  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
  }
  return resultingFirstChild;
}

oldFiber === null 就是用来判断老的 Fiber 节点变量完了的代码,Fiber 链表是一个单向链表,所以为 null 的时候代表已经结束了。所以就直接把剩余的 newChild 通过循环创建 Fiber。

到这里,目前简单的对数组进行增、删节点的对比还是比较简单,接下来就是移动的情况是如何进行复用的呢

4. 移动的情况如何进行节点复用(节点修改)

对于移动的情况,首先要思考,怎么能判断数组是否发生过移动操作呢?

如果给你两个数组,你是否能判断出来数组是否发生过移动。

答案是:老的数组和新的数组里面都有这个元素,而且位置不相同。

从两个数组中找到相同元素(是指可复用的节点),方法有很多种,来看看 React 是如何高效的找出来的。

把所有老数组元素按 key 或者是 index 放 Map 里,然后遍历新数组,根据新数组的 key 或者 index 看看是否存在相同的 key 或者 index 标识的旧节点

function mapRemainingChildren(
  returnFiber: Fiber,
  currentFirstChild: Fiber
): Map<string | number, Fiber> {
  const existingChildren: Map<string | number, Fiber> = new Map();

  let existingChild = currentFirstChild; // currentFirstChild 是老数组链表的第一个元素
  while (existingChild !== null) {
    // 看到这里可能会疑惑怎么在 Map 里面的key 是 fiber 的key 还是 fiber 的 index 呢?
    // 我觉得是根据数据类型,fiber 的key 是字符串,而 index 是数字,这样就能区分了
    // 所以这里是用的 map,而不是对象,如果是对象的key 就不能区分 字符串类型和数字类型了。
    if (existingChild.key !== null) {
      existingChildren.set(existingChild.key, existingChild);
    } else {
      existingChildren.set(existingChild.index, existingChild);
    }
    // 兄弟节点挨个遍历
    existingChild = existingChild.sibling;
  }
  return existingChildren;
}

这个 mapRemainingChildren 就是将老数组存放到 Map 里面。元素有 key 就 Map 的键就存 key,没有 key 就存 index,key 一定是字符串,index 一定是 number,所以取的时候是能区分的,所以这里用的是 Map,而不是对象,如果是对象,属性是字符串,就没办法区别是 key 还是 index 了。

现在有了这个 Map,剩下的就是循环新数组,找到 Map 里面可以复用的节点,如果找不到就创建,这个逻辑基本上跟 updateSlot 的复用逻辑很像,一个是从老数组链表中获取节点对比,一个是从 Map 里获取节点对比。

// 如果前面的算法有复用,那么 newIdx 就不从 0 开始
for (; newIdx < newChildren.length; newIdx++) {
  const newFiber = updateFromMap(...); // 这里正常情况下会返回值  复用或新创建 和updateSlot不同
  if (newFiber !== null) {
    if (shouldTrackSideEffects) {
      if (newFiber.alternate !== null) {
        // 新的节点是复用老节点  从Map对象中删除旧节点值
        existingChildren.delete(
          newFiber.key === null ? newIdx : newFiber.key,
        );
      }
    }
    // 更新index 判定哪些节点需要被标记 插入effectTag
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      //返回时候只需要返回自己   因为兄弟节点都挂载到自己身上了
      resultingFirstChild = newFiber;
    } else {
      //不停的给自己追加兄弟 a--->b--->c--->d
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
}

if (shouldTrackSideEffects) {
  //旧节点中还存在,没法被复用  这里需要遍历删除
  existingChildren.forEach(child => deleteChild(returnFiber, child));
}
// 只需要返回第一个节点即可   后面兄弟通过 slibling连接
return resultingFirstChild;

到这里新数组遍历完毕,也就是同一层的 Diff 过程完毕,接下来进行总结一下:

对于数组的 diff 策略,相对比较复杂,最后来梳理一下这个策略,其实还是很简单,只是看源码的时候比较难懂。

我们可以把整个过程分为三个阶段:

  1. 第一遍历新数组,新老数组相同 index 进行对比,通过 updateSlot方法找到可以复用的节点,直到找到不可以复用的节点就退出循环。
  2. 第一遍历完之后,删除剩余的老节点,追加剩余的新节点的过程: 如果是新节点已遍历完成,就将剩余的老节点批量删除;如果是老节点遍历完成仍有新节点剩余,则将新节点直接插入。如果有删除/新增则直接返回,不进行下一个步骤.
  3. (非新增/删除的情况)即移动的情况: 把所有老数组元素按 key 或 index 放 Map 里,然后遍历新数组,插入老数组的元素,这是移动的情况。

# diff 总结

  1. 针对文本节点
  • 第一种情况。旧节点是一个 TextNode,那么就代表这这个节点可以复用,有复用的节点,对性能优化很有帮助。既然新的 child 只有一个 TextNode,那么复用节点之后,就把剩下的 节点就可以删掉了,那么 div 的 child 就可以添加到 workInProgress 中去了;

  • 第二种情况: 旧节点 不是一个 TextNode,那么就代表这个节点不能复用,所以就从 currentFirstChild 开始删掉剩余的节点;

  1. 针对 React 节点
  • 初次渲染直接 created更新时判断 key 是否一致,再判断节点类型是否一致,如果条件满足则复用旧的节点。不满足时稍微复杂一些;

  • key 相同但 type 不同代表更新的单个节点和旧的节点(以及旧节点的兄弟节点)肯定无法复用了,所以执行的方式是删除旧的和兄弟节点(deleteRemainingChildren)。

  • key 不同 :代表更新的单个节点和旧的某个节点无法复用,但是有可能旧的兄弟节点可被复用,所以执行的方式是只删除旧的节点(deleteChild)。

  • while 循环: 查看兄弟节点是否可以复用;

  1. 针对列表
  • 第一遍历新数组,新老数组相同 index 进行对比,通过 updateSlot方法找到可以复用的节点,直到找到不可以复用的节点就退出循环。
  • 第一遍历完之后,删除剩余的老节点,追加剩余的新节点的过程: 如果是新节点已遍历完成,就将剩余的老节点批量删除;如果是老节点遍历完成仍有新节点剩余,则将新节点直接插入。如果有删除/新增则直接返回,不进行下一个步骤.
  • (非新增/删除的情况)即移动的情况: 把所有老数组元素按 key 或 index 放 Map 里,然后遍历新数组,插入老数组的元素,这是移动的情况。

# Fiber总结

# 如何解决主线程长时间占用问题?

TIP

问题描述: React 在一些响应体验要求较高的场景不适用,比如动画,布局和手势;根本原因是渲染/更新过程一旦开始无法中断,持续占用主线程,主线程忙于执行 JS,无暇他顾(布局、动画),造成掉帧、延迟响应(甚至无响应)等不佳体验

求: 一种能够彻底解决主线程长时间占用问题的机制,不仅能够应对眼前的问题,还要有长远意义

The “fiber” reconciler is a new effort aiming to resolve the problems inherent in the stack reconciler and fix a few long-standing issues.

解决方案

把渲染/更新过程拆分为小块任务,通过合理的调度机制来控制时间(更细粒度、更强的控制力)

那么,面临 5 个子问题:

# 1. 拆什么?什么不能拆?

把渲染/更新过程分为 2 个阶段(diff + patch):

1.diff ~ render/reconciliation
2.patch ~ commit

diff 的实际工作是对比prevInstancenextInstance的状态,找出差异及其对应的 DOM change。diff 本质上是一些计算(遍历、比较),是可拆分的(算一半待会儿接着算)

patch 阶段把本次更新中的所有 DOM change 应用到 DOM 树,是一连串的 DOM 操作。这些 DOM 操作虽然看起来也可以拆分(按照 change list 一段一段做),但这样做一方面可能造成 DOM 实际状态与维护的内部状态不一致,另外还会影响体验。而且,一般场景下,DOM 更新的耗时比起 diff 及生命周期函数耗时不算什么,拆分的意义不很大

所以,render/reconciliation 阶段的工作(diff)可以拆分,commit 阶段的工作(patch)不可拆分

P.S. diff 与 reconciliation 只是对应关系,并不等价,如果非要区分的话,reconciliation 包括 diff:

This is a part of the process that React calls reconciliation which starts when you call ReactDOM.render() or setState(). By the end of the reconciliation, React knows the result DOM tree, and a renderer like react-dom or react-native applies the minimal set of changes necessary to update the DOM nodes (or the platform-specific views in case of React Native).

(引自Top-Down Reconciliation (opens new window)

# 2. 怎么拆?

先凭空乱来几种 diff 工作拆分方案:

  • 按组件结构拆。不好分,无法预估各组件更新的工作量

  • 按实际工序拆。比如分为getNextState(), shouldUpdate(), updateState(), checkChildren()再穿插一些生命周期函数

按组件拆太粗,显然对大组件不太公平。按工序拆太细,任务太多,频繁调度不划算。那么有没有合适的拆分单位?

有。Fiber 的拆分单位是 fiber(fiber tree 上的一个节点),实际上就是按虚拟 DOM 节点拆,因为 fiber tree 是根据 vDOM tree 构造出来的,树结构一模一样,只是节点携带的信息有差异

所以,实际上是 vDOM node 粒度的拆分(以 fiber 为工作单元),每个组件实例和每个 DOM 节点抽象表示的实例都是一个工作单元。工作循环中,每次处理一个 fiber,处理完可以中断/挂起整个工作循环

# 3. 如何调度任务?

分 2 部分:

  • 工作循环
  • 优先级机制

工作循环是基本的任务调度机制,工作循环中每次处理一个任务(工作单元),处理完毕有一次喘息的机会:

// Flush asynchronous work until the deadline runs out of time.
while (nextUnitOfWork !== null && !shouldYield()) {
  nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}

shouldYield就是看时间用完了没(idleDeadline.timeRemaining()),没用完的话继续处理下一个任务,用完了就结束,把时间控制权还给主线程,等下一次requestIdleCallback回调再接着做:

// If there's work left over, schedule a new callback.
if (nextFlushedExpirationTime !== NoWork) {
  scheduleCallbackWithExpiration(nextFlushedExpirationTime);
}

也就是说,(不考虑突发事件的)正常调度是由工作循环来完成的,基本规则是:每个工作单元结束检查是否还有时间做下一个,没时间了就先“挂起”

优先级机制用来处理突发事件与优化次序,例如:

  • 到 commit 阶段了,提高优先级

  • 高优任务做一半出错了,给降一下优先级

  • 抽空关注一下低优任务,别给饿死了

  • 如果对应 DOM 节点此刻不可见,给降到最低优先级

这些策略用来动态调整任务调度,是工作循环的辅助机制,最先做最重要的事情

# 4. 如何中断/断点恢复?

TIP

中断:检查当前正在处理的工作单元,保存当前成果(firstEffect, lastEffect),修改 tag 标记一下,迅速收尾并再开一个requestIdleCallback,下次有机会再做;

TIP

断点恢复:下次再处理到该工作单元时,看 tag 是被打断的任务,接着做未完成的部分或者重做

P.S.无论是时间用尽“自然”中断,还是被高优任务粗暴打断,对中断机制来说都一样

# 5. 如何收集任务结果?

Fiber reconciliation 的工作循环具体如下:

  1. 找到根节点优先级最高的 workInProgress tree,取其待处理的节点(代表组件或 DOM 节点)

  2. 检查当前节点是否需要更新,不需要的话,直接到 4

  3. 标记一下(打个 tag),更新自己(组件更新propscontext等,DOM 节点记下 DOM change),并为孩子生成 workInProgress node

  4. 如果没有产生子节点,归并 effect list(包含 DOM change)到父级

  5. 把孩子或兄弟作为待处理节点,准备进入下一个工作循环。如果没有待处理节点(回到了 workInProgress tree 的根节点),工作循环结束

通过每个节点更新结束时向上归并 effect list来收集任务结果,reconciliation 结束后,根节点的 effect list里记录了包括 DOM change 在内的所有 side effect

# 举一反三

既然任务可拆分(只要最终得到完整 effect list 就行),那就允许并行执行(多个 Fiber reconciler + 多个 worker),首屏也更容易分块加载/渲染(vDOM 森林)

并行渲染的话,据说 Firefox 测试结果显示,130ms 的页面,只需要 30ms 就能搞定,所以在这方面是值得期待的,而 React 已经做好准备了,这也就是在 React Fiber 上下文经常听到的待unlock的更多特性之一

# 源码简析

# 参考

文章整合自:

  1. 浅谈对 React Fiber 的理解 (opens new window)
  2. 完全理解 React Fiber (opens new window)
  3. 详解 React 16 的 Diff 策略 (opens new window)
【未经作者允许禁止转载】 Last Updated: 2/4/2024, 6:06:40 AM