likes
comments
collection
share

React 版本架构更迭史

作者站长头像
站长
· 阅读数 21

一、React 发展史

  • 2011 年,Jordan 开发了 FaxJS,最开始是用 SML 语言开发而不是 JS。从 FaxJS 这个名字就能看出很重的函数式影子,后来 Tom Occhino 改名 React。虽然当时是11年前,但最初的版本就已经有了 CSS in JS,考虑服务端渲染,用 HTML in JS 替代 Template,后来有参考 Facebook 的 XHP 发明了 JSX。

  • 2013 年 React 开源,本以为会一炮而红,但却是受到很多质疑,认为 JSX 是“历史的倒退”,每次 rerender 是“极傻”的行为,会带来性能问题,性能肯定很差。

  • 2014 年收到早期用户的好评,逐渐得到认可。

  • 2015 年 Jordan 开始捣鼓 React Native 并发布。然后是 Dan 和 Andrew 发明 Redux。

  • 2016 年,React 成为主流。

  • 2017 年,尝试解决性能问题,开始了数年的重构,引入 Fiber 架构。

  • 2019 年,Sebastian 终于设计出了替代 Class 组件副作用的函数式解决方案 - Hooks。完美解决了副作用和业务逻辑复用的问题。

  • 2022年,v18concurrent特性上线。

  • 2023年React新文档发布。

React 版本架构更迭史

React 官网中提到 React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。 那么制约快速响应的因素是什么呢?我们日常浏览页面时,有两类场景会制约快速响应:

  • 当执行大计算量的操作或者设备性能不足时,页面掉帧导致卡顿,概括为 CPU 的瓶颈。
  • 进行 I/O 操作后,需要等待数据返回之后才能继续操作,等待过程中不能快速响应,概括为 I/O 瓶颈。

接下来我们从 React 的版本迭代过程中来看看, React 是怎么优化上面的问题的。

React15

这个版本的 React 的架构主要分为 Reconciler(协调器)Renderer(渲染器)

  • 协调器主要负责根据自变量变化计算出UI变化。
  • 渲染器主要负责把UI变化渲染到宿主环境中。 React 已经能够满足大多数应用的需求,但是对于复杂的应用会有明显的卡顿,出现性能瓶颈,也就是 cpu 瓶颈。为什么会出现性能瓶颈呢?这是因为这个版本的 Reconciler 是通过递归来实现的,如果递归的时间超过 16 ms就会阻塞渲染,造成卡顿(可以看看浏览器渲染原理)。

那么有没有什么办法解决呢?既然知道卡顿的原因是因为阻塞了渲染,那么我们只要给浏览器留出足够的时间来渲染就可以了,具体来说就是在 Reconciler 时,如果到了浏览器要渲染更新页面的时候,就先终止运行,等渲染结束(也就是浏览器空闲的时候)再继续 Reconciler,这样就不会造成卡顿了。解决办法是有了,但是 Reconciler 是由递归实现的,递归也不能中断啊,这是问题一。那我们假设递归可以终止,设想下会发生什么。

React 版本架构更迭史

就像图中这样,如果可以中断的话,就会出现中间状态————页面的状态只更新了一部分,其他部分还展示的以前的状态,会给用户带来巨大的困扰。这种情况就像你看电视,上一帧是个壮汉,下一帧是性感美女,结果屏幕上美女的头先出来了,下半身还是刚刚的壮汉,然后慢慢才看到整个美女,体验非常差。这是问题二。

假设我们忽略问题二的体验问题能行的通吗?答案是不行的,还有个重要的问题是没办法记录中断的位置,并在中断后继续接着执行。所以这是问题三。

因为底层架构(递归 Reconciler )不能解决上面的三个问题,所以历时两年之后,React16带着并发模式来了。

React16

React16 对底层架构进行了重构,重构后的架构分为三个部分 Scheduler(调度器)Reconciler(协调器)Renderer(渲染器)

  • 调度器主要负责优先级调度,高优先级的任务优先进入 Reconciler 。
  • 协调器主要负责根据自变量变化计算出UI变化。
  • 渲染器主要负责把UI变化渲染到宿主环境中。
  • 并引入 fiber架构 作为VDOM的实现,也是新架构的基石。

那新架构是怎么解决前面提到的问题的呢? 问题一:新架构引入 fiber ,借鉴操作系统时间分片的概念,把 Reconciler 的更新流程从递归不可中断,改为了用 while 循环遍历链表,并通过 shouldYeild 方法判断是否中断循环,让浏览器优先渲染页面。 问题三:新架构 VDOM 树(fiber树)采用树状链表的结构,用全局变量记录节点的引用,通过记录的节点即可找到其他的节点。 问题二:采用双缓存机制———— React 运行时,最多存在两颗 VDOM 树,也就是 fiber 树,一棵 fiber 树对应页面上的 UI(前缓冲区),另一棵 fiber 树代表正要更新到屏幕上的UI(后缓冲区)。这样就可以等当新的页面的状态全部计算完毕后再一次性更新到页面上。

新的生命周期

在新架构的并发模式特性下 Reconciler 过程可以被中断,中断之后会重新 Reconciler ,这会导致 React 的一些行为与之前不一致,比如以下的生命周期函数:

  • componentWillMount
  • componentWillReceiveProps
  • componentWillUpdate

这几个生命周期函数可能会在并发更新时多次执行,再加上部分开发中对部分生命周期的使用不规范,以前的项目升级可能会出现意想不到的副作用。因此这几个生命被标记为“ unsafe ”不安全的。并新增了 static getDerivedStateFromPropsgetSnapshotBeforeUpdate 两个生命周期函数来替代上面不安全的生命周期函数,不安全的生命周期函数会在未来的版本彻底移除。

基于 expirationTime 的优先级控制

上面都是针对如何解决 CPU 的瓶颈的,那么 React 是怎么解决 I/O 的瓶颈的呢? 对前端而言,最主要的 I/O 瓶颈是网络延迟,那怎么在网络延迟客观存在的情况下减少网络延迟对用户的影响呢? React的解法是:将人机交互的研究成果整合到 UI 中。

人机交互的研究成果表明,用户对不同操作的卡顿的感知程度不同。举个例子:当用户在搜索框输入文字时,即从按下键盘开始到搜索框中显示字符之间有轻微的延迟,用户也会感觉到明显的卡顿。但是当用户点击按钮加载数据时,即使点击按钮到数据展示出来经历了数秒的时间,用户也不会觉得卡顿。

那么只要为不同的操作赋予不同的优先级,按照人机交互的研究成果,优先处理如“文本框输入” “鼠标悬停” “鼠标点击”等用户更易感知的操作,即可在网络延迟客观存在的情况下,在一定程度上减少网络延迟对用户的影响。 怎么做呢: 1、为不同的操作赋予不同的优先级 2、对所有优先级统一调度,优先处理最高优先级的任务 3、如果当前有任务在处理,但是产生了更高优先级的任务,则中断当前任务,优先处理高优先级的任务。

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;

在当前版本,React 定义了以上5个优先级(任务过期的时间),每个任务的 expirationTime = 任务产生的时间 + 任务过期时间。每当进入 Schedule 阶段时,会根据 expirationTime 选出优先级最高的任务( expirationTime 值越小优先级越高)。

践行代数效应——hooks

关于 ClassComponent ,会存在以下几个问题: 1、业务逻辑分散,难以维护。 业务逻辑会分散在不同的生命周期函数中,会出现一个生命周期中出现互不相关的逻辑,同时也会出现同一个业务逻辑分散到不同的生命周期中。组件越复杂问题会越严重。 2、有状态的逻辑复用困难 有状态的逻辑不能提出来复用,虽然通过高阶组件等方式也可以复用,但是无形中也为组件引入了额外的复杂度。 3、心智负担 需要学习类,以及 this 指向相关的知识。 详细信息可以看这里,动机

直到v16.8带来了 hooks ,它的出现为上面的问题提供了解决思路。

渐进升级

为了使老项目平滑的升级到新版本,React团队采用了渐进升级的策略。 首先是规范代码,在16.3中新增了 StrictMode ,在这个模式下,针对开发者编写的“不符合并发更新规范的代码”给出提示,逐步引导开发者编写规范的代码。比如使用以 will 开头的生命周期就会给出对应的报错提示。

为了允许不同情况的 React 在同一个页面共存,借此让并发模式的 React 逐步渗透至原有项目中。 React 了提供如下三种开发模式:

  • Legacy 模式:通过 ReactDom.reander(.rootNode) 创建的应用遵循该模式。默认关闭StrictMode,和以前一样
  • Blocking 模式:通过 ReactDOM.createBlockingRoot(rootNode).render(),默认开启StrictMode,作为向第三种模式迁移的中间态(可以体验并发模式的部分功能)。
  • Concurrent 模式:通过 ReactDOM.createRoot(rootNode).render() 创建的应用,默认开启StrictMode ,这种模式开启了所有的新功能。

下面是各模式的特性对比 React 版本架构更迭史

React17(垫脚石版本)

1、原先事件注册在 document 上,在这个版本调整为事件注册在应用所在根节点。 改动原因:为了使不同模式的应用可以在同一个页面内工作,如果不改多个应用的事件都注册在 document 上,会破坏原有的事件系统 2、优先级控制改为基于 lane 模型的控制策略 改动原因:基于 expirationTime 的优先级控制不能很好的支撑并发更新。

  • 不能很好的表达批的概念。比如,现在有5个更新分别是1,2,3,4,5. 我只想更新1,3,5基于 expirationTime 是没办法做到的,expirationTime 只能表达一个连续的范围,比如 expirationTime < 3,或者 expirationTime > 3.做多可以表达 expirationTime > 1或者expirationTime < 3。
  • I/O 场景的高优先级更新会阻塞低优先级的更新。

3、新的 JSX 转换逻辑,编写 JSX 代码将不再需要手动导入 React 包,编译器会针对 JSX 代码进行自动导入(React/jsx-runtime)和优化 改动原因:基于性能(旧的方式性能上没有优势)和未来简化 React 的学习使用成本,详细信息点这里

React18

与社区大量沟通后,React 发现当前的渐进升级策略存在两方面问题:

一:由于开发模式影响的事整个应用,因此无法在同一个应用中完成渐进升级。(开发者从 Legacy 模式切换到Blocking 模式。会自动开启 StrictMode ,此时就会出现整个应用的并发不兼容警告,开发者需要修复整个应用中的不兼容代码,由此来看渐进升级的目的并没有达到)。

二:React团队发现,开发者从新架构获益主要是由于使用了并发特性(开启并发更新后才能使用的那些 React 为了解决 CPU 瓶颈、IO 瓶颈设计的特性,比如:useTranstion、useDeferredValue)。

所以 React 团队提出了新的渐进升级策略————开发者仍然可以在默认情况下使用同步更新,在使用并发特性后在开启并发更新。因此 React18 中不再提供三种开发模式,而是以是否使用并发特性作为是否开启并发更新的依据。

因此在 React18 中不再提供多个开发模式,默认通过 ReactDOM.createRoot(rootNode).render() 创建应用,将模式转换成了并发特性。可以通过以下 api 开启并发特性

  • useTransition
  • startTransition
  • useDeferredValue

总结

从 React15 开始,围绕着突破 CPU 的瓶颈和 I/O 的瓶颈以及方便开发者渐进升级 React 应用的目标,不断的解决遇到的问题,对底层实现的重构,并在中间带来了hooks这一引领潮流的产物,在这个过程中 React 变得越来越好,一直到 React18 将并发作为新的特性发布(也算是目前情况的最优解吧)。给 React 点赞!也希望大家像 React 一样能够发现自己的问题,然后通过努力去优化让自己变得更好!

最后

第一次感受到码字太不容易了,希望大家点赞+评论啊! 有不对的地方也欢迎大家指出来!

参考文章

React设计原理-卡颂 React运行时优化方案的演进 像 React 作者一样思考 - 前端 UI 框架简史 作者:会影