虚拟DOM
虚拟 dom
1.对虚拟 DOM 的理解?虚拟 DOM 主要做了什么?虚拟 DOM 本身是什么?
从本质上来说,Virtual Dom 是一个 JavaScript 对象,通过对象的方式来表示 DOM 结构。将页面的状态抽象为 JS 对象的形式,配合不同的渲染工具,使跨平台渲染成为可能。通过事务处理机制,将多次 DOM 修改的结果一次性的更新到页面上,从而有效的减少页面渲染的次数,减少修改 DOM 的重绘重排次数,提高渲染性能。
虚拟 DOM 是对 DOM 的抽象,这个对象是更加轻量级的对 DOM 的描述。它设计的最初目的,就是更好的跨平台,比如 node.js 就没有 DOM,如果想实现 SSR,那么一个方式就是借助虚拟 dom,因为虚拟 dom 本身是 js 对象。 在代码渲染到页面之前,vue 或者 react 会把代码转换成一个对象(虚拟 DOM)。以对象的形式来描述真实 dom 结构,最终渲染到页面。在每次数据发生变化前,虚拟 dom 都会缓存一份,变化之时,现在的虚拟 dom 会与缓存的虚拟 dom 进行比较。在 vue 或者 react 内部封装了 diff 算法,通过这个算法来进行比较,渲染时修改改变的变化,原先没有发生改变的通过原先的数据进行渲染。
另外现代前端框架的一个基本要求就是无须手动操作 DOM,一方面是因为手动操作 DOM 无法保证程序性能,多人协作的项目中如果 review 不严格,可能会有开发者写出性能较低的代码,另一方面更重要的是省略手动 DOM 操作可以大大提高开发效率。
为什么要用 Virtual DOM:
(1)保证性能下限,在不进行手动优化的情况下,提供过得去的性能
下面对比一下修改 DOM 时真实 DOM 操作和 Virtual DOM 的过程,来看一下它们重排重绘的性能消耗 ∶
- 真实 DOM∶ 生成 HTML 字符串+ 重建所有的 DOM 元素
- Virtual DOM∶ 生成 vNode + DOMDiff +必要的 DOM 更新
Virtual DOM 的更新 DOM 的准备工作耗费更多的时间,也就是 JS 层面,相比于更多的 DOM 操作它的消费是极其便宜的。尤雨溪在社区论坛中说道 ∶ 框架给你的保证是,你不需要手动优化的情况下,我依然可以给你提供过得去的性能。
(2)跨平台 Virtual DOM 本质上是 JavaScript 的对象,它可以很方便的跨平台操作,比如服务端渲染、uniapp 等。
2.React diff 算法的原理是什么?
vdom 时间复杂度 O(n^3)优化到 O(n)
传统的 diff 算法
两棵树中的节点一一进行对比的复杂度为O(n^2),树1上的点1要遍历树2上的所有的点,树1上的点2也要遍历树2的所有点,以此类推,复杂度为O(n^2)
。如果在比较过程中发现树 1(也就是旧树)上的一个点 A 在树 2(新树)上没有找到,点 A 会被删掉,在老 diff 算法里点 A 被删后的空位,需要遍历树 2 上的所有点去找到一个可以填充它,复杂度为 O(n)。
1.只比较同一层级,不跨级比较
2.tab 不相同,则直接删掉重建,不再深度比较
3.tag 和 key,两者都相同,则认为是同一节点,不再深度比较
实际上,diff 算法探讨的就是虚拟 DOM 树发生变化后,生成 DOM 树更新补丁的方式。它通过对比新旧两株虚拟 DOM 树的变更差异,将更新补丁作用于真实 DOM,以最小成本完成视图更新。
具体的流程如下:
- 真实的 DOM 首先会映射为虚拟 DOM;
- 当虚拟 DOM 发生变化后,就会根据差距计算生成 patch,这个 patch 是一个结构化的数据,内容包含了增加、更新、移除等;
- 根据 patch 去更新真实的 DOM,反馈到用户的界面上。
一个简单的例子:
import React from "react";
export default class ExampleComponent extends React.Component {
render() {
if (this.props.isVisible) {
return <div className="visible">visbile</div>;
}
return <div className="hidden">hidden</div>;
}
}
这里,首先假定 ExampleComponent 可见,然后再改变它的状态,让它不可见 。映射为真实的 DOM 操作是这样的,React 会创建一个 div 节点。
<div class="visible">visbile</div>
当把 visbile 的值变为 false 时,就会替换 class 属性为 hidden,并重写内部的 innerText 为 hidden。这样一个生成补丁、更新差异的过程统称为 diff 算法。
diff 算法可以总结为三个策略,分别从树、组件及元素三个层面进行复杂度的优化:
策略一:
忽略节点跨层级操作场景,提升比对效率。(基于树进行对比)
这一策略需要进行树比对,即对树进行分层比较。树比对的处理手法是非常“暴力”的,即两棵树只对同一层次的节点进行比较,如果发现节点已经不存在了,则该节点及其子节点会被完全删除掉,不会用于进一步的比较,这就提升了比对效率。
策略二:
如果组件的 class 一致,则默认为相似的树结构,否则默认为不同的树结构。(基于组件进行对比)
在组件比对的过程中:
- 如果组件是同一类型则进行树比对;
- 如果不是则直接放入补丁中。
只要父组件类型不同,就会被重新渲染。这也就是为什么 shouldComponentUpdate、PureComponent 及 React.memo 可以提高性能的原因。
策略三:
同一层级的子节点,可以通过标记 key 的方式进行列表对比。(基于节点进行对比)
元素比对主要发生在同层级中,通过标记节点操作生成补丁。节点操作包含了插入、移动、删除等。其中节点重新排序同时涉及插入、移动、删除三个操作,所以效率消耗最大,此时策略三起到了至关重要的作用。通过标记 key 的方式,React 可以直接移动 DOM 节点,降低内耗。
fiber 机制下
Fiber 机制下节点与树分别采用 FiberNode 与 FiberTree 进行重构。FiberNode 使用了双链表的结构,可以直接找到兄弟节点与子节点,使得整个更新过程可以随时暂停恢复。FiberTree 则是通过 FiberNode 构成的树。
Fiber 机制下,整个更新过程由 current 与 workInProgress 两株树双缓冲完成。当 workInProgress 更新完成后,通过修改 current 相关指针指向的节点,直接抛弃老树,虽然非常简单粗暴,却非常合理。
3.React key 是干嘛用的 为什么要加?key 主要是解决哪一类问题的
Keys 是 React 用于追踪哪些列表中元素被修改、被添加或者被移除的辅助标识。在开发过程中,我们需要保证某个元素的 key 在其同级元素中具有唯一性。
在 React Diff 算法中 React 会借助元素的 Key 值来判断该元素是新近创建的还是被移动而来的元素,从而减少不必要的元素重渲染此外,React 还需要借助 Key 值来判断元素与本地状态的关联关系。
注意事项:
- key 值一定要和具体的元素—一对应;
- 尽量不要用数组的 index 去作为 key;
- 不要在 render 的时候用随机数或者其他操作给元素加上不稳定的 key,这样造成的性能开销比不加 key 的情况下更糟糕。
4.虚拟 DOM 的引入与直接操作原生 DOM 相比,哪一个效率更高,为什么
虚拟 DOM 相对原生的 DOM 不一定是效率更高,如果只修改一个按钮的文案,那么虚拟 DOM 的操作无论如何都不可能比真实的 DOM 操作更快。在首次渲染大量 DOM 时,由于多了一层虚拟 DOM 的计算,虚拟 DOM 也会比 innerHTML 插入慢。它能保证性能下限,在真实 DOM 操作的时候进行针对性的优化时,还是更快的。所以要根据具体的场景进行探讨。
在整个 DOM 操作的演化过程中,其实主要矛盾并不在于性能,而在于开发者写得爽不爽,在于研发体验/研发效率。虚拟 DOM 不是别的,正是前端开发们为了追求更好的研发体验和研发效率而创造出来的高阶产物。虚拟 DOM 并不一定会带来更好的性能,React 官方也从来没有把虚拟 DOM 作为性能层面的卖点对外输出过。虚拟 DOM 的优越之处在于,它能够在提供更爽、更高效的研发模式(也就是函数式的 UI 编程方式)的同时,仍然保持一个还不错的性能。
5.React 与 Vue 的 diff 算法有何不同?
diff 算法是指生成更新补丁的方式,主要应用于虚拟 DOM 树变化后,更新真实 DOM。所以 diff 算法一定存在这样一个过程:触发更新 → 生成补丁 → 应用补丁。
React 的 diff 算法,触发更新的时机主要在 state 变化与 hooks 调用之后。此时触发虚拟 DOM 树变更遍历,采用了深度优先遍历算法。但传统的遍历方式,效率较低。为了优化效率,使用了分治的方式。将单一节点比对转化为了 3 种类型节点的比对,分别是树、组件及元素,以此提升效率。
- 树比对:由于网页视图中较少有跨层级节点移动,两株虚拟 DOM 树只对同一层次的节点进行比较。
- 组件比对:如果组件是同一类型,则进行树比对,如果不是,则直接放入到补丁中。
- 元素比对:主要发生在同层级中,通过标记节点操作生成补丁,节点操作对应真实的 DOM 剪裁操作。
以上是经典的 React diff 算法内容。自 React 16 起,引入了 Fiber 架构。为了使整个更新过程可随时暂停恢复,节点与树分别采用了 FiberNode 与 FiberTree 进行重构。fiberNode 使用了双链表的结构,可以直接找到兄弟节点与子节点。整个更新过程由 current 与 workInProgress 两株树双缓冲完成。workInProgress 更新完成后,再通过修改 current 相关指针指向新节点。
Vue 的整体 diff 策略与 React 对齐,虽然缺乏时间切片能力,但这并不意味着 Vue 的性能更差,因为在 Vue 3 初期引入过,后期因为收益不高移除掉了。除了高帧率动画,在 Vue 中其他的场景几乎都可以使用防抖和节流去提高响应性能。
6.如何根据 React diff 算法原理优化代码
根据 diff 算法的设计原则,应尽量避免跨层级节点移动。
通过设置唯一 key 进行优化,尽量减少组件层级深度。因为过深的层级会加深遍历深度,带来性能问题。
设置 shouldComponentUpdate 或者 React.pureComponet 减少 diff 次数。
7.React 优化 Diff 算法
三种优化策略
基于树,组件,元素优化的 diff 三点策略,react 分别进行以下算法优化
tree diff component diff element diff tree diff react 对树的算法进行了分层比较。react 通过 updateDepth 对 Virtual Dom 树进行层级控制,只会对相同层级的节点进行比较,即同一个父节点下的所有子节点。当发现节点不存在,则该节点和其子节点都会被删除。这样是需要遍历一次 dom 树,就完成了整个 dom 树的对比
component diff 如果是同类型的组件,则直接对比 virtual Dom tree 如果不是同类型的组件,会直接替换掉组件下的所有子组件 如果类型相同,但是可能 virtual DOM 没有变化,这种情况下我们可以使用 shouldComponentUpdate() 来判断是否需要进行 diff
element diff 移动优化 在移动前,会将节点在新集合中的位置和在老集合中 lastIndex 进行比较,如果 if (child._mountIndex < lastIndex) 进行移动操作,否则不进行移动操作。这是一种顺序移动优化。只有在新集合的位置 小于 在老集合中的位置 才进行移动。
如果遍历的过程中,发现在新集合中没有,但是在老集合中的节点,会进行删除操作
element diff 通过唯一 key 进行 diff 优化。
React Diff 算法的优化
- 同层节点之间相互比较,不会垮节点比较;
- 不同类型的节点,产生不同的树结构;
- 开发中,可以通过 key 来指定哪些节点在不同的渲染下保持稳定;
情况一:对比不同类型的元素
当节点为不同的元素,React 会拆卸原有的树,并且建立起新的树:
- 当一个元素从
<a>
变成<img>
,从<Article>
变成<Comment>
,或从<button>
变成<div>
都会触发一个完整的重建流程; - 当卸载一颗树时,对应的 DOM 节点也会被销毁,组件实例将执行 componentWillUnmount()方法;
- 当建立一颗新的树时,对应的 DOM 节点会被创建以及插入到 DOM 中,组件实例将执行 componentWillMont()方法,紧接着 componentDidMount()方法;
比如
React 会销毁 Counter 组件并且重新建立一个新的组件,而不会对 Counter 进行复用;
<div>
<Counter />
</div>
<span>
<Counter />
</span>
情况二:对比同一类型的元素
当比对两个相同类型的 Reac 元素时,React 会保留 DOM 节点,仅比对及更新有改变的属性。
比如
通过比对这两个元素,React 知道只需要修改 DOM 元素上的 className 属性;
<div className="before" title="stuff"></div>
<div className="after" title="stuff"></div>当更新 style 属性时,React 仅更新有所变更的属性。
通过比对这两个属性,React 知道只需要修改 DOM 元素上的 color 样式,无需修改 fontWidth。
<div style={{color:'red', fontWidth:'bold'}}></div>
<div style={{color:'green', fontWidth:'bold'}}></div>如果是同类型的组件元素:
- 组件会保持不变,React 会更新该组件的 props,并且调用 componentWillReceiveProps()和 componentWillUpdate()方法;
- 下一步,调用 render()方法,diff 算法将在之前的结果以及新的结果中进行递归;
情况三:对子节点进行递归
在默认条件下,当递归 DOM 节点的子元素时,React 会同时遍历两个子元素的列表;当产生差异时,生成一个 mutation。
我们来看一下在最后插入一条数据的情况:
前面两个比较时完全相同的,所以不会产生 mutation;
最后一个比较,产生一个 mutation,将其插入到新的 DOM 树中即可;
<ul>
<li>first</li>
<li>second</li>
</ul>
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
但是如果我们是在中间插入一条数据:
React 会对每一个子元素产生一个 mutation,而不是保持前两个
<li>
的不变;这种低效的比较方式会带来一定的性能问题
<ul>
<li>星际穿越</li>
<li>盗梦空间</li>
</ul>
<ul>
<li>大话西游</li>
<li>星际穿越</li>
<li>盗梦空间</li>
</ul>
keys 的优化
- 方式一:在最后位置插入数据
- 这种情况,有无 key 意义不大
- 方式二:在前面插入数据
- 这种做法,在没有 key 的情况下,所有的
<li>
都需要进行修改
- 这种做法,在没有 key 的情况下,所有的
- 当子元素(这里的 li)拥有 key 时,React 使用 key 来匹配原有树上的子元素以及最新树上的子元素:
- 在下面这种场景下,key 为 111 和 222 的元素仅仅进行位移,不需要进行任何的修改;
- 将 key 为 333 的元素插入到最前面的位置即可
- key 的注意事项:
- key 应该是唯一的;
- key 不要使用随机数(随机数在下一次 render 时,会重新生成一个数字);
- 使用 index 作为 key,对性能是没有优化的;
8.总结
diff 算法是指生成更新补丁的方式,主要应用于虚拟 DOM 树变化后,更新真实 DOM。所以 diff 算法一定存在这样一个过程:触发更新 → 生成补丁 → 应用补丁。
React 的 diff 算法,触发更新的时机主要在 state 变化与 hooks 调用之后。此时触发虚拟 DOM 树变更遍历,采用了深度优先遍历算法。但传统的遍历方式,效率较低。为了优化效率,使用了分治的方式。将单一节点比对转化为了 3 种类型节点的比对,分别是树、组件及元素,以此提升效率。
树比对:由于网页视图中较少有跨层级节点移动,两株虚拟 DOM 树只对同一层次的节点进行比较。
组件比对:如果组件是同一类型,则进行树比对,如果不是,则直接放入到补丁中。
元素比对:主要发生在同层级中,通过标记节点操作生成补丁,节点操作对应真实的 DOM 剪裁操作。
以上是经典的 React diff 算法内容。自 React 16 起,引入了 Fiber 架构。为了使整个更新过程可随时暂停恢复,节点与树分别采用了 FiberNode 与 FiberTree 进行重构。fiberNode 使用了双链表的结构,可以直接找到兄弟节点与子节点。
整个更新过程由 current 与 workInProgress 两株树双缓冲完成。workInProgress 更新完成后,再通过修改 current 相关指针指向新节点。
然后拿 Vue 和 Preact 与 React 的 diff 算法进行对比。
Preact 的 Diff 算法相较于 React,整体设计思路相似,但最底层的元素采用了真实 DOM 对比操作,也没有采用 Fiber 设计。Vue 的 Diff 算法整体也与 React 相似,同样未实现 Fiber 设计。
然后进行横向比较,React 拥有完整的 Diff 算法策略,且拥有随时中断更新的时间切片能力,在大批量节点更新的极端情况下,拥有更友好的交互体验。
Preact 可以在一些对性能要求不高,仅需要渲染框架的简单场景下应用。
Vue 的整体 diff 策略与 React 对齐,虽然缺乏时间切片能力,但这并不意味着 Vue 的性能更差,因为在 Vue 3 初期引入过,后期因为收益不高移除掉了。除了高帧率动画,在 Vue 中其他的场景几乎都可以使用防抖和节流去提高响应性能。