从 React 的源码看原理
React 的实现原理
React 被称为应用级框架的原因在于——其每次更新流程都是从应用的根节点开始,遍历整个应用。
对比其他框架:
Vue3 的更新流程开始于组件
Svelte 的更新流程开始于元素
作为应用级框架,React 的实现原理很简单,按照步骤可以概括为:
触发事件,改变自变量,开启更新流程
执行 VDOM 相关操作,在 React 中被称为 reconcile
根据步骤 2 计算出的“需要变化的 UI”执行对应的 UI 操作,在 React 中被称为 commit
基于每次更新流程都遍历整个应用这个原理,React 甚至不需要确定哪个自变量发生了变化。由于任何自变量的变化都会开启一次遍历应用的更新流程,因此 React 不需要“细粒度更新”和 AOT
React 快速响应原理
两个场景会制约快速响应
CPU 瓶颈
网络 IO 瓶颈
解决 CPU 瓶颈
CPU 瓶颈的原因
主流浏览器刷新频率为 60Hz,即每(1000ms / 60Hz)16.6ms 浏览器刷新一次。我们知道,JS 可以操作 DOM,GUI 渲染线程与 JS 线程是互斥的。所以 JS 脚本执行和浏览器布局、绘制不能同时执行。
在每 16.6ms 时间内,需要完成如下工作:JS 脚本执行 ----- 样式布局 ----- 样式绘制,当 JS 执行时间过长,超出了 16.6ms,这次刷新就没有时间执行样式布局和样式绘制了。
如何解决
在浏览器每一帧的时间中,预留一些时间给 JS 线程,React 利用这部分时间更新组件(可以看到,在源码中,预留的初始时间是 5ms)。
当预留的时间不够用时,React 将线程控制权交还给浏览器的渲染引擎,使其有时间渲染 UI,React 则等待下一帧时间到来继续被中断的工作。
这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为时间切片(time slice)
所以,解决 CPU 瓶颈的关键是实现时间切片,而时间切片的关键是:将同步的更新变为可中断的异步更新。
扩展:页面渲染和事件循环
默认情况下,浏览器的每个 Tab 页对应一个渲染进程,渲染进程包含“主线程”(包括 JS 线程和渲染线程)、事件触发线程(I/O 线程)、定时器触发线程、合成线程等多个线程。
主线程的工作非常繁忙。要处理事件响应,执行 JS 代码,处理 DOM,计算样式,处理布局等。
主线程维护着一个事件循环,所有参与调度的任务都会加入到任务队列中,这些任务不仅来自于线程内部,也可能来自于外部。主线程会在事件循环中不断地从任务队列中取出任务执行,随着循环一直执行,新加入的任务会位于队列末尾,之前加入的任务会被取出执行。
其他进程通过 IPC 将任务发送给渲染进程的事件触发线程(I/O 线程),事件触发线程再将任务发送给主线程的任务队列。
例如:
1. 点击鼠标后,浏览器进程通过 IPC 将“点击事件”发送给渲染进程的事件触发线程,事件触发线程再将其发送给任务队列。
2. 资源加载完成后,网络进程通过 IPC 将“资源加载完成”事件发送给渲染进程的事件触发线程,事件触发线程再将其发送给任务队列。
任务队列分为宏任务队列和微任务队列。因为加入宏任务队列的新任务需要等待队列中其他任务都执行完后才能执行(此时任务队列中的任务被称为宏任务),这对于“突发情况下需要优先执行的任务”是不利的,为了解决时效性问题,在宏任务执行过程中可以产生微任务,保存在主线程的微任务队列中。在该宏任务执行结束前,主线程会遍历微任务队列,将该宏任务执行过程中产生的微任务批量执行。
扩展:浏览器渲染
在宏任务中,有一类“与渲染相关的任务”,包括:
构建 DOM:将 HTML 解析为 DOM 树
构建 CSSOM:将 CSS 解析为 CSSOM 树
构建 Render Tree:将 DOM 树和 CSSOM 树结合,生成 Render Tree
布局(Layout):构建布局树,布局树会移除 Render 树中不可见的元素,比如 display: none 的元素,并计算可见部分的几何位置
分层(Layer):将页面划分为多个图层,一些层叠上下文 CSS 属性(比如 z-index、opacity、position)、“由于显式不全被裁剪的内容”等回事 DOM 元素形成独立的图层,每个图层可以单独绘制,这样可以提高绘制效率
绘制(Paint):为每个图层生成包含“绘制信息”的绘制列表,将绘制列表提交给渲染进程的合成器线程用于绘制
执行上述任务的流程被称为“渲染流水线”。每次执行流水线时,上述所有任务并不一定全部执行。
例如:
1. 当通过 JS 或 CSS 修改 DOM 元素的几何属性(如宽高、位置等)时,会触发完整的渲染流水线,这种情况称为重排。
2. 当修改的属性不涉及几何属性(如颜色、背景等)时,会省略流水线中的 Layout 和 Layer 过程,这种情况称为重绘。
3. 当修改“不涉及重排、重绘的属性”(比如 transform 属性)时,会省略流水线中的 Layout、Layer 和 Paint 过程,仅执行合成线程的绘制工作,这种情况称为合成。
按照性能高低对这些情况排序,重排 < 重绘 < 合成。这也是 CSS 动画性能优于JS 动画性能的原因,前者可能仅涉及合成,后者可能涉及重排和重绘。
绘制的最终产物是是一张图片,这张图片被发送给显卡后即可显示在屏幕上。屏幕的刷新频率为 60Hz,即每秒刷新 60 次,每 16.6ms 刷新一次,所以当屏幕的刷新频率跟显卡更新频率一致时,用户不会感知卡顿。
渲染流水线可以简单理解为也是宏任务,那么“执行 JS” 与渲染流水线同为宏任务,如果 JS 执行时间过长,导致渲染流水线绘制图片的速度跟不上屏幕的刷新频率,就会造成页面掉帧,用户就会感知到卡顿。
解决网络 IO 瓶颈
网络延迟是前端开发者无法解决的。如何在网络延迟客观存在的情况下,减少用户对网络延迟的感知?
React 给出的答案是将人机交互研究的结果整合到真实的 UI 中。例如,研究表明,在屏幕之间进行切换时显示过多的中间加载状态会使切换感觉更慢。同样,从研究中得知,像悬停和文本输入这样的交互需要在非常短的时间内处理,而点击和页面切换可以稍微等待一下,而不会感觉到延迟。Concurrent Mode 内部使用的不同“优先级”大致对应于人类感知研究中的交互类别。
为此,React 实现了 Suspense 功能及配套的 hook——useDeferredValue。
而在源码内部,为了支持这些特性,同样需要将同步的更新变为可中断的异步更新。
异步可中断更新
异步可中断更新可以理解为:更新在执行过程中可能会被打断(浏览器时间分片用尽或有更高优任务插队),当可以继续执行时恢复之前执行的中间状态。
React 架构
React 15 架构
React15 架构可以分为两层:
Reconciler(协调器)—— 负责找出变化的组件
Renderer(渲染器)—— 负责将变化的组件渲染到页面上
Reconciler(协调器)
在 React 中可以通过 this.setState、this.forceUpdate、ReactDOM.render 等 API 触发更新。
每当有更新发生时,Reconciler 会做如下工作:
调用函数组件、或 class 组件的 render 方法,将返回的 JSX 转化为虚拟 DOM
将虚拟 DOM 和上次更新时的虚拟 DOM 对比
通过对比找出本次更新中变化的虚拟 DOM
通知 Renderer 将变化的虚拟 DOM 渲染到页面上
Renderer(渲染器)
由于 React 支持跨平台,所以不同平台有不同的 Renderer。我们前端最熟悉的是负责在浏览器环境渲染的 Renderer —— ReactDOM
在每次更新发生时,Renderer 接到 Reconciler 通知,将变化的组件渲染在当前宿主环境。
React 15 架构的缺点
在 Reconciler 中,mount 的组件会调用 mountComponent,update 的组件会调用 updateComponent。这两个方法都会递归更新子组件。所以 React 15 的 Reconciler 被称为 “stack” Reconciler。
由于递归执行,所以更新一旦开始,中途就无法中断。当层级很深时,递归更新时间超过了 16ms,用户交互就会卡顿。
老架构中递归的性能问题和阻塞渲染,无法中断,React 决定重写整个架构。
React16 的架构
React16 架构可以分为三层:
Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入 Reconciler
Reconciler(协调器)—— 负责找出变化的组件
Renderer(渲染器)—— 负责将变化的组件渲染到页面上
Scheduler(调度器)
既然我们以浏览器是否有剩余时间作为任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。
其实部分浏览器已经实现了这个 API,这就是 requestIdleCallback。但是由于以下因素,React 放弃使用:
浏览器兼容性
触发频率不稳定,受很多因素影响。比如当我们的浏览器切换 tab 后,之前 tab 注册的 requestIdleCallback 触发的频率会变得很低
基于以上原因,React 实现了功能更完备的 requestIdleCallbackpolyfill,这就是 Scheduler。除了在空闲时触发回调的功能外,Scheduler 还提供了多种调度优先级供任务设置。
Scheduler 是独立于 React 的库
Reconciler(协调器)
React16 采用新的Reconciler,其内部采用了 Fiber
架构,被称为 Fiber Reconciler。
React 16 中的 Reconciler 更新工作从递归变成了可以中断的循环过程。每次循环都会调用 shouldYield 判断当前是否有剩余时间。如果没有剩余时间则暂停更新流程,将主线程交给渲染流水线,等待下一个宏任务再继续执行,这就是 Time Slice 的实现原理。
当 Scheduler 将调度后的任务交给 Reconclier 后,Reconciler 会为变化的 VDOM 元素 打上代表增/删/更新的各种副作用标记(flag)。
function workLoopConcurrent() {
// 一直执行任务,知道任务执行完成或中断
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress)
}
}
// shouldYield 是 shouldYieldToHost 别名
var frameYieldMs = 5
var frameInterval = frameYieldMs
var startTime = -1 // 当前任务开始时间,会在不同的函数中更新
function shouldYieldToHost() {
var timeElapsed = getCurrentTime() - startTime
// 时间间隔是否大于调度器设置的时间间隔,默认为 5ms
if (timeElapsed < frameInterval) {
return false
}
return true
}
// 代表插入或移动元素的 flag
const Placement = 0b0000000000000000000000010
// 代表更新元素的 flag
const Update = 0b0000000000000000000000100
那么 React16 是如何解决中断更新时 DOM 渲染不完全的问题呢?
在 React16 中,Reconciler 与 Renderer 不再是交替工作。当 Scheduler 将任务交给 Reconciler 后,Reconciler 会为变化的虚拟 DOM 打上代表增/删/更新的标记
整个 Scheduler 与 Reconciler 的工作都在内存中进行。只有当所有组件都完成 Reconciler 的工作,才会统一交给 Renderer。所以即使反复中断,用户也不会看见更新不完全的 DOM。
Renderer(渲染器)
Scheduler 与 Reconciler 的工作都在内存中进行,只有当所有组件都完成 Reconciler 的工作,工作流程才会进入 Renderer
Renderer 根据 Reconciler 为 VDOM 元素打的各种标记(flag),同步执行对应的 DOM 操作。
主打特性的迭代
按照“主打特性”划分,React 大体经历了四个发展时期:
Sync Mode:同步模式,React15 之前的版本
Async Mode:异步模式,React16 之前的版本
Concurrent Mode:并发模式,React16.4 之前的版本
Concurrent Features:并发特性,React16.4 之后的版本
Fiber 架构
代数效应
代数效应是函数式编程中的一个概念,用于将副作用从函数调用中分离。简单地说,就是一种在函数内部声明需要执行某些操作,但不立即执行它们的方式,这些操作的具体实现留给函数的调用者或者环境去处理。
React Fiber 的三层意思
作为架构
之前 React15 的 Reconciler 采用递归的方式执行,数据保存在递归调用栈中,所以被称为 “Stack” Reconciler。React16 的 Reconciler 基于 Fiber 节点实现,被称为 Fiber Reconciler。
// class FiberNode
return:指向父 FiberNode
child:指向第一个子 FiberNode
sibling:指向右边的兄弟 FiberNode
作为静态的数据结构
每个 Fiber 节点对应一个 React element,保存了该组件的类型(函数组件/类组件/原生组件...)、对应的 DOM 节点等信息。
// class FiberNode
tag:对应组件的类型 Function、Class、HostComponent、HostRoot
key:key属性
elementType:大部分情况同 type
type:对于 FunctionComponent,指函数本身;对于 ClassComponent,指Class;对于 HostComponent,指 DOM tagName(小写形式)
stateNode:FiberNode 对应的元素,比如 FunctionComponent 对应的 DOM 元素
作为动态的工作单元
每个 Fiber 节点保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新...)
// class FiberNode
flags:代表本次更新中该组件改变的状态
subTreeFlags:代表本次更新中其子组件改变的状态
deletions:
lanes:代表本次更新中该组件的优先级
childLanes:代表本次更新中其子组件的优先级
alternate:指向上次更新时的 FiberNode
React Fiber 可以理解为:
React 内部实现的一套状态更新机制。支持任务不同优先级,可中断与恢复,并且恢复后可以复用之前的中间状态。
其中每个任务更新单元为 React Element 对应的 Fiber 节点。
双缓存 Fiber 树
React 使用“双缓存”来完成 Fiber 树的构建与替换 —— 对应着 DOM 树的创建与更新。
在 React 中最多会同时存在两棵 Fiber 树。当前屏幕上显示内容对应的 Fiber 树称为 current Fiber 树,正在内存中构建的 Fiber 树称为 workInProgress Fiber 树。
current Fiber 树中的 Fiber 节点被称为 current fiber,workInProgress Fiber 树中的 Fiber 节点被称为 workInProgress fiber,他们通过 alternate 属性连接。
React 应用的根节点 FiberRootNode 通过 current 指针在不同 Fiber 树的 rootFiber 间切换来完成 current Fiber 树指向的切换。
即当 workInProgress Fiber 树构建完成交给 Renderer 渲染在页面上后,应用根节点的 current 指针指向 workInProgress Fiber 树,此时 workInProgress Fiber 树就变为 current Fiber 树。
每次状态更新都会产生新的 workInProgress Fiber 树,通过 current 与 workInProgress 的替换,完成 DOM 更新。
JSX 与 Fiber 的关系
JSX 在运行时的返回结果(即 React.createElement()的返回值)都是 React Element,Reconciler 在 React Element 基础上生成了 Fiber 节点。
Last updated
Was this helpful?