带你了解虚拟dom

2023-11-07
dom

Before

从使用React开始就知道了这么个概念,叫做虚拟DOM,Vue中(包括Vue2和Vue3)也用到虚拟DOM,或者叫虚拟节点VNode。
那么什么是虚拟DOM,又为什么要使用它呢,让我们一起来了解一下(下文中的代码示例都基于Vue3)。

什么是虚拟DOM

虚拟DOM对应的就是真实DOM,我们先了解一下什么是真实DOM。
DOM即Document Object Model,文档对象模型,可以通过F12在控制台的Elements页签下查看。
也可以通过JavaScript来操作DOM,进行一些增删改查的操作:

Copy
const app = document.getElementById('app');
app.innerHTML = '<div>hello world</div>';
全屏

而虚拟节点则是通过一个JavaScript对象来描述真实的DOM节点,它与真实DOM是一一对应的,比如:

Copy
// 虚拟DOM
const vnode = {
  tag: 'div',
  props: {
    class: 'red'
  },
  children: [
    {
      tag: 'p',
      children: 'hello world'
    }
  ]
}
// 对应真实DOM
<div class="red">
    <p>hello world</p>
</div>
全屏

所以说白了虚拟DOM就是一个JavaScript对象,而在运行的时候,渲染器会遍历整颗虚拟DOM树(也就是遍历Js对象)来构建真实的DOM树,这个过程被称之为挂载(mount)。

为什么要使用虚拟DOM

关于为何要使用虚拟DOM,我一直以来的答案都是:操作真实的DOM慢,而操作JavaScript对象非常快。
这句话确实不错,但是使用虚拟DOM最终还是要操作真实DOM的,所以使用虚拟DOM的消耗是操作js的消耗+操作真实DOM的消耗,这么看起来似乎它是必然更慢的。
但是在很多场景下,很难写出非常优化的命令式代码,比如一个大列表,如果只有一行数据变更,那么只更新一行肯定比整个列表刷新要来的快。 但是要写出只更新变更行的命令式代码是比较困难的(因为更新会有很多情况,让开发者把所有情况罗列出来时不太现实的)。
而使用虚拟DOM可以比较通用计算出最小的变更,从而减少真实DOM的操作来提升性能, 而且虚拟DOM还可以收集多次DOM的更新,然后一次性更新真实DOM,这样也可以减少真实DOM的操作。
性能好坏还是要在具体场景下才能进行对比,但是虚拟DOM保证了不管你的数据变化多少,每次重绘的性能都可以接受。

跨平台

虚拟DOM可以非常方便的实现跨平台,比如React Native就是通过虚拟DOM来实现的。
Vue中的渲染器可以被看做是一个通用的渲染器,所有平台相关的操作都作为参数传入渲染器生成器中(createRenderer), 生成器自身就封装所有通用的方法,比如diff算法等等。

编程方式

在jQuery时代,我们都是通过命令式的方式来操作DOM,这种方式操作步骤多,代码量大,容易出错且不易维护。
而虚拟DOM则是通过声明式的方式来操作DOM,只需要关心数据的变化,而不需要关心如何操作DOM。框架为你cover了底层的DOM操作,让开发者的代码更加简洁易维护。
声明式的代码非常适合于函数式编程,所有UI的状态都可以看做是给定了数据的函数,UI的状态只取决于数据的变化,这非常有助于创建可复用可测试的UI组件。

diff算法

最后介绍一下diff算法。
虚拟DOM提升性能的关键在于diff算法,diff算法是通过新旧虚拟DOM的对比来计算出最小的变更,从而减少真实DOM的操作。
如果不使用diff算法,做法就是卸载全部旧节点,然后再挂载全部的新节点。那我们一步步来看如何进行优化。

简单的diff算法

节点的变更无非是添加、删除、移动以及更新。
首先我们可以通过新旧节点的对比找出相同的节点,这样就只需要进行更新操作。如果新旧子节点的数量不一致,那么就需要进行添加或删除操作。
在使用vue或者react是,应该都遇到过框架提示你需要在for循环中添加key,这个key不应该是index,而需要是一个唯一标识,diff算法用这个key来判断节点是否相同。
因为只使用tag来判断是否是相同节点是不可靠的,有了key,当新旧节点的key和tag都相同,那么就可以认为是相同节点,从而进行复用。 能够复用的话,就只需要再进行更新和移动操作了。
通过两层循环遍历新旧节点,我们能够找到所有可复用的节点进行更新,更新完的节点顺序还是旧的顺序,这时候就需要进行移动操作了。
那么如何判断那些节点需要移动呢?我们可以在上面的循环寻找可复用节点的过程中,把对应旧节点的索引记录下来, 如果后续存在索引值比当前已经遇到的最大索引值还要小的节点,就意味着这个节点需要移动。那么如何移动呢?
首先要知道的是移动的是真实的DOM,在旧的节点中存在着对真实DOM的引用。
当我们遇到需要移动的节点时,先找到上一个虚拟节点对应的真实节点的下一个兄弟节点,以这个节点作为锚点,将需要移动的节点插入到这个锚点之前即可完成移动。
如果有节点新增,同样地使用上述方式找到锚点,插入新的节点。如果有节点需要删除,那么就在更新后遍历旧子节点,如果在新子节点中无法找到相同key的节点,就进行卸载。

双端diff

简单的diff算法会存在移动次数大于必要次数的情况,比如下面这个例子,就会先挪动p节点,再挪动span节点,而更好的处理方式是只挪动div节点。

Copy
const oldChildren = [
  {type: 'p', children: 'p', key: 1},
  {type: 'span', children: 'span', key: 2},
  {type: 'div', children: 'div', key: 3},
]

const newChildren = [
  {type: 'div', children: 'new div', key: 3},
  {type: 'p', children: 'p', key: 1},
  {type: 'span', children: 'span', key: 2},
]
全屏

双端算法的思路是对新旧两组子节点的头尾两端进行比较,也就是新头与旧头、新尾与旧尾、新头与旧尾、新尾与旧头四种情况。
如果新头与旧头相同,那么就复用这个节点,然后新旧头指针都向后移动一位。新尾与旧尾同理。 如果新头与旧尾相同,那么就复用这个节点,并且移动节点,再移动指针,旧头与新尾同理。
每次循环比较4个节点,减少需要移动的次数,弥补了简单diff的不足。

编译优化

diff算法会遍历节点进行对比,但其实很多节点是不会改变的,像下面这段template,会有更新的就只有第二个p子节点,其他的节点都是静态节点。 所以遍历所有节点会有很多不必要的消耗。

Copy
<template>
  <div>
    <p>hello world</p>
    <p>{{ name }}</p>
  </div>
</template>
全屏

vue3在编译阶段做了很多事情,它会充分分析模板,提取关键信息负加在对应的虚拟节点上,从而在运行阶段,渲染器能够通过这些附加的信息找到最优路径。
首先它对动静节点作了区分,对静态节点做了提升(hoisted)。这样静态节点就只会被创建一次,后续的每次渲染中都可以直接被重用,而不需要多次创建。

Copy
import { createElementVNode as _createElementVNode } from "vue"
const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "hello world", -1 /* HOISTED */)
全屏

vue3还提出了block的概念,它本质上也是一个虚拟节点,但是多了一个dynamicChildren数组,用来收集所有的动态子代节点, 使得动态节点的对比可以忽略DOM层级结构(v-if和v-for节点也作为Block)。
对于动态节点,还会额外打一个patchFlag标记,用于记录节点的动态内容。比如上面这个template的第二个p节点就会被打上标记1,代表有动态的textContent。
这样在运行阶段,就不需要再对这个节点的其他属性进行对比。

Copy
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _hoisted_1,
    _createElementVNode("p", null, _toDisplayString(_ctx.name), 1 /* TEXT */)
  ]))
}
全屏

总结

虚拟DOM有很多的优点,但是你很难说哪个优点是这些框架选择使用虚拟DOM的的根本原因,现在也有一些框架不使用虚拟DOM,比如svelte。 直接将数据与真实DOM进行映射,在编译阶段将需要做的更新计算出来,来获得更高的性能。

Reference

1. Virtual DOM is pure overhead
2. 虚拟 DOM 到底是什么?
3. 网上都说操作真实 DOM 慢,但测试结果却比 React 更快,为什么?
4. 面试官:你了解vue的diff算法吗?说说看
5. 新兴前端框架 Svelte 从入门到原理 6. Vue.js - 渲染机制

<<上一篇到头啦~~
到底啦~~下一篇>>