React16核心架构
组件在渲染时,会创建一个新的虚拟 DOM,React 会利用 diff 算法将新的虚拟 DOM 树和现有的进行比较,通过打补丁的方式,将发生改变的元素替换,最后再转变为真实的 DOM 结构,开发者只需要关注组件的状态即可,框架会在用户无感知的情况下,将更新的数据同步到视图中,也就是将状态渲染为对应的视图,这个过程也称为调和(Reconciliation)
Fiber诞生的原因
解决同步更新的局限性
一次调和的过程包含了很多步骤,包括调用各个生命周期函数、计算对比虚拟 DOM 树、DOM 树的更新,这些都是同步进行的,一旦开始执行就不会中断,直到所有的工作流程全部结束为止,这个时候会一直占着浏览器资源,导致用户的触发得不到及时的响应,会出现明显的卡顿,等这次调和结束之后,刚才的触发操作会突然响应,造成非常不好的体验
调和的过程是同步的,组件层层嵌套,逐渐深入,在更新完所有组件之前不会停止,函数调用非常深,且很长时间不会返回:
为了解决这个问题,Fiber应运而生
Fiber思想
其本质上是一种数据结构,可以用纯js对象来表示,是一种比线程控制更精密的并发处理机制,目标是提高在动画、布局和手势等领域的适用性
核心思想:将原本同步的、阻塞的渲染过程变为异步的、可中断的
最重要的功能:增量渲染(能够将渲染工作分成块,并将其分散到多个帧上)
简述
通过 Scheduler 调度器,将更新耗时的任务进行时间分片,给每一个工作单元分配一定的时间,每一片运行的时间很短,在每个小片执行完任务后,主线程被释放,此时会先去执行其他高优先级的任务(比如 input 输入),虽然总的执行时间不变,但这样做唯一的线程就不会被独占,页面能够对高优先级任务作出及时响应,用户就不会感知到卡顿了
Fiber 分片模式下,函数的堆栈调用如下图所示,其中每一个波谷代表深入执行一个分片的过程,波峰代表一个分片执行结束,交还控制权,寻找下一个执行分片
浏览器一帧
浏览器的画面是通过一帧帧渲染出来的,一般来说,渲染的帧率和设备的刷新率要保持一致,一般情况下,设备的屏幕刷新率为一秒钟60次,当每秒绘制的帧数超过60时,页面渲染比较流畅,反之则会出现一定程度的卡顿
可以看到流程是这样的:
- 先处理输入事件,让用户得到最早的反馈
- 再处理定时器,需要检查定时器是否到时间,并执行对应的回调
- 接下来处理 Begin Frame(开始帧),即每一帧的事件,包括 window.resize、scroll、media query change 等
- 接下来执行请求动画帧 requestAnimationFrame(rAF),即在每次绘制之前,会执行 rAF 回调
- 紧接着进行 Layout 操作,包括计算布局和更新布局,即这个元素的样式是怎样的,它应该在页面如何展示
- 接着进行 Paint 操作,得到树中每个节点的尺寸与位置等信息,浏览器针对每个元素进行内容填充
到这时以上的六个阶段都已经完成了,接下来处于空闲阶段,可以在这时执行 requestIdleCallback 里注册的任务
(浏览器不一定执行所有步骤,具体情况取决于哪些步骤是必需的)
时间分片
任务如何进行分片
在 Reconciliation 阶段任务分片可以被打断,用来执行优先级高的任务,所以如何拆分任务很重要
如何知道浏览器是否有空闲时间呢?
从上述流程得知浏览器是一帧帧执行的,在执行完子任务后,在下一帧之前主线程会有一定的空闲时间,requestIdleCallback 可以在这个空闲期调用空闲期回调,从而执行一些任务
如果浏览器一直处于忙碌状态,requestIdleCallback 注册的任务可能永远不会执行,此时可以通过 requestIdleCallback 的第二个参数指定一个超时时间,如果超过这个时间,回调函数会被执行
浏览器通过 requestIdleCallback 分配执行时间片,我们按照规定在这个时间内将任务执行完毕,并将控制权交还给浏览器
(为了兼容每个浏览器,React内部自己实现了一个)
Fiber需要分解渲染任务,然后根据优先级使用API调度,异步执行任务:
- 低优先级任务:由 requestIdleCallback 处理,如数据预加载
- 高优先级任务:由 requestAnimationFrame 处理,如动画、输入响应
requestIdleCallback
requestIdleCallback(callback, options)
- callback:空闲时执行的任务,回调函数接收 IdleDeadline 对象作为入参,该对象包含:
-
- didTimeout:布尔值,表示任务是否超时
- timeRemaining():表示当前帧剩余时间,也就是该任务的剩余时间
- options:只有一个参数 timeout,表示超过这个时间之后,如果任务还没执行,则不必等待空闲,强制执行
// 执行低优先级任务
requestIdleCallback(nonEssentialWork, { timeout: 2000 });
let tasks = {
length: 4
}
function nonEssentialWork (deadline) {
// 当任务队列中还有剩余任务时,每帧还有剩余时间或任务超时,执行任务
while (tasks.length > 0 && (deadline.timeRemaining() > 0 || deadline.didTimeout)) {
tasks.length = tasks.length - 1
}
if (tasks.length > 0) {
requestIdleCallback(nonEssentialWork);
}
}
一个子任务多大合适呢?
子任务的大小由当前帧的剩余时间和任务的优先级动态来决定,React 会尝试将渲染工作拆分成较小的任务,每个任务大约在 5 毫秒内完成,这是基于浏览器的帧时间(大约 16.6 毫秒)来计算的,以确保在一帧内可以执行多个小任务,避免单个更新任务过长导致的界面卡顿
任务调度时,怎么知道哪些任务优先级高呢? 请继续往下看
优先级
优先级级别
| 等级 | 定义 | 例子 |
|---|---|---|
| ImmediatePriority | 用于需要立即同步执行的更新 | 输入响应 |
| UserBlockingPriority | 用户操作导致的更新 | 点击提交按钮,提交的反馈(如按钮变为加载状态)应该尽快显示 |
| NormalPriority | 默认的更新优先级,适用于大多数渲染更新 | 数据显示,当从服务器接收到新数据并需要更新列表时,这些更新通常会使用正常的优先级 |
| LowPriority | 不紧急的更新,适用于可以延迟执行,不会显著影响用户体验的更新 | 从服务器加载额外的数据或预加载资源,这些任务很重要,但不需要立即完成,可以稍后处理 |
| IdlePriority | 不紧急的更新,通常用于性能优化 | 无需立即显示的日志记录或清理缓存,这些任务只有在主线程完全空闲时才会执行 |
| NoPriority | 非常不紧急的任务,适用于那些即使不执行也不会有问题的任务 | 某些类型的分析跟踪,这些任务可以被推迟很长时间执行,甚至在应用的整个生命周期中都不执行也不会影响用户体验 |
优先级策略
React 的优先级机制是自动的,由 React 内部的调度系统(Scheduler)和时间切片机制(Time Slicing)共同决定,在实际开发中,通常不需要手动设置优先级,不过开发者可以通过特定的 API 或者 React 自身的更新机制来暗示或者指定更新的优先级:
- 隐式优先级:React 根据更新的上下文来自动设定优先级。例如,直接由用户交互触发的更新(如点击按钮)通常会被赋予更高的优先级
- 调度器(Scheduler) :React 的调度器库允许指定更新的优先级,React 18 引入了 startTransition API,用来降低更新的优先级,允许标记某些更新为过渡期间的更新,这些更新会被视为低优先级
- requestIdleCallback:使用 requestIdleCallback 来执行低优先级的更新,可以利用这个 API 在主线程空闲时执行一些工作
- 手动控制:通过直接使用调度器库中的 API,如 scheduler.unstable_scheduleCallback,开发者可以更细粒度地控制任务的执行时机
- useEffect 的依赖项:使用 useEffect 钩子时,如果提供了依赖项数组作为第二个参数,React 会在其依赖项变更时安排更新。这些更新通常会在组件渲染完成后,浏览器的空闲时间内进行
- 条件渲染:可以通过条件渲染来控制何时执行更新。例如,只有当某个条件满足时才渲染组件,这可以减少不必要的渲染工作
- 异步更新:可以将某些更新标记为异步,这些更新不会立即执行,而是在稍后的时间点执行,通常具有较低的优先级
Fiber树构建
React 运行时存在3种实例:
- Elements:表示 JSX 代码,描述 UI 长什么样子,这些会被创建成 element 对象的形式,保存了 type、props、children 等信息
- Instances:React 维护的 vDOM tree node
- DOM:真实渲染的 DOM 节点
Fiber 是一个 js 对象,创建是根据 React 元素来的,在整个 React 构建的虚拟 DOM 中,每一个元素都有对应的 Fiber,从而构建出一棵 Fiber 树,每一个组件的实例对应的 Fiber 实例负责管理组件实例的更新、渲染,以及和其他的 Fiber 实例的联系,每个 Fiber 不仅仅包含元素的信息,还有其他信息,方便 Scheduler 来调度
静态数据结构的属性
作为一种静态数据结构,保存了组件相关的信息:
type Fiber = {
// 作为静态数据结构的属性
// 标记不同的组件类型
tag: WorkTag,
// ReactElement里面的key,唯一标识,如果出现列表的时候,需要为每一个item指定key
key: null | string,
// ReactElement.type,createElement的第一个参数
elementType: any,
// 异步组件resolved之后返回的内容,一般是function或者class
type: any,
// 当前组件实例的引用,比如浏览器环境就是DOM节点
stateNode: any,
}
形成Fiber树的属性
作为一种架构,这些属性用来形成Fiber Tree:
type Fiber = {
// 指向其在Fiber节点树中的parent,用来在处理完这个节点之后向上返回
return: Fiber | null,
// 指向自己的第一个子节点
child: Fiber | null,
// 指向自己的兄弟结构
sibling: Fiber | null,
index: number,
...
}
mount 时生成 Fiber 树
这棵树通常被称为 current 树 (当前树,记录当前页面的状态)
每一个 Fiber 节点与 Virtual DOM 一一对应,所有 Fiber 节点 连接起来形成 Fiber Tree,这是个单链表树结构,因为使用了链表结构,即使处理流程被中断了,我们随时可以从上次未处理完的 Fiber 继续遍历下去
以前的协调算法是递归调用,利用 DOM 树级关系构成的栈递归,而 Fiber 作为扁平化的链表数据存储结构,通过 child 找子节点、return 找父节点、sibling 找兄弟节点,从递归遍历改为循环遍历,然后配合 requestIdleCallback API,实现任务拆分、中断与恢复
如下图所示,比如在 h1(hello) 中断了,那么下一次就会从 p(world) 开始处理:
这些指针的作用是什么呢?
- 构建树结构:child 和 sibling 指针使得 React 能够构建起一个树状结构,这与虚拟 DOM 树的结构相对应
- 高效遍历:通过这些指针,React 可以高效地遍历整个虚拟 DOM 树,无论是自顶向下还是自底向上
- 任务调度:Fiber 架构使得 React 可以更细粒度地调度任务,return 指针允许 React 在任务之间进行切换,实现时间切片和优先级调度
- 生命周期管理:这些指针帮助 React 管理组件的生命周期,包括挂载、更新和卸载过程
- 错误恢复:当渲染过程中出现错误时,return 指针可以帮助 React 回退到出错组件的父级,从而实现错误边界机制
- 异步渲染:Fiber 架构通过这些指针实现了异步渲染,允许 React 在渲染过程中让出控制权,然后继续执行,这有助于提高应用的响应性
- 更新优先级:不同的更新可以被赋予不同的优先级,React 可以根据这些优先级和指针来决定何时执行更新
Fiber更新机制
双缓存机制
当使用 canvas 绘制动画时,如果上一帧计算量比较大,导致清除上一帧画面到绘制当前帧画面之间有较长间隙,就会出现白屏
为了解决这个问题,canvas 在内存中绘制当前动画,完成后直接用当前帧替换上一帧动画,省去了两帧替换的计算时间,因此不会出现从白屏到画面出现的闪烁
React 也践行了这个理念,在 React 中最多会同时存在两颗 Fiber 树,当前真正显示在屏幕中的是 current Fiber Tree,在内存中构建的是 workInProgress Fiber Tree,两棵树通过 alternate 指针互相连接:
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;
在下一次渲染开始时,React 会将当前的 workInProgress 树作为新的渲染基础,这意味着上一次的 workInProgress 树在完成渲染后成为了 current 树,而新的工作将在新的 workInProgress 树上进行
每个 Fiber 上都有个 alternate 属性,也指向一个 Fiber,创建 workInProgress 节点时优先取 alternate,没有的话就创建一个
其中,创建 workInProgress Fiber 的过程也是一个 diff 的过程,diff 完成之后会生成一个 Effect List,这个 Effect List 就是最终 Commit 阶段用来处理副作用的列表
以前面生成Fiber树的例子继续了解
update 时生成 workInProgress 树
接下来,我们更新上述的代码片段,在 update 时,react 会根据新的 jsx 内容,调用 createWorkInProgress 函数,创建新的 workInProgress 树,通过深度优先遍历,对发生改变的 Fiber 打上不同的 flags 副作用标签,并通过 firstEffect、nextEffect 、lastEffect 字段形成 Effect List 链表,生成的 workInProgress 树如下图所示:
双缓存Fiber树
如上所述,current Fiber 和 workInProgress Fiber 中对应的 alternate 指针互相指向,生成的双缓存 Fiber 树如下图所示:
整体更新流程
总的来说,Fiber分为两个阶段,一个是可中断的调和阶段,一个是不可中断的提交阶段
调和阶段
调和阶段以 Fiber 树为基础,把每个 Fiber 作为一个工作单元,自顶向下逐节点构造 workInProgress 树
提交阶段
提交过程阶段是一口气直接做完(同步执行),不被控制和中止,这个阶段的实际工作量是比较大的,所以尽量不要在后3个生命周期函数里干重活
-
处理 effect list(包括3种处理:更新 DOM 树、调用组件生命周期函数以及更新 ref 等内部状态)
-
该阶段结束时,所有更新都 commit 到 DOM 树上了
优化前后对比
729个节点
Fiber对生命周期的影响
上面说过,在经过 Fiber 处理之后,一次调和的过程分为两个阶段,分别是 render 阶段和 commit 阶段,render 是指虚拟 DOM 和真实 DOM 进行 diff 的过程,在这个过程中会收集变化,在 commit 阶段全部更新到真实 DOM 上
而 render 的过程可以被打断的,在被更高优先级的任务打断后,刚才的 render 不管走到哪一步都会被中断重来,这时候原有的生命周期就会产生一些问题了
举个例子:一个正在执行的任务已经调用了 componentWillUpdate 钩子,但发现时间分片已经用完了,此时只能交还控制权,申请下一个时间分片,此时有另一个高优先级的任务先去执行,无论刚才的任务走到哪里,都只能重头再来,这样会导致在 render 之前的钩子可能会被多次调用
生命周期-旧
可以从上图看到,render 前有四个生命周期会被调用,分别是 componentWillMount、componentWillreceiveprops、shouldComponentUpdate 和 componentWillUpdate
shouldComponentUpdate
是一个性能优化的生命周期,不直接影响组件的渲染过程,只是用来控制是否需要调用diff算法执行更新,返回值是可以预料的,所以即使调用多次也不会产生意想不到的结果
componentWillMount、componentWillReceiveProps、componentWillUpdate
无法预料开发者会在这几个生命周期中做什么操作,很可能会产生副作用,它们可能会在数据变化时被调用,但此时DOM还未更新,因此任何对DOM的直接操作或对组件状态的直接修改都可能带来不可预见的结果:
-
componentWillMount
在 React 16.3 之前,componentWillMount 在组件挂载到 DOM 之前被调用,由于它在渲染发生之前就执行,开发者可能会在这个生命周期中执行一些初始化操作。但是,如果在这个生命周期中进行异步操作或修改组件状态,可能会导致以下几种问题:
- 异步操作:如果在 componentWillMount 中发起异步请求,并在请求完成后修改状态,那么这些状态的改变不会触发重新渲染,因为组件已经渲染过了
- 直接修改状态:直接修改组件状态而不经过 setState 会导致状态更新无法被 React 追踪,从而绕过了 React 的状态更新机制
-
componentWillReceiveProps
在组件将要接收新的属性之前被调用,由于它允许开发者根据新的属性来更新组件状态,这可能会导致以下问题:
- 循环依赖:如果在这个生命周期中根据新的属性值立即更新状态,而状态更新又触发了重新渲染,重新渲染中又调用了 componentWillReceiveProps,这样就形成了无限循环,导致性能问题
- 不必要的渲染:频繁地在这个生命周期中更新状态,即使这些更新并不是必要的,也会导致不必要的渲染,影响性能
-
componentWillUpdate
在组件将要更新之前被调用,它允许开发者在更新之前执行一些操作,但同样存在问题:
- 直接操作 DOM:在这个生命周期中直接操作 DOM 可能会导致与 React 的渲染过程冲突,因为此时 React 正在准备更新 DOM
- 状态更新:在这个生命周期中更新状态会导致在当前渲染周期中多次渲染,因为状态更新会立即触发额外的渲染
为了避免上述问题,React 废弃了三个不安全的生命周期,引入了两个新的安全且符合 Fiber 运行机制的生命周期
生命周期-新
新加了两个生命周期:
getDerivedStateFromProps
替代 componentWillReceiveProps,用于根据 props 计算状态,但不触发额外渲染
getSnapshotBeforeUpdate
替代 componentWillUpdate,允许在更新前获取一些信息(比如滚动位置或当前的渲染输出),并在 componentDidUpdate 中使用这些信息,如:
- 当组件的更新可能会更改页面的滚动位置时,可以在 getSnapshotBeforeUpdate 中获取当前的滚动位置,并在 componentDidUpdate 中将滚动位置恢复回来,以避免因更新导致的滚动位置丢失
- 当渲染结果可能依赖于之前的渲染时,可以使用这个生命周期来获取前一次渲染的一些信息
为什么vue不需要Fiber
根本的原因:React 和 vue 响应式实现方式的不同
- vue:基于 template 和 watcher 的组件级更新,把每个更新任务分割得足够小,对数据进行劫持,当数据发生变化时,知道需要找谁更新,不需要使用到 Fiber 架构就能将任务进行更细粒度的拆分
- React: 不管在哪里调用 setState,都是从根节点开始遍历的,更新任务还是很大,需要利用 Fiber 将大任务分割为多个小任务,可以中断和恢复,不阻塞主进程执行高优先级的任务
Fiber架构对前端生态的影响
背景
JS 是单线程,会带来以下问题:
- 如果脚本执行的时间过长,用户体验(特别是响应能力)就会变差,如用户正在输入一些内容,而此时 JS 正在执行大量的逻辑,那么就无法及时对用户的输入作出响应
- 最佳实践是将复杂的逻辑拆分为更小的任务,在页面加载时,页面可以运行一些 JS 逻辑,然后将控制权交还给浏览器,此时浏览器可以检查任务队列,看是否需要响应用户输入,接着再继续运行 JS,这种方式显然比第一种要好,但随之也带来了其他问题:
每次将控制权交还浏览器时,浏览器都会花时间检查任务队列,处理完事件后,再获取下一个 JS 代码逻辑,当浏览器更快地响应事件时,页面的整体加载时间会变慢,且当用户交互比较多时,页面加载会非常慢;假如不那么频繁地进行上面的过程,浏览器响应用户事件花费的时间就越长
isInputPending
调用方式:window.navigator.scheduling.isInputPending()
Facebook 在 Chromium 中提出并实现了 isInputPending() API,这是第一个将中断的概念用于浏览器用户交互的 API,它可以提高网页的响应能力,但是不会对性能造成太大影响,并且允许 JS 能够检查事件队列而不会将控制权交于浏览器
即便不使用 React,我们也可以利用这个 API,来平衡 JS 执行、页面渲染及用户输入之间的优先级,通过合理使用 isInputPending 方法,我们可以在页面渲染时及时响应用户输入,并且当有长耗时的 JS 任务要执行时,可以通过 isInputPending 来中断 JS 的执行,将控制权交还给浏览器来执行用户响应
(目前 isInputPending API 仅在 Chromium 87 版本开始提供,其他浏览器未实现)
总结
- 加载速度快:一次性执行首屏需要执行的逻辑,但输入响应能力差
- 输入响应快:将复杂的逻辑拆分为小块任务,以保证对外界输入的响应,但加载慢
- 两者兼有:JS 检查是否有用户输入,而不会产生将控制权交给浏览器并返回的开销