背景
自从React16版本更新了Hook用法,同时引入了新的Fiber架构去重构整个渲染和事件处理过程,React团队引入Hook是为了更好剥离业务代码,让开发能更加友好的抽象代码,达到低耦合的函数组件目的,那么重构Diff算法,引入Fiber架构是为了什么呢? 其实只是为了能够一个目标快速响应
,原先Diff算法时间复杂度为$$ O(n^3)$$ ,最后经过Fiber重构达到了$$ O(n) $$,这里面具体有什么门道,值得我们去深入研究一下。
问题
在了解Fiber架构之前,我们需要对原有React16之前版本是有什么问题,才需要引入Fiber架构去解决该问题?
React15及以前的版本采用的是Stack Reconciler(栈协调器)架构,使用同步递归方式去创建虚拟DOM,一旦进入创建过程,就无法中断,如果创建过程超过16ms,用户就会出现页面卡顿感觉。具体可以参考下图:
因此,从网上搜索了一下React15及以前的版本反馈,的主要问题有如下几个:
- React的动画效果表现不佳
- React在有大量DOM节点渲染卡顿
为什么
为什么会出现卡顿的情况,主要原因如下:
- JavaScript是单线程,与渲染线程互斥,当其中一个线程执行时,另一个线程只能挂起等待。
- Stack Reconciler 栈协调器某个任务是长期占用JavaScript主线程
前置知识
为了更好了解Fiber架构设计,需要提前了解一些前置知识,每个知识点其实都需要深入了解,这里只是简单描述,主要有以下几点:
在我们学习前端知识的时候,有个结论是: 单线程的 JavaScript 与多线程的浏览器
。
一个完整的web网页在浏览器显示和交互的进程(chrome为主),需要涉及到线程主要以下几个部分:
GUI 渲染线程
,负责渲染浏览器界面HTML元素,当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。JavaScript引擎线程
,JS内核,负责处理Javascript脚本程序。 一直等待着任务队列中任务的到来,然后解析Javascript脚本,运行代码。定时触发器线程
,定时器setInterval与setTimeout所在线程,为什么要单独弄个线程处理定时器?是因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确事件触发线程
,用来控制事件轮询,JS引擎自己忙不过来,需要浏览器另开线程协助异步http请求线程
,在XMLHttpRequest
或fetch
在连接后是通过浏览器新开一个线程请求, 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到 JavaScript引擎的处理队列中等待处理。这里需要注意XMLHttpRequest
和fetch
的区别,fetch
是w3c标准化后一个专门提供给开发调用发起http的API接口,XMLHttpRequest是一个非标准化的Http请求对象,主要是可以发起http请求获取XML数据。
上述就是浏览器的多线程,然后单线程的JavaScript通常指的是JavaScript引擎线程
,为什么需要单线程?因为多线程可能会出现各种UI交互冲突问题。因此了解单线程JS需要注意几点:
- GUI线程和JS引擎是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新则会被保存在一个队列中等到JS引擎线程空闲时立即被执行。
- JS 引擎只是任意的 JS 代码按需执行的环境,是其他线程调用触发JS引擎执行JS代码,比如:一个按钮点击触发事件,接着调用js引擎执行等
JS 引擎工作流程图如下:
React 生命周期
为了更好了解React Fiber架构,我们需要对比React15和React16的生命周期,具体如下:
React15的生命周期
在15版本的时候,一个完整的组件生命周期如下(按照执行顺序):
- constructor(),组件的构造函数,用来初始化state
- componentWillMount(),初始化渲染前时调用
- componentDidMount(),初始化渲染后调用
- componentWillReceiveProps(),父组件修改组件的props时会调用
- render(),每次渲染时候会调用
- componentWillUpdate(),组件更新前调用
- shouldComponentUpdate(),组件更新时调用,主要判断组件要不要更新
- componentDidUpdate(),组件更新后调用
- componentWillUnmount(),组件卸载时调用
按照不同时期,执行过程是不一样,具体可以见React的生命周期更改相关文章。
React16生命周期
相比较React15,16版本基于Fiber架构主要对更新周期的函数做了调整,整个生命周期如下:
- constructor(),组件的构造函数,用来初始化state
- getDerivedStateFromProps(),初始化/更新时调用,使用 props 来派生/更新 state。
- componentDidMount(),初始化渲染后调用
- shouldComponentUpdate(),
- render(),每次渲染时候会调用
- shouldComponentUpdate(),组件更新时调用,主要判断组件要不要更新
- getSnapshotBeforeUpdate(),返回值会作为第三个参数给到 componentDidUpdate。它的执行时机是在 render 方法之后,真实 DOM 更新之前。可以同时获取到更新前的真实 DOM 和更新前后的 state&props 信息。
- componentDidUpdate(),组件更新后调用,从 getSnapshotBeforeUpdate 获取到的值
- componentWillUnmount(),组件卸载时调用
对比一下,React 16 废弃的是哪些生命周期:
- componentWillMount;
- componentWillUpdate;
- componentWillReceiveProps
这些生命周期的共性,就是它们都处于 render 阶段,都可能重复被执行,而且由于这些 API 常年被滥用,它们在重复执行的过程中都存在着不可小觑的风险。
为什么废弃这些生命周期,因为引用了Fiber架构,render 阶段是允许暂停、终止和重启的。这就导致 render 阶段的生命周期都是有可能被重复执行的。
React16生命周期图如下:
React 虚拟DOM
虚拟 DOM(Virtual DOM)本质上是JS 和 DOM 之间的一个映射缓存,它在形态上表现为一个能够描述 DOM 结构及其属性信息的 JS 对象。
记住两个点:
- 虚拟 DOM 是 JS 对象
- 虚拟 DOM 是对真实 DOM 的描述
虚拟DOM出现react生命周期的两个节点:
- 挂载阶段,React 将结合 JSX 的描述,构建出虚拟 DOM 树,然后通过 ReactDOM.render 实现虚拟 DOM 到真实 DOM 的映射
- 更新阶段,页面的变化在作用于真实 DOM 之前,会先作用于虚拟 DOM,虚拟 DOM 将在 JS 层借助算法先对比出具体有哪些真实 DOM 需要被改变,然后再将这些改变作用于真实 DOM,这里就需要DOM Diff算法。
为什么需要虚拟DOM?并不是因为虚拟DOM有更高的性能,而是因为虚拟 DOM 的优越之处在于,它能够在提供更爽、更高效的研发模式(也就是函数式的 UI 编程方式)的同时,仍然保持一个还不错的性能。解决了以下问题:
- 研发体验/研发效率的问题,解决以往模板和数据,需要重复调整的问题
- 跨平台的问题,从web、小程序、app等,一套虚拟DOM,结合不同渲染逻辑,满足各类跨端场景
而在虚拟DOM这一块,Fiber架构的引入,最大的调整就是虚拟DOM更新中的diff算法,由于分片渲染,不需要一次将diff执行,可以分批计算从而减少diff算法的复杂度。
Stack Reconciler(栈协调器)
在了解Fiber架构之前,需要对React15的Stack Reconciler(栈协调器)做一次完整了解。先了解一下什么Reconciler协调器,在React中是这么定义的:
Virtual DOM 是一种编程概念。在这个概念里,UI 以一种理想化的,或者说“虚拟的”表现形式被保存于内存中,并通过如 ReactDOM 等类库使之与“真实的” DOM 同步。这一过程叫作Reconciler 协调(调和)。
所以实现Reconciler,其实就是实现虚拟DOM到真实DOM渲染的整个逻辑过程,因此调和 !== Diff,但是Diff 确实是调和过程中最具代表性的一环。
那么要了解React15是如何实现Stack Reconciler,最重要的两块:
- Diff算法策略
- 找到diff节点并
同步
更新渲染
Diff算法策略要点:(主要是树递归)
- Diff 算法性能突破的关键点在于“分层对比”;
- 类型一致的节点才有继续 Diff 的必要性;
- key 属性的设置,可以帮我们尽可能重用同一层级内的节点。
Fiber架构
我们先来看看 React 团队在“React 哲学”中对 React 的定位:
我们认为,React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。它在 Facebook 和 Instagram 上表现优秀。
快速响应
是React哲学理念,因此Fiber架构的出现是为了让React框架能更加快速响应用户的操作。
是什么
什么是 Fiber?从字面上来理解,Fiber 这个单词翻译过来是“丝、纤维”的意思,是比线还要细的东西。在计算机科学里,我们有进程、线程之分,而 Fiber 就是比线程还要纤细的一个过程,也就是所谓的“纤程”。纤程的出现,意在对渲染过程实现更加精细的控制。
Fiber的概念理解:
- 从架构角度来看,Fiber 是对 React 核心算法(即调和过程)的重写
- 从编码角度来看,Fiber 是 React 内部所定义的一种数据结构,它是 Fiber 树结构的节点单位,也就是 React 16 新架构下的“虚拟 DOM”;
- 从工作流的角度来看,Fiber 节点保存了组件需要更新的状态和副作用,一个 Fiber 同时也对应着一个工作单元。
从架构角度理解Fiber:
- 架构核心:“可中断”“可恢复”与“优先级”
- 可中断,指的是在Fiber架构下,任何工作任务都可以被更高优先级的任务中断
- 可恢复,指的是被中断的任务可以被恢复继续执行
- 优先级,指的是每个任务都有自己的优先级定义
- 因此需要增加“Scheduler(调度器)”,作用是调度更新的优先级的任务
怎么解决问题
有了Fiber架构,怎么解决React15所面临的问题,虚拟DOM同步渲染真实DOM导致页面卡顿?
- 将虚拟DOM,从原有的树结构,改为链表结构,拆分成一个个Fiber树节点
- 利用Fiber架构,将渲染过程拆分成一个个工作单元任务,设置优先级,支持可中断、可恢复
- 这样子当需要渲染复杂DOM时候,同时不影响其他优先级较高工作任务执行
可以参考下图,了解一下Fiber架构工作图:
当然这样子讲只是简单的原理,还需要弄明白异步后可能产生更多问题?比如如何定制优先级,当两个同样优先级的任务相遇的时候如何解决,这些放在第二章讲解。
总结
经过第一篇Fiber文章学习,大概了解到Fiber架构出现的背景和原因,以及它是什么,是如何工作的解决之前所遇到问题。简单总结一下:
- React中定义Reconciler协调,指的是将虚拟DOM渲染到真实DOM的过程,React15之前采用是stack Reconciler栈协调,同步渲染机制导致页面卡顿
- React16之后采用Fiber Reconciler,实现异步渲染DOM
- 采用新的Fiber架构,同时影响到React的整个生命周期,主要是在更新阶段的生命周期
后续深入了解请看第二篇章《从React中学习Fiber架构(二)》。
额外话题(Vue.js对比)
相比较React做Fiber架构优化,主要是针对事件做了时间分片,那么为什么Vue3(Vue@next)版本并不需要做呢?Vue.js作者尤雨溪是这样子回答的:
尤雨溪:在 Web 应用中,「可中断式更新」主要是由大量 CPU 计算加上复杂 DOM 操作引起的。时间分片旨在让应用在 CPU 进行大量计算时也能与用户交互,但时间分片只能对大量 CPU 计算进行优化,无法优化复杂 DOM 操作,因为要确保用户正在操作的界面是最新的状态才行。因此,我们可以考虑两种不同的可中断式更新的场景:
- CPU 计算量不大,但 DOM 操作非常复杂(比如说你向页面中插入了十万个节点)。这种场景下不管你做不做时间分片,页面都会很卡。
- CPU 计算量非常大。理论上时间分片在这种场景里会有较大收益,但是人机交互研究表明,除了动画之外,大部分用户不会觉得 10 毫秒和 100 毫秒有很大区别。
也就是说,时间分片只在 CPU 需要连续计算 100 毫秒以上的情况下才有较大收益。有意思的地方就出现了,在 React 经常会出现 100 毫秒以上的计算量,因为- Fiber 架构的复杂性导致 React 的虚拟 DOM 协调效率较低,这是系统性的问题。
- React 使用 JSX 导致它的渲染效率比 template 低,因为 template 很容易做静态分析和优化。
- React Hooks 将大部分组件树的优化 API 暴露给开发者,开发者很多时候需要手动调用 useMemo 来优化渲染效率。这意味着 React 应用默认就有 render 过多的问题。更严重的是,这些优化在 React 里很难自动化。
- 这些优化要求开发者正确设置依赖数组
- 盲目添加 useMemo 会导致应该 render 的没 render。
很不幸,大部分开发者都很懒,不会在每个地方都加上优化,因此大部分 React 应用都会有大量的没必要的 CPU 计算工作。
对比较而言,Vue 解决了上述问题:- Vue 的架构里没有时间分片,也就没有 Fiber,因此简单了很多,这使得渲染可以更快。
- Vue 通过分析 template、简化协调过程,做了大量的 AOT 优化,性能测试结果表明大部分的 DOM 内容有 80% 属于静态内容,因此 Vue 3 的协调速度比 Svelte 快,花费的时间比 React 的 1/10 还少。
- 通过数据响应式追踪,Vue 可以做到组件树级别的优化,比如把插槽编译为函数以避免 children 的变化引发 re-render,比如自动缓存内联事件处理函数以避免 re-render。Vue 3 可以做到在不借助开发者的任何手动优化的情况下,防止子组件在非必要的情况下 re-render。这意味着同样一次更新,React 应用可能要 re-render 多个组件,而 Vue 应用很可能只 re-render 一个组件。
因此,在默认情况下,Vue 3 应用会比 React 应用少花费很多 CPU 时间,因而遇到 CPU 连续计算时间超过 100 毫秒的机会相当少,除非是极端情况。但大部分极端情况是 DOM 操作过于复杂,而不是 CPU 计算量太大。
进行汇总一下描述,Vue3之所以没有使用Fiber架构,主要有以下几个原因:
- Vue.js针对template渲染机制做了多重优化,包括AOT优化(在构建的时候提前进行编译,提前将template转义成render函数)等,使得DOM元素渲染更快
- 复杂DOM渲染出现超过100ms以上的计算,是因为React本身机制导致,并不是所有复杂的DOM渲染都会需要100ms
- React Hook的暴露增加渲染效率的复杂度,从而导致React渲染更慢,从而需要Fiber架构去协调
- Vue数据响应式追踪机制,避免了多次重复render组件树,提高渲染效率
- Vue使用Fiber架构去实现,确实可以有好处,但是会增加整体代码体积和复杂度,投入产出比太低
基础概念
AOT vs JIT
- AOT,Ahead Of Time,提前编译或预编译,宿主环境获得的是编译后的代码,在浏览器中我们可以直接下载并运行编译后的代码,比如:Vue的template是通过Vue-loader编译后才能使用。
- JIT,Just In Time,即时编译 ,代码在宿主环境编译并执行,每个文件都是单独编译的,当我们更改代码时不需要再次构建整个项目,比如:React中JSX只有在浏览器运行的时候才知道具体代码。
- 本文作者: qborfy
- 本文链接: https://www.qborfy.com/today/20220117.html
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!