一、React 设计理念
00 min
2024-7-15
 
💡
软件的设计是为了服务理念。只有懂了设计理念,才能明白为了实现这样的理念需要如何去做架构。 所以,在我们深入源码架构之前,先来聊聊 React 的理念。
 
 
文章目录
 

1. React 理念


从官网可以看到 React 是这么介绍的:
我们认为,React 是基于 JavaScript 构建快速响应的大型Web应用程序的首先方式。它在 Facebook 和 Instagram 上表现优秀。
可见,关键是实现快速响应,那么制约快速响应的因素是什么呢?
  • 当遇到大计算量的操作或者是设备性能不足时,会导致页面的掉帧和卡顿。
  • 发送网络请求后,需要等待数据返回才能进一步操作,导致不能快速响应。
这两种场景可以分别概括为 CPU 的瓶颈 IO 的瓶颈,那么 React 是如何解决这两个瓶颈的呢?
 

1.1 CPU 的瓶颈


当项目变得愈发庞大、组件越来越多的时候,就容易遇到 CPU 的瓶颈。
试想我们向视图中渲染 3000 个 li:
主流浏览器的刷新率为 60Hz,即每秒(1000ms / 60Hz)16.6ms 刷新一次。
JS 可以操作 DOM,但 GUI 渲染进程JS 线程是互斥的,所以 JS 脚本执行浏览器布局、绘制是不能同时执行的。
在符合预期的一帧(16.6ms) 内,浏览器需要完成如下工作:
但当 JS 脚本执行时间过长超出 16.6ms,那在本次的刷新中就没有时间执行样式布局绘制了。
在上述的demo 中,由于组件数量繁多(3000 个),js 脚本执行时间过长,页面掉帧造成卡顿。
可以从 Chrome 的 Performance 面板执行的堆栈图中看到,JS 执行时间为 73.65ms,这个时间远远多于一帧的时间。
notion image
React 如何解决这个问题的呢?
答案是:在浏览器每一帧的时间中,预留一些时间给 JS 线程,React 利用这部分时间更新组件(执行 JS 脚本)
💡
在 React 源码当中,预留的初始时间是 5ms
当预留的时间不够用时,React 将线程控制权交还给浏览器,使其有时间渲染 UI,React 则等待下一帧时间到来继续被中断的工作。
这种将长任务拆分到每一帧中,像蚂蚁搬家一样一次执行一小段的任务操作,被称为时间切片(time slice)
接下来我们开启 Concurrent Mode (后续会详细讲解,暂时先了解如何开启时间切片)
此时,React 的长任务会被拆分到每一帧不同的 task 中,JS 脚本 执行时间大体在 5ms 左右,这样浏览器就有剩余时间去执行样式布局样式绘制了,减少掉帧的可能性。
notion image
所以,解决 CPU 瓶颈的关键是实现时间切片,而时间切片的关键是:将同步的更新变为异步可中断的更新
 
同步更新 vs 防抖 vs 节流 vs 异步更新 demo 对比
从 demo 中可以看到,当牺牲了列表的更新速度,React 大幅度提高了输入响应速度,让交互变得更加自然。
 

1.2 IO 的瓶颈


网络延迟是前端开发者无法解决的。如何在网络延迟客观存在的情况下,减少用户对网络延迟的感知呢?
以业界人机交互最顶尖的公司,苹果的 IOS 系统的表现为例:
点击“设置“面板中的”通用”,进入“通用”界面:
notion image
作为对比,再点击“设置“面板中的”Siri 与搜索“,进入”Siri 与搜索“界面:
notion image
可以感受到两者的在体验上的区别么?
事实上,点击“通用”后的交互是同步的,会直接显示手续界面。而在点击“Siri 与搜索”后的交互是异步的,需要等待请求返回后再显示后续界面。但是从用户的感知上看,两者的区别是微乎其微的。
这里的窍门在于:点击“Siri 与搜索”后,先在当前页面停留了一小段时间,这一小段时间被用来请求数据。
当“这一小段时间”足够短时,用户是无感知的。如果请求时间超过了“这一小段时间”的范围,再显示一个 loading 的效果。
试想如果一点击“Siri 与搜索“就显示 loading 效果,即使请求时间很短,loading 效果一闪而过。用户也是可以感知到的。
为此,React 实现了 Suspense 功能及配套的 hook —— useDeferredValue
而在源码内部,为了支持这些特性,同样需要将同步的更新变为可中断的异步更新
 

1.3 总结


React 为了践行”构建快速响应的大型 Web 应用程序“理念做出的努力。
其中的关键是解决 CPU 瓶颈和 IO 的瓶颈。而落到实现上,则需要将同步的更新变为可中断的异步更新
 
 

2. 老版 React 架构


React 从 v15 升级到了 v16 之后重构了整个架构。这一节我们聊聊 v15 为什么不能满足快速响应的理念,以至于被重构。
 
React 15架构可以分为两层:
  • Reconciler(协调器)—— 负责找出组件发生变化的组件
  • Renderer (渲染器)—— 负责将发生变化的组件渲染的界面中
 

Reconciler(协调器)

在 React 中可以通过 this.setState、this.forceUpdate、ReactDOM.render 等 API 触发更新。
每当有更新发生时,Reconciler 会做如下工作:
  1. 调用函数组件、或 class 组件的 render 方法,将返回的JSX 转换为虚拟 DOM
  1. 将虚拟 DOM 和上次更新时的虚拟 DOM 对比
  1. 通过对比找出本次发生变化的虚拟 DOM
  1. 通过 Renderer 将变化的虚拟 DOM 渲染到页面上
你可以在这里看到 React 官方对 Reconciler 的解释
 

Renderer(渲染器)

由于 React 支持跨平台,所以不同平台有不同的 Renderer。我们前端最熟悉的是负责在浏览器环境中渲染的 Renderer —— ReactDOM
除此之外,还有:
  • ReactNative 渲染器,渲染 App 原生组件
  • ReactTest 渲染器,渲染出纯 JS 对象用于测试
  • ReactArt 渲染器,渲染到 Canvas,SVG 或 VML (IE8)
每次更新发生时,Renderer 接收到 Reconciler 通知,将变化的组件渲染到当前的宿主环境中。
你可以在这里看到 React 官方对 Renderer 的解释。
 

React 15架构的缺点

在 Reconciler 中,mount 的组件会调用 mountComponent,update 的组件会调用 updateComponent。这两个方法都会递归更新子组件。
 
递归更新子组件的缺点
由于递归执行,所以更新一旦开始就无法中断了。当层级很深的时候,递归更新的时间就会超过 16ms ,用户交互就会卡顿。
 
在上个章节中,我们已经提到了 React 的解决办法 —— 用可中断的更新代替同步的更新。那么React15 的架构支持异步更新吗?我们可以看一个例子:
demo code
初始化时 state.count = 1,每次点击按钮 state.count++,列表中 3 个元素的值分别是 1、2、3 乘以 state.count 的结果,用红色标注了更新的步骤:
notion image
我们可以看到,Reconciler 和 Renderer 是交替工作的,当第一个 li 在页面上已经变化后,第二个 li 再进入 Reconciler。
由于整个过程都是同步执行的,所以在用户看来所有的 DOM 都是同时更新的。
接下来,我们模拟一下如果中途中断更新会怎么样?
 
注意 以下是我们模拟中断的情况,实际上 React15 并不会中断进行中的更新
notion image
当第一个 li 完成更新是中断更新,即步骤 3 完成后中断更新,此时后面的步骤都还没有执行。
用户原本期望得到 123 变为 246,但实际看到的却是更新不完整的 DOM!(即 223),基于这个原因,React 决定重写整个架构。
 
 

3. 新版 React 架构


React16 架构可以分为三层:
  • Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入 Reconciler
  • Reconciler (协调器) —— 负责找出变化的组件
  • Renderer (渲染器)—— 将发生变化的组件渲染到页面上
可以看到,相比与老版的 React(React15),React16 中新增了 Scheduler(调度器),接下来了解一下它。
 

Scheduler(调度器)

既然我们以浏览器是否有剩余时间作为任务的中断标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。
其实部分浏览器已经实现了这个 API,就是 requestIdleCallback。但是由于一下的原因,React 放弃使用:
  • 浏览器兼容性
  • 触发频率不稳定,受很多因素的影响。比如当我们的浏览器切换 tab 之后,之前 tab 注册的 requestIdleCallback 触发的频率会变得很低
基于以上的原因,React 实现了功能更加完备的 requestIdleCallback polyfill,这就是 Scheduler 。除了在空闲实现触发回调的功能外,Scheduler 还提供了多种调度优先级供任务设置。
Scheduler🔗是独立于 react 的库
 

Reconciler(协调器)

我们知道,在老版 React (React 15)中 Reconciler 是递归处理虚拟 DOM 的。那我们看看 React16 版本中的 Reconciler 做了哪些改进呢?🔗
我们可以看见,更新工作从递归变成了可中断的循环过程。每次循环都会调用 shouldYield 判断当前是否有剩余时间。
那么 React16 是如何解决中断更新时 DOM 渲染不完全的问题呢?
在 React16 中,Reconciler 与 Renderer 不再是交替工作。当 Scheduler 将任务交给 Reconciler 后,Reconciler 会为变化的虚拟 DOM 打上代表增、删、改的标记,类似于这样:
全部的标记见这里🔗
整个 Scheduler 与 Reconciler 的工作都在内存中进行。只有当所有组件都完成 Reconciler 的工作,才会统一交给 Renderer。
你可以在这里🔗看到 React 官方对 React16 新 Reconciler 的解释
 
 

Renderer(渲染器)

Renderer 根据 Reconciler 为虚拟 DOM 打的标记,同步执行对应的 DOM 操作。
在此,引用一节中使用的 demo
demo code
在 React16 架构中整个更新流程调整为:
notion image
其中红色框中的步骤是随时可以被打断的,参考一下原因:
  • 有其他更高优的任务需要先更新
  • 当前帧没有剩余时间
由于红色框中的工作都是在内存当中去完成的,不会更新页面上的 DOM,所以即使反复的中断,用户也不会看见更新不完整的 DOM。
实际上,由于 Scheduler 和 Reconciler 都是与平台无关的,所以 React 为他们单独发了一个包:React-Reconciler🔗 。你可以使用这个包实现自己的一个 ReactDOM,
 
 

总结

本章主要学习 React16 的新架构,采用了新的 Reconciler,Reconciler 内部采用了 Fiber 的架构。
思考:Fiber 是什么?他和 Reconciler 或者说和 React 之间是什么关系?
 
 
 

4.Fiber 架构工作原理


 
上一篇
空白文章
下一篇
解密 React:探究背后的原理与源码