本文引用了一些卡颂的文章[:React技术揭秘](react.iamkasong.com/preparation…)
感谢🙏
React 理念
我们可以从官网看到React的理念:
我们认为,React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。它在 Facebook 和 Instagram 上表现优秀。
可见,关键是实现快速响应。那么制约快速响应的因素是什么呢?
我们日常使用 App,浏览网页时,有两类场景会制约快速响应:
- 当遇到大计算量的操作或者设备性能不足使页面掉帧,导致卡顿。
- 发送网络请求后,由于需要等待数据返回才能进一步操作导致不能快速响应。
CPU 的瓶颈
第一个问题就是 CPU 的瓶颈,
第二个问题是 IO 的瓶颈,网络延迟是前端开发者无法解决的。但是可以将人机交互研究的结果整合到真实的 UI 中 。比如 loading, react 的 Suspence 和 useDeferredValue。 (这部分也是基于 fiber 实现的,暂时先不做深究)
主流浏览器刷新频率为 60Hz,即每(1000ms / 60Hz)16.6ms 浏览器刷新一次。
我们知道,JS 可以操作 DOM,GUI渲染线程与JS线程是互斥的。所以JS 脚本执行和浏览器布局、绘制不能同时执行。
浏览器的渲染进程有:
- GUI渲染线程
- JS 引擎线程
- 计时器线程
- 异步 http 请求线程
- 事件触发线程
在每 16.6ms 时间内,需要完成如下工作:
JS脚本执行 ----- 样式布局 ----- 样式绘制
当 JS 执行时间过长,超出了 16.6ms,这次刷新就没有时间执行样式布局和样式绘制了。
所以如果浏览器遇到一个重组件,或者一个超长的 js 处理逻辑,则JS 脚本执行时间过长,页面掉帧,造成卡顿。
如何解决这个问题呢?
答案是:在浏览器每一帧的时间中,预留一些时间给 JS 线程,React利用这部分时间更新组件(可以看到,在源码中,预留的初始时间是 5ms)。
当预留的时间不够用时,React将线程控制权交还给浏览器使其有时间渲染 UI,React则等待下一帧时间到来继续被中断的工作。
这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为时间切片(time slice)
老的 React 架构
React15 架构可以分为两层:
- Reconciler(协调器)—— 负责找出变化的组件, 比较当前虚拟Dom树与上次内存中储存的虚拟Dom树,找出变化的虚拟Dom节点
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上
React15 架构的缺点
在Reconciler中,mount的组件会调用mountComponent,update的组件会调用updateComponent。这两个方法都会递归更新子组件。
递归更新的缺点
由于递归执行,所以更新一旦开始,中途就无法中断。当在一个庞大的项目中。diff层级很深时,此时想要找到真正变化的部分就会耗费大量的时间。递归更新超过了 16ms,用户交互就会卡顿,页面响应变差、动画、手势等应用效果差。
新的 React 架构
React16 架构
React16 架构可以分为三层:
- Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
- Reconciler(协调器)—— 负责找出变化的组件
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上
可以看到,相较于 React15,React16 中新增了Scheduler(调度器) ,让我们来了解下他。
Scheduler(调度器)
既然我们以浏览器是否有剩余时间作为任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。
区分任务的优先级,高优先的任务优先进入Reconclier中
Reconciler(协调器)
我们知道,在 React15 中Reconciler是递归处理虚拟 DOM 的。让我们看看React16 的 Reconciler。
我们可以看见,更新工作从递归变成了可以中断的循环过程。每次循环都会调用shouldYield判断当前是否有剩余时间。
/** @noinline */
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
在 React16 中,Reconciler与Renderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟 DOM 打上代表增/删/更新的标记,类似这样:
export const Placement = /* */ 0b0000000000010;
export const Update = /* */ 0b0000000000100;
export const PlacementAndUpdate = /* */ 0b0000000000110;
export const Deletion = /* */ 0b0000000001000;
整个Scheduler与Reconciler的工作都在内存中进行。只有当所有组件都完成Reconciler的工作,才会统一交给Renderer。
Renderer(渲染器)
Renderer根据Reconciler为虚拟 DOM 打的标记,同步执行对应的 DOM 操作。
什么是 Fiber?
前面我们说到了 react15 中,在一个庞大的项目里,如果有某个节点发生变化,就会给 diff 带来巨大的压力,此时想要找到真正变化的部分就会耗费大量的时间。导致页面卡顿。
为了解决这一问题,React 团队花费两年时间,重写了 React 的核心算法reconciliation,在 React v16 中发布,为了区分 reconciler(调和器),将之前的 reconciler 称为 stack reconciler,之后称作 fiber reconciler(简称:Fiber) 。
简而言之,Fiber 实际上是一种核心算法,为了解决中断和树庞大的问题,也可以认为 Fiber 就是 v16 之后的虚拟 DOM。
先来看看 element、fiber、DOM 元素三者的关系:
- element 对象就是我们的 jsx 代码,上面保存了 props、key、children 等信息;
- DOM 元素就是最终呈现给用户展示的效果;
- 而 fiber 就是充当 element 和 DOM 元素的桥梁,简单来说,只要 element 发生改变,就会通过 fiber 做一次调和( Reconcile ),使对应的 DOM 元素发生改变
虚拟 DOM 如何转化成 Fiber 的?
万物始于 jsx,那么我们就从 jsx 入手,从而了解 Fiber。
先看看最常见的一段 jsx 代码:
function App() {
return (
<div>
i am
<span>KaSong</span>
</div>
)
}
import ReactDOM from 'react-dom/client';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<App />
);
注:React v18 将原先的 render 替换为 createRoot,也就是将原先的legacy 模式转化为 concurrent 模式。
ReactDOM.createRoot 结构:
最大的改变点是模式的转化,原先的 legacy 模式是同步的,而转化后的 concurrent 模式是异步的。可以说在 React v18 的版本中兼容了同步渲染和异步渲染。
当普通的 JSX 代码被 babel 编译成 React.createElement 的形式后,最终会走到beginWork 这个方法中。进行 fiber 节点的创建/更新。
Fiber中保存了什么?
为了更直观地查看 FiberNode 的属性,我们直接看对应的 type(位置在同目录下的 ReactInternalTypes)。
将 FiberNode 内容简单化为四个部分,分别是 Instance、Fiber、Effect、Priority。
Instance:这个部分是用来存储一些对应 element 元素的属性。
export type Fiber = {
tag: WorkTag, // 组件的类型,判断函数式组件、类组件等(上述的tag)
key: null | string, // key
elementType: any, // 元素的类型
type: any, // 与fiber关联的功能或类,如<div>,指向对应的类或函数
stateNode: any, // 真实的DOM节点
...
}
Fiber:这部分内容存储的是关于 Fiber 链表相关的内容和相关的 props、state。
export type Fiber = {
...
return: Fiber | null, // 指向父节点的fiber
child: Fiber | null, // 指向第一个子节点的fiber
sibling: Fiber | null, // 指向下一个兄弟节点的fiber
index: number, // 索引,是父节点fiber下的子节点fiber中的下表
ref:
| null
| (((handle: mixed) => void) & {_stringRef: ?string, ...})
| RefObject, // ref的指向,可能为null、函数或对象
pendingProps: any, // 本次渲染所需的props
memoizedProps: any, // 上次渲染所需的props
updateQueue: mixed, // 类组件的更新队列(setState),用于状态更新、DOM更新
memoizedState: any, // 类组件保存上次渲染后的state,函数组件保存的hooks信息
dependencies: Dependencies | null, // contexts、events(事件源) 等依赖
mode: TypeOfMode, // 类型为number,用于描述fiber的模式
...
}
Effect:副作用相关的内容。
export type Fiber = {
...
flags: Flags, // 用于记录fiber的状态(删除、新增、替换等)
subtreeFlags: Flags, // 当前子节点的副作用状态
deletions: Array<Fiber> | null, // 删除的子节点的fiber
nextEffect: Fiber | null, // 指向下一个副作用的fiber
firstEffect: Fiber | null, // 指向第一个副作用的fiber
lastEffect: Fiber | null, // 指向最后一个副作用的fiber
...
}
Priority:优先级相关的内容。
export type Fiber = {
...
lanes: Lanes, // 优先级,用于调度
childLanes: Lanes,
alternate: Fiber | null,
actualDuration?: number,
actualStartTime?: number,
selfBaseDuration?: number,
treeBaseDuration?: number,
...
}
链表之间如何链接的?
Fiber 结构的创建和更新都是深度优先遍历,遍历顺序为:
1. rootFiber beginWork
2. App Fiber beginWork
3. div Fiber beginWork
4. "i am" Fiber beginWork
5. "i am" Fiber completeWork
6. span Fiber beginWork
7. span Fiber completeWork
8. div Fiber completeWork
9. App Fiber completeWork
10. rootFiber completeWork
注意
之所以没有 “KaSong” Fiber 的 beginWork/completeWork,是因为作为一种性能优化手段,针对只有单一文本子节点的Fiber,React会特殊处理。
这里提到的beginWork/completeWork,在接下来的流程里会详细说。
React 18 并发机制
在 React v18 中,最重要的一个概念就是并发(concurrency) 。其中 useTransition 、useDeferredValue 的内部原理都是基于并发的,可见并发的重要性。
什么是并发?
并发: 在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。
通俗来讲,并发是具备处理多个任务的能力,但不是同时执行,而是交替执行,每次依旧只能执行一个
React 中的并发
来看看 React 官网是如何描述的:
官网描述并发属于新的一种幕后机制,它允许在同一时间内,准备多个版本的 UI,即多个版本的更新。
时间切片
首先,我们要知道一个前置知识点:window.requestIdleCallback。
时间切片的本质是模拟实现requestIdleCallback。
每一帧详细的执行流程:
- 用户的操作:如 click、input 等;
- 执行 JS 代码,宏任务和微任务;
- 渲染前执行 resize/scroll 等操作,再执行 requestAnimationFrame 回调;
- 渲染页面,绘制 html、css 等;
- 执行 RIC(requestIdleCallback 回调函数),如果前面的步骤执行完成了,一帧还有剩余时间,就会执行该函数。
而 React 是将任务进行拆解,然后放到 requestIdleCallback 中执行。比如一个 300ms 的更新,拆解为 6 个 50ms 的更新,然后放到 requestIdleCallback 中,如果一帧之内有剩余就会去执行,这样的话更新一直在继续,也可以达到交互的效果。
但是 react 并没有采用requestIdleCallback,有以下原因:
- 浏览器兼容性
- 触发频率不稳定,受很多因素影响。比如当我们的浏览器切换 tab 后,之前 tab 注册的requestIdleCallback触发的频率会变得很低
而 react 是采用了MessageChannel 来模拟 requestIdleCallback 实现了时间切片。
所以Scheduler将需要被执行的回调函数作为MessageChannel的回调执行。如果当前宿主环境不支持MessageChannel,则使用setTimeout。
优先级
优先级是 React 中非常重要的模块,分为两种方式:
- 紧急更新(Urgent updates): 用户的交互,如:点击、输入等,直接影响用户体验的行为都属于紧急情况;
- 过渡更新(Transition updates): 页面跳转等操作属于非紧急情况。
优先级的模块非常大,这里不做过多的介绍。我们只需要知道,所有的操作都有对应优先级,React 会先执行紧急的更新,其次才会执行非紧急的更新。
并发模式的实现
关于并发模式,整体可分为三步,分别是:
- 每个更新,都会分配一个优先级(lane),用于区分紧急程度;
- 将不紧急的更新拆解成多段,并通过宏任务的方式将其合理分配到浏览器的帧当中,使得紧急任务可以插入进来;
- 优先级高的更新会打断优先级低的更新,等优先级高的更新执行完后,再执行优先级低的任务。
举例:
并发是通过交替执行来实现的,也就是这样:
上面是两个 setState 引起的两个渲染流程,下面的优先级更高。先处理上面那次渲染的 1、2、3 的 fiber 节点,然后处理下面那次渲染的 1、2、3、4、5、6 的 fiber 节点,之后继续处理上面那次渲染的 4、5、6 的 fiber 节点。
每处理一个 fiber 节点,都判断下是否打断,shouldYield 返回 true 的时候就终止这次循环。
Diff 算法
该过程是通过Reconciler(协调器)进行的,也就是发生在 render 阶段。
双缓存 Fiber 树
在React中最多会同时存在两棵Fiber树。当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树。
current Fiber树中的Fiber节点被称为current fiber,workInProgress Fiber树中的Fiber节点被称为workInProgress fiber,他们通过alternate属性连接。
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;
React应用的根节点通过使current指针在不同Fiber树的rootFiber间切换来完成current Fiber树指向的切换。
即当workInProgress Fiber树构建完成交给Renderer渲染在页面上后,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树。
每次状态更新都会产生新的workInProgress Fiber树,通过current与workInProgress的替换,完成DOM更新。
-
我们点击p节点触发状态改变,这会开启一次新的render阶段并构建一棵新的workInProgress Fiber 树。
- workInProgress Fiber 树在render阶段完成构建后进入commit阶段渲染到页面上。渲染完毕后,workInProgress Fiber 树变为current Fiber 树。
一个DOM节点在某一时刻最多会有4个节点和他相关。
- current Fiber。如果该DOM节点已在页面中,current Fiber代表该DOM节点对应的Fiber节点。
- workInProgress Fiber。如果该DOM节点将在本次更新中渲染到页面中,workInProgress Fiber代表该DOM节点对应的Fiber节点。
- DOM节点本身。
- JSX对象。即ClassComponent的render方法的返回结果,或FunctionComponent的调用结果。JSX对象中包含描述DOM节点的信息。
Diff 算法的本质就是对比 current Fiber 和 JSX 对象,生成 workInProgress 的过程
Diff 的瓶颈以及 React 如何应对
由于Diff操作本身也会带来性能损耗,React文档中提到,即使在最前沿的算法中,将前后两棵树完全比对的算法的复杂程度为 O(n 3 ),其中n是树中元素的数量。
如果在React中使用了该算法,那么展示1000个元素所需要执行的计算量将在十亿的量级范围。这个开销实在是太过高昂。
为了降低算法复杂度,React的diff会预设三个限制:
- 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。
- 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。
beginWork
“递”阶段
首先从rootFiber开始向下深度优先遍历。为遍历到的每个Fiber节点调用beginWork 方法。
该方法会根据传入的Fiber节点创建子Fiber节点,并将这两个Fiber节点连接起来。
当遍历到叶子节点(即没有子组件的组件)时就会进入“归”阶段。
completeWork
在“归”阶段会调用completeWork处理Fiber节点。
当某个Fiber节点执行完completeWork,如果其存在兄弟Fiber节点(即fiber.sibling !== null),会进入其兄弟Fiber的“递”阶段。
如果不存在兄弟Fiber,会进入父级Fiber的“归”阶段。
“递”和“归”阶段会交错执行直到“归”到rootFiber。至此,render阶段的工作就结束了。
总结:beginWork是位置的 diff,completeWork是属性的 diff
单节点 diff 和多节点 diff
单节点:
React通过先判断key是否相同,如果key相同则判断type是否相同,只有都相同时一个DOM节点才能复用。
多节点:
- 判断当前节点的更新属于哪种情况
- 如果是新增,执行新增逻辑
- 如果是删除,执行删除逻辑
- 如果是更新,执行更新逻辑
按这个方案,其实有个隐含的前提——不同操作的优先级是相同的
但是React团队发现,在日常开发中,相较于新增和删除,更新组件发生的频率更高。所以Diff会优先判断当前节点是否属于更新。
注意
在我们做数组相关的算法题时,经常使用双指针从数组头和尾同时遍历以提高效率,但是这里却不行。
同级的Fiber节点是由sibling指针链接形成的单链表,即不支持双指针遍历。
基于以上原因,Diff算法的整体逻辑会经历两轮遍历:
第一轮遍历:处理更新的节点。
第二轮遍历:处理剩下的不属于更新的节点。
commit 阶段 (renderer 渲染器)
同步执行,转换成真实的 dom
处理副作用;
除此之外,一些生命周期钩子(比如componentDidXXX)、hook(比如useEffect)需要在commit阶段执行。
useEffect的触发、优先级相关的重置、ref的绑定/解绑等。。
总结:
schedule,异步可终端更新,高优先级任务打断低优先级任务。