Vue中的虚拟DOM、diff算法、key的作用详解
作者:猫老板的豆
一、虚拟DOM
从本质上来说,虚拟DOM是一个JavaScript对象,通过对象的形式来描述真实的DOM结构。
将页面的状态抽象为JS对象的形式,配合不同的渲染工具,使跨平台渲染成为可能。通过事务处理机制,将多次DOM修改的结果一次性的更新到页面上,从而有效的减少页面渲染的次数,减少修改DOM的重绘重排次数,提高渲染性能。
虚拟DOM本身是js对象,是对DOM的抽象,是更加轻量级的对 DOM的描述。
虚拟DOM流程
创建虚拟 DOM:
当组件被创建或更新时,Vue 的渲染函数会返回一个新的虚拟 DOM 树。
这个树是一个 JavaScript 对象,它描述了 真实DOM 的结构、属性和子元素。
// 真实dom <ul id="list"> <li class="item">哈哈</li> <li class="item">呵呵</li> <li class="item">嘿嘿</li> </ul>
Vue会把代码转换成一个对象(虚拟 DOM),通过对象的形式来描述真实的DOM结构
转换成虚拟 DOM对象, 类似于下面这种(伪代码)
let oldVDOM = { // 旧虚拟DOM tagName: 'ul', // 标签名 props: { // 标签属性 id: 'list' }, children: [ // 标签子节点 { tagName: 'li', props: { class: 'item' }, text: '哈哈' }, { tagName: 'li', props: { class: 'item' }, text: '呵呵' }, { tagName: 'li', props: { class: 'item' }, text: '嘿嘿' }, ] }
比较虚拟 DOM:
这时候,我修改一个li标签
的文本:
<ul id="list"> <li class="item">哈哈</li> <li class="item">呵呵</li> <li class="item">哈哈哈</li> // 修改 </ul>
这时候生成的新虚拟DOM
为:
let newVDOM = { // 新虚拟DOM tagName: 'ul', // 标签名 props: { // 标签属性 id: 'list' }, children: [ // 标签子节点 { tagName: 'li', props: { class: 'item' }, text: '哈哈' }, { tagName: 'li', props: { class: 'item' }, text: '呵呵' }, { tagName: 'li', props: { class: 'item' }, text: '哈哈哈' }, ] }
- Vue会对比新旧虚拟DOM之间的差异,对比出是哪个虚拟节点更改了,找出这些差异。
- 这个比较过程会找出两个树之间的最小变化集,即哪些节点需要被添加、更新或删除。
- 这个过程被称为“diffing”或“差异算法”。Vue 的差异算法是高度优化的,可以快速地计算出两个树之间的差异。
应用更改:
一旦差异被计算出来:
- Vue 就会只更新这些有差异的节点应用到实际的 DOM 上,而不用更新其他数据没发生改变的节点,实现精准地更新真实DOM,进而提高效率。
- 这个过程是批量的,意味着多个更改可以一次性地应用到 DOM 上,从而减少了浏览器的重排和重绘次数。
- 这个过程被称为 Patch 或“打补丁”
更新视图:
当 DOM 更新完成后,浏览器就会重新渲染视图,以反映组件的最新状态。
虚拟DOM优点
- 性能优化:通过减少对实际 DOM 的操作次数,虚拟 DOM 可以显著提高性能。
- 跨平台:虚拟 DOM 使得 Vue.js 可以轻松地实现跨平台应用,例如使用 Web Workers 或 Weex 等技术来创建桌面应用或移动应用。
- 易于测试和调试:由于虚拟 DOM 是 JavaScript 对象,因此它可以使用 JavaScript 的所有调试工具进行测试和调试。此外,你还可以使用诸如 Jest 或 Mocha 等测试框架来编写针对虚拟 DOM 的单元测试。
用虚拟DOM算法操作真实DOM,性能高于直接操作真实DOM
虚拟DOM
和虚拟DOM算法
是两种概念:虚拟DOM算法 = 虚拟DOM + Diff算法
二、diff 算法 ⚡️
上面的例子中,其实只有一个li标签
修改了文本,其他都是不变的,所以没必要所有的节点都要更新,只更新这个li标签
就行,Diff算法就是查出这个li标签
的算法。
Diff算法是一种对比算法。对比两者是旧虚拟DOM
和新虚拟DOM
,对比出是哪个虚拟节点更改了,找出这个虚拟节点,并只更新这个虚拟节点所对应的真实节点,而不用更新其他数据没发生改变的节点,实现精准地
更新真实DOM,进而提高效率
。
- 使用虚拟DOM算法的损耗计算: 总损耗 = 虚拟DOM增删改+(与Diff算法效率有关)真实DOM差异增删改+(较少的节点)排版与重绘
- 直接操作真实DOM的损耗计算: 总损耗 = 真实DOM完全增删改+(可能较多的节点)排版与重绘
⚡️ diff 算法是一种优化手段:将新旧两个虚拟 DOM 树进行差异对比,修补(更新)差异的过程叫做patch(打补丁),将更新补丁作用于真实 DOM,以最小成本完成视图更新。
- Diff 算法:当组件的数据发生变化时,Vue.js 会创建一个新的虚拟 DOM 树来反映这些变化。然后,它会使用 diff 算法来比较旧的虚拟 DOM 树和新的虚拟 DOM 树之间的差异。这个比较过程会找出两个树之间的最小变化集,即哪些节点需要被添加、更新或删除。
- Patch 过程(打补丁):在确定了需要更新的节点之后,Vue.js 会进入 patch 过程。这个过程会将差异(或称为“补丁”)应用到实际的 DOM 上。通过只更新发生变化的节点,Vue.js 可以避免不必要的 DOM 操作,从而提高性能。
- 最小成本视图更新:通过只更新发生变化的节点,Vue.js 能够在不重新渲染整个页面的情况下更新视图。这大大降低了浏览器的重排和重绘次数,从而提高了页面的性能。
为什么 vue ,react 这些框架中都会有 diff 算法呢?
要知道渲染真实 DOM 的开销是很大的,比如有时候我们修改了某个数据,如果直接渲染到真实 DOM 上会引起整个 DOM 树的重绘和重排,有没有可能我们只更新我们修改的那一小块 DOM 而不要更新整个 DOM 呢?diff 算法能够帮助我们。
当数据发生变化时,vue是怎么更新节点的?
我们先根据真实 DOM 生成一棵 virtual DOM
(虚拟DOM)树,当 virtual DOM
某个节点的数据改变后会生成一个新的 Vnode
(虚拟节点),然后 Vnode
和 oldVnode
作对比,发现有不一样的地方就直接修改在真实的 DOM 上,然后使 oldVnode
的值为Vnode
。
diff 的过程就是调用名为 patch 的函数,比较新旧节点,一边比较一边给真实的 DOM 打补丁。
diff算法流程
在Vue中,DIFF算法(也称为差异算法或对比算法)主要用于比较新旧虚拟DOM(Virtual DOM)之间的差异,并基于这些差异来最小化地更新真实的DOM,从而提高页面渲染的性能。
1. 虚拟虚拟DOM
Vue在渲染过程中,首先会创建一个虚拟的DOM树(VNode),这个树是对真实DOM的一个抽象表示。
当数据发生变化时,Vue会生成一个新的虚拟DOM树,并与旧的虚拟DOM树进行比较。
2. 对比虚拟DOM
diff算法会比较新旧两个虚拟DOM树,找出它们之间的差异。这个比较过程是在同层级进行的,不会跨层级比较。
比较过程中,Vue会采用一系列优化策略,如双指针、头尾比较等,以提高效率。
- Vue会比较新旧虚拟DOM节点的类型和属性,以及它们的子节点。
- 如果两个节点相同(即类型和属性都相同),则不需要进行任何操作。
- 如果节点类型不同,或者属性发生了变化,Vue会创建一个新的DOM节点来替换旧的节点。
- 对于子节点的比较,Vue会递归地应用DIFF算法。
3. patch打补丁
一旦diff算法找出新旧虚拟DOM之间的差异,Vue会生成一个patch对象,这个对象记录了需要执行的DOM操作。
然后,Vue会遍历这个patch对象,执行相应的DOM操作,更新真实的DOM。
diff算法优化策略
1. 比较只会在同层级进行, 不会跨层级比较
只会在同层级的节点之间进行比较,不会跨层级比较。这大大减少了比较的次数和复杂度。
如果DOM节点出现了跨层级操作,Diff算法会简单地视为删除旧节点并创建新节点,而不是尝试去移动节点。
<div> <p>123</p> </div>
<div> <span>456</span> </div>
上面的代码会分别比较同一层的两个div以及第二层的p和span,但是不会拿div和span作比较。
一张很形象的图:(比较只会在同层级进行, 不会跨层级比较)
概括起来就是对操作前后的dom树同一层的节点进行对比,一层一层对比,然后再插入真实的dom中,重新渲染
2. 深度优先策略
diff算法在比较过程中采用深度优先策略,深度优先策略意味着算法会先尽可能深地搜索树的分支,直到到达某个节点所在的最深位置,然后再回溯到上一层,继续搜索其他分支。
当比较两个VNode时,如果它们都有子节点,那么diff算法会首先比较这两个VNode的子节点。这个过程会递归地应用深度优先策略,先比较子节点的子节点,直到到达树的叶子节点。
3. 双指针策略
双指针策略是指在比较新旧VNode时,同时设置两个指针,一个指向旧VNode的头部,另一个指向新VNode的头部。这两个指针会同时移动,以找出两个VNode树中的相同节点或差异。
- 移动指针:在比较过程中,如果当前节点相同(即类型和属性都相同),则两个指针都会向后移动,继续比较它们的子节点。
- 节点复用:如果找到了相同的节点,Vue会复用该节点,而不是重新创建。这样可以避免不必要的DOM操作,提高性能。
- 处理差异:当两个指针指向的节点不同时,Vue会根据差异类型进行相应的处理,如添加新节点、删除旧节点或更新节点。
4. 头尾比较
头尾比较策略是在双指针策略的基础上的一种优化。它同时从新旧VNode的头部和尾部开始比较,以快速找到相同且不需要移动的节点。
从两头向中间比较:从新旧VNode的头部和尾部同时开始比较,如果找到相同的节点,则将这些节点标记为已处理,并继续向中间移动指针。
快速定位相同节点:由于头尾比较策略同时从两端开始,因此可以更快地定位到相同且不需要移动的节点。这样可以减少不必要的比较和DOM操作,提高性能。
处理剩余节点:当头部和尾部的指针相遇或交叉时,表示已经处理完了所有相同的节点。此时,如果新VNode中还有剩余节点,Vue会将这些节点添加到真实DOM的末尾;如果旧VNode中还有剩余节点,Vue会将这些节点从真实DOM中删除。
5. 跳过静态节点
Vue会对模板中的静态内容进行优化,以减少更新时的性能消耗。
例如,如果一个节点的内容是静态的(即不会随数据变化而变化),Vue会将其标记为静态节点,并在后续的DIFF过程中跳过这些节点的比较。
通过以上优化策略,diff算法可以更加高效地找出新旧VNode之间的差异,并以最小化的代价更新真实的DOM。这些优化策略使得Vue在处理大量DOM更新时能够保持较高的性能。
6. 唯一标识key
详见下文
三、vue for 循环中 key 的作用
key是给每一个vnode的唯一id,可以根据key,更准确、 更快的找到对应的vnode节点。帮助Vue跟踪每个节点的身份,以便在数据改变时能够高效地更新虚拟DOM。
在新旧虚拟DOM的对比过程中看是否能找到相同的key:
1.有相同的key:(Vue就会认为它们是同一个节点)
- 若虚拟DOM中的内容没变,则直接复用之前的真实DOM
- 若虚拟DOM中的内容变了,则生产新的真实DOM,并替换掉旧的真是DOM
2.没有相同的key:(Vue就会认为是新节点)
- 根据数据创建新的真是DOM,随后渲染到页面
vue中列表循环需加:key="唯一标识"
唯一标识可以是 item
里面 id
等
因为vue组件高度复用增加 Key 可以标识组件的唯一性,那么 Key 是如何更高效的更新虚拟 DOM 的呢,我们看下面的例子:
<body> <div id="demo"> <p v-for="item in items" :key="item">{{item}}</p> </div> <script src="../../dist/vue.js"></script> <script> // 创建实例 const app = new Vue({ el: '#demo', data: { items: ['a', 'b', 'c', 'd', 'e'] }, mounted () { setTimeout(() => { this.items.splice(2, 0, 'f') // ['a', 'b', 'f', 'c', 'd', 'e'] }, 2000); }, }); </script> </body>
在不使用key的情况,vue会进行这样的操作:
分析下整体流程:
- 比较A,A,相同类型的节点,进行patch,但数据相同,不发生dom操作
- 比较B,B,相同类型的节点,进行patch,但数据相同,不发生dom操作
- 比较C,F,相同类型的节点,进行patch,数据不同,发生dom操作
- 比较D,C,相同类型的节点,进行patch,数据不同,发生dom操作
- 比较E,D,相同类型的节点,进行patch,数据不同,发生dom操作
- 循环结束,将E插入到DOM中 一共发生了3次更新,1次插入操作
我们希望可以在B和C之间加一个F,diff 算法默认执行起来是这样的:即把C更新成F,D更新成C,E更新成D,最后再插入E,是不是很没有效率?
所以我们需要使用key来给每个节点做一个唯一标识,Diff算法就可以正确的识别此节点,找到正确的位置区插入新的节点。
当为v-for渲染的列表项设置唯一的key时,Vue能够准确地识别每个列表项的身份。
当列表数据发生变化时,Vue会根据key来判断哪些列表项是新添加的、哪些是被删除的、哪些是需要更新的。 这有助于Vue进行高效的DOM更新操作,只更新需要改变的部分,而不是重新渲染整个列表。
当我们在使用v-for时,需要给单元加上key:
- 如果不用key,Vue会采用就地复地原则:最小化element的移动,并且会尝试尽最大程度在同适当的地方对相同类型的element,做patch或者reuse。
- 如果使用了key,Vue会根据keys的顺序记录element,曾经拥有了key的element如果不再出现的话,会被直接remove或者destoryed
- 用+new Date()生成的时间戳作为key,手动强制触发重新渲染
- 当拥有新值的rerender作为key时,拥有了新key的Comp出现了,那么旧key Comp会被移除,新key Comp触发渲染
四、Vue 2 与 Vue3 中的区别
在 Vue 2 中:
- 使用了虚拟 DOM 的技术来优化 DOM 操作。当数据变化时,Vue 会生成一个新的虚拟 DOM 树,并与旧的虚拟 DOM 树进行比较(这个过程通常称为“diff”)。如果发现有差异,Vue 就会更新真实的 DOM。
- 在 Vue 2 的实现中,这种比较是递归进行的,会遍历整个虚拟 DOM 树,即使只有很少的部分发生了变化。这种策略在某些情况下可能会导致较高的计算复杂度。
- ❗ 补充:虽然理论上要遍历全树,但实际通过同层比较和 key 优化可以跳过部分子树,并非完全无优化。
在 Vue3 中:
区块树(Block Tree) 是Vue 3中引入的一种重要的优化机制,它是Vue 3编译器在解析模板时生成的一种树状结构,它基于模板中的代码逻辑将模板划分为多个区块(Block)。
区块树的主要功能是在渲染时优化DOM操作,它可以将模板中的静态内容和动态内容区分开,从而在更新时只关注那些实际发生变化的动态内容。 通过减少不必要的渲染开销来提高性能。
- 模板解析:Vue 3编译器首先会将模板解析成抽象语法树(AST),然后遍历AST,找出其中的连续节点块(Block),并对这些节点块进行分析和整理,最终形成一个区块树。
- 静态提升(Hoist Static) :在构建区块树的过程中,Vue 3会将静态节点(即不会改变的节点)提升到渲染函数之外,从而减少了在每次渲染时都需要重新创建这些节点的开销,彻底避免重复 diff。
- 标记动态节点类型(PatchFlags ):对于动态节点,Vue 3会在区块树中标记它们的变化类型(例如,属性更新、子节点更新等)。
- 这些标记可以在创建虚拟 DOM 节点时生成,并在后续的 diff 过程中使用。由于有了这些标记,Vue 3 在进行 diff 时就可以更有针对性地比较那些实际发生变化的节点,根据这些标记快速定位到需要更新的节点,并只对这些节点进行更新,而无需遍历整个树。这显著减少了比较的范围和计算量,从而提高了更新性能。
综上:
- 将纯静态节点提升到渲染函数外部,避免重复创建和比较;
- 对动态节点标记 PatchFlags 标识其变化类型(如文本/属性/子节点);
- 配合区块树结构记录动态子节点位置。
- 在 diff 过程中,Vue 3 能够跳过静态节点,并根据 PatchFlags 仅对动态节点的特定属性进行靶向比对,从而大幅降低计算复杂度。
举例:
<!-- 模板 --> <div> <span>Static</span> <!-- 静态提升(Hoist Static) --> <span :class="cls"></span> <!-- 标记动态节点类型(PatchFlags ) class --> <span>{{ text }}</span> <!-- 标记动态节点类型(PatchFlags ) text--> </div>
- Vue 2:比对 3 个 的所有属性+子节点
- Vue 3:完全跳过第一个静态 span,仅比对第二个 span 的 class 属性和第三个 span 的文本内容
这种机制使得 Vue 3 在具有大量静态内容+局部动态更新的场景下,性能优势尤为明显。
不建议用index作为key
不建议 用 index
作为 key,和没写基本上没区别,因为不管你数组的顺序怎么颠倒,index 都是 0, 1, 2 这样排列,导致 Vue 会复用错误的旧子节点,做很多额外的工作,效率底。
- 用key会出问题:对数据进行破坏顺序的的操作(如:在数组前面或中间添加、删除数据)
- 用key不会出问题:没有对数据进行破坏顺序的的操作(如:在数组最后面添加、删除数据)
建议key用唯一标识,如:id、手机号、学号等
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。