React 有两个函数:
diff 函数: 计算状态变更前后的虚拟 DOM 树差异;
渲染函数: 渲染整个虚拟 DOM 树或者处理差异点。
在日常的开发中,需要同时引入 React 与 ReactDOM 两个库:
import React from 'react';
import ReactDOM from 'react-dom';
ReactDOM.render(<h1>hi!</h2>, document.getElementById('root'));
其中 React 主要的工作是组件实现、更新调度等计算工作;而 ReactDOM 提供了在网页上渲染的基础。
也正因为这样的拆分,当 React 向 iOS、Android 开发时,只需要通过 React Native 提供 Native 层的元素渲染即可完成。
Virtual DOM 的工作原理
虚拟 DOM 的工作原理是通过 JS 对象模拟 DOM 的节点。
在 Facebook 构建 React 初期时,考虑到要提升代码抽象能力、避免人为的 DOM 操作、降低代码整体风险等因素,所以引入了虚拟 DOM。
虚拟 DOM 在实现上通常是 Plain Object,以 React 为例,在 render 函数中写的 JSX 会在 Babel 插件的作用下,编译为 React.createElement 执行 JSX 中的属性参数。
React.createElement 执行后会返回一个 Plain Object,它会描述自己的 tag 类型、props 属性以及 children 情况等。这些 Plain Object 通过树形结构组成一棵虚拟 DOM 树。当状态发生变更时,将变更前后的虚拟 DOM 树进行差异比较,这个过程称为 diff,生成的结果称为 patch。计算之后,会渲染 Patch 完成对真实 DOM 的操作。
虚拟 DOM 的优点
1️⃣ 2️⃣ 3️⃣
1️⃣ 改善大规模 DOM 操作的性能
2️⃣ 规避 XSS 风险
3️⃣ 能以较低的成本实现跨平台开发
虚拟 DOM 一定比真实的 DOM 操作性能更高吗?
☎️ 不是
如果只修改一个按钮的文案,那么虚拟 DOM 的操作无论如何都不可能比真实的 DOM 操作更快。所以具体场景具体分析。
快的场景 :如果大量的直接操作 DOM 则容易引起网页性能的下降,这时 React 基于虚拟 DOM 的 diff 处理与批处理操作,可以降低 DOM 的操作范围与频次,提升页面性能。在这样的场景下虚拟 DOM 就比较快。
慢的场景 :首次渲染或微量操作,虚拟 DOM 的渲染速度就会比真实 DOM 更慢。
虚拟 DOM 一定可以规避 XSS吗?
☀️不是
虚拟 DOM 内部确保了字符转义,所以可以做到规避 XSS,但 React 仍存在风险,因为 React 留有 dangerouslySetInnerHTML API 绕过转义。
没有虚拟 DOM 不能实现跨平台吗?
✋ 不是
比如 NativeScript 没有虚拟 DOM 层 ,它是通过提供兼容原生 API 的 JS API 实现跨平台开发。
那虚拟 DOM 的优势在哪里?实际上它的优势在于跨平台的成本更低。在 React Native 之后,前端社区从虚拟 DOM 中体会到了跨平台的无限前景,所以在后续的发展中,都借鉴了虚拟 DOM。比如:社区流行的小程序同构方案,在构建过程中会提供类似虚拟 DOM 的结构描述对象,来支撑多端转换。
虚拟 DOM 的缺点
1️⃣ 2️⃣
1️⃣ 内存占用较高,因为需要模拟整个网页的真实 DOM。
2️⃣ 高性能应用场景存在难以优化的情况,类似像 Google Earth 一类的高性能前端应用在技术选型上往往不会选择 React。(比如 Google 的移动部门尝试用 Flutter 构建高性能 Web 应用。图表类的话,D3.js 是更好的选择。)
除了渲染页面,虚拟 DOM 还有哪些应用场景?
❗❓
只要记录了真实 DOM 变更,它甚至可以应用于埋点统计与数据记录等。具体案例可以参考 rrweb。
Diff 算法
diff 算法探讨的就是虚拟 DOM 树发生变化后,生成 DOM 树更新补丁的方式。它通过对比新旧两株虚拟 DOM 树的变更差异,将更新补丁作用于真实 DOM,以最小成本完成视图更新。
具体的流程
▪️ ▫️ ◾ ◽ ◼️ ◻️
触发更新 → 生成补丁 → 应用补丁。
①真实 DOM 与虚拟 DOM 之间存在一个映射关系。这个映射关系依靠初始化时的 JSX 建立完成;
②当虚拟 DOM 发生变化后,就会根据差距计算生成 patch,这个 patch 是一个结构化的数据,内容包含了增加、更新、移除等;
③最后再根据 patch 去更新真实的 DOM,反馈到用户的界面上。
整个过程中需要注意 3 点:更新时机、遍历算法、优化策略
触发更新的时机主要在 state 变化与 hooks 调用之后。此时,虚拟 DOM 树的结点发生变化,开始进行比对。采用了深度优先遍历算法,保证了组件的生命周期时序不错乱,但传统的 diff 算法也带来了一个严重的性能瓶颈,复杂程度为 O(n^3),其中 n 表示树的节点总数,效率较低。
为了优化效率,使用了分治的方式。将单一节点比对转化为了 3 种类型节点的比对,分别是树、组件及元素,以此提升效率。
1️⃣ 忽略节点跨层级操作场景,提升比对效率。
树比对:由于网页视图中较少有跨层级节点移动,两株虚拟 DOM 树只对同一层次的节点进行比较。
这种分层比较处理手法非常“暴力”,即两棵树只对同一层次的节点进行比较,如果发现节点已经不存在了,则该节点及其子节点会被完全删除掉,不会用于进一步的比较,这就提升了比对效率。
2️⃣ 如果组件的类型一致,则默认为相似的树结构,否则默认为不同的树结构。
组件比对:如果组件是同一类型,则进行树比对,如果不是,则直接放入到补丁中。
只要父组件类型不同,就会被重新渲染。这也就是为什么shouldComponentUpdate、PureComponent 及 React.memo 可以提高性能的原因。
3️⃣同一层级的子节点,可以通过标记 key 的方式进行列表对比。
元素比对:主要发生在同层级中,通过标记节点操作生成补丁,节点操作对应真实的 DOM 剪裁操作。
节点操作包含了插入、移动、删除等。其中节点重新排序同时涉及插入、移动、删除三个操作,所以效率消耗最大,此时策略三起到了至关重要的作用。
通过标记 key 的方式,React 可以直接移动 DOM 节点,降低内耗。
Fiber 给 diff 算法带来的影响
以上是经典的 React diff 算法内容。自 React 16 起,引入了 Fiber 架构。为了使整个更新过程可随时暂停恢复,节点与树分别采用了 FiberNode 与 FiberTree 进行重构。FiberNode 使用了双链表的结构,可以直接找到兄弟节点与子节点。
Fiber 机制下,整个更新过程由 current 与 workInProgress 两株树双缓冲完成。workInProgress 更新完成后,再通过修改 current 相关指针指向新节点,直接抛弃老树。
Vue 与 React 的 diff 算法对比
Vue 的 Diff 算法整体也与 React 相似,但未实现 Fiber 设计。
Vue在元素对比时,如果新旧两个元素是同一个元素,且没有设置 key 时,snabbdom 在 diff 子元素中会一次性对比旧节点、新节点及它们的首尾元素四个节点,以及验证列表是否有变化。
React 拥有完整的 Diff 算法策略,且拥有随时中断更新的时间切片能力,在大批量节点更新的极端情况下,拥有更友好的交互体验。
Vue 的整体 diff 策略与 React 对齐,虽然缺乏时间切片能力,但这并不意味着 Vue 的性能更差。除了高帧率动画,在 Vue 中其他的场景几乎都可以使用防抖和节流去提高响应性能。
另外,vue3里面增加了静态节点的标记,性能上进一步提升。
如何根据 React diff 算法原理优化代码呢?
根据 diff 算法的设计原则,应尽量避免跨层级节点移动。
通过设置唯一 key 进行优化,尽量减少组件层级深度。因为过深的层级会加深遍历深度,带来性能问题。
设置 shouldComponentUpdate 或者 React.pureComponet 减少 diff 次数。
React的渲染流程
React 的渲染过程大致一致,但协调并不相同,以 React 16 为分界线,分为 Stack Reconciler 和 Fiber Reconciler。这里的协调从狭义上来讲,特指 React 的 diff 算法,广义上来讲,有时候也指 React 的 reconciler 模块,它通常包含了 diff 算法和一些公共逻辑。
回到 Stack Reconciler 中,Stack Reconciler 的核心调度方式是递归。调度的基本处理单位是事务,它的事务基类是 Transaction,这里的事务是 React 团队从后端开发中加入的概念。在 React 16 以前,挂载主要通过 ReactMount 模块完成,更新通过 ReactUpdate 模块完成,模块之间相互分离,落脚执行点也是事务。
在 React 16 及以后,协调改为了 Fiber Reconciler。它的调度方式主要有两个特点,第一个是协作式多任务模式,在这个模式下,线程会定时放弃自己的运行权利,交还给主线程,通过requestIdleCallback 实现。第二个特点是策略优先级,调度任务通过标记 tag 的方式分优先级执行,比如动画,或者标记为 high 的任务可以优先执行。Fiber Reconciler的基本单位是 Fiber,Fiber 基于过去的 React Element 提供了二次封装,提供了指向父、子、兄弟节点的引用,为 diff 工作的双链表实现提供了基础。
在新的架构下,整个生命周期被划分为 Render 和 Commit 两个阶段。Render 阶段的执行特点是可中断、可停止、无副作用,主要是通过构造 workInProgress树计算出 diff。以 current 树为基础,将每个 Fiber 作为一个基本单位,自下而上逐个节点检查并构造 workInProgress 树。这个过程不再是递归,而是基于循环来完成。
在执行上通过 requestIdleCallback 来调度执行每组任务,每组中的每个计算任务被称为 work,每个 work 完成后确认是否有优先级更高的 work 需要插入,如果有就让位,没有就继续。优先级通常是标记为动画或者 high 的会先处理。每完成一组后,将调度权交回主线程,直到下一次 requestIdleCallback 调用,再继续构建 workInProgress 树。
在 commit 阶段需要处理 effect 列表,这里的 effect 列表包含了根据 diff 更新 DOM 树、回调生命周期、响应 ref 等。
但一定要注意,这个阶段是同步执行的,不可中断暂停,所以不要在 componentDidMount、componentDidUpdate、componentWiilUnmount 中去执行重度消耗算力的任务。
如果只是一般的应用场景,比如管理后台、H5 展示页等,两者性能差距并不大,但在动画、画布及手势等场景下,Stack Reconciler 的设计会占用占主线程,造成卡顿,而 fiber reconciler 的设计则能带来高性能的表现。