虚拟DOM与DOM Diff 的原理

400 阅读5分钟

来源/参考链接:

虚拟DOM

虚拟DOM 是什么

一个能代表 DOM 树的 JS对象,通常含有标签名、标签上的属性、事件监听和子元素们,以及其他属性。

虚拟DOM 长什么样

Vue的虚拟DOM

const vNode={
    tag: 'div'//标签名或组件名
    data: {
        class:'red',//标签上的属性
        on:{
          click:()=>{} // 事件
        }
    },
    children:[//子元素们(这里表示div下有两个span)
        { type:'span', ... },
        { type:'span', ... }
    ],
}

React 的虚拟DOM

const vNode={
    key=null,
    type='div'//标签名或组件名
    props:{
        children:[//子元素们(这里表示div下有两个span)
            { type:'span', ... },
            { type:'span', ... }
        ],
        className:'red',//标签上的属性
        onclick:()=>{} // 事件
    },
    ref=null
}

如何创建虚拟DOM

Vue

  • 方法1(麻烦):用函数创造虚拟 dom
  • 方法2(简便):直接用模板来创造虚拟DOM。再用 vue-loader 转译 。 image.png image.png

React

  • 方法1(麻烦):用 React.CreateElement 创造虚拟 dom
  • 方法2(简便):直接用 JSX 来创造虚拟DOM。JSX 可以用 babel 转换成 createElement 的形式。 image.png
    image.png

简便形式的缺点:严重依赖打包工具

虚拟DOM的优缺点

虚拟DOM的优点

操作虚拟DOM带来的性能提升主要是来自这 2 个方面:

  1. 虚拟DOM 能够减少不必要的 DOM 操作。
  • 虚拟DOM可以将多次操作合并为一次操作(减少次数) 比如,添加 1000个dom节点,原生 js 要每次一个一个添加 1000 次,但是 react/vue 可以把这 1000 操作合并成一次,把 1000 次操作直接封装在一个数组里面,一次性更新 dom。 (不是优化 dom 操作,而是优化 dom 操作的次数)

  • 虚拟DOM可以借助DOM diff 省去多余的操作。(减少范围) 比如,页面上有 990 个节点,还要添加 10 个。原生 js 会操作 1000 次 dom,但是使用虚拟 dom 会仅仅更新新添加的 10 个 dom。因为虚拟 dom 发现那些 990 个节点已经在页面里面了,它并不会去更新它们。

  1. 虚拟DOM 能够跨平台渲染 虚拟 DOM 本质上是一个 js 对象,它不仅可以变成DOM,还可以给任何其他实体建立映射关系,比如:ios 应用,安卓应用,小程序。

虚拟DOM的缺点

  • 需要用额外的创建函数,如 CreateElement (react)或h(vue),可以用 JSX 来简化成XML写法,
  • 严重依赖打包工具,因为 JS 不认识 jsx 语法。

操作真实 dom 慢,是这样吗?

  • 和操作原生 js相比 ,操作 dom 要比操作原生 js 里面的数组确实要慢。(操作 dom 本身其实并不慢,操作 1000 个 dom 在毫秒级)
  • 任何基于 DOM 的库(比如Vue/React)都不可能在操作DOM的时候比 DOM 还快。 比如说 vue 和 react,因为这两个库的底层还是操作真实 dom,相当于加了一层。也就是说,并不能简单说,操作虚拟 dom 本身会提升浏览器性能。

虚拟DOM 和真实DOM 性能对比

数据规模合理时,比如小于几千时,虚拟DOM可以省去多余的操作。但是规模大到很大程度时,比如十万级别,真实DOM 会更加稳定。如果 react 没有任何优化,10 万级别的DOM 会在浏览器上造成 30s 左右的不可交互时间。vue 两秒左右

DOM diff——虚拟DOM的对比算法

diff算法是发生在虚拟DOM上的

image.png

例2,删除了左子树

请注意,dom diff 并不是简单的删除左子树(计算机是从左往右对比的),而是:

  1. 比较首层,发现 div 没变,不更新
  2. 比较左边的 span,发现 children 改变,更新 dom 内容
  3. 比较右侧 span,发现 span 被删除,batch 记录删除 dom

diff 算法是什么

image.png 本质就是一个函数,输入参数是两个虚拟 dom,输出参数是一个补丁,补丁队列就是对真实 dom 的增量。

patches = patch(oldVNode, newVNode)

补丁队列长这样:

[
    {type:'INSERT',vNode:...},
    {type:'TEXT',vNode:...}, //更新文本
    {type:'PROPS',propsPatch: []} //更新属性
]

DOM diff 的大概逻辑

graph TD
Tree-diff逐层比较,找出需要更新的节点 --> 判断节点类型
判断节点类型 --> 组件:Component-diff
判断节点类型 --> 标签:Element-diff

组件:Component-diff--> 比较组件类型
比较组件类型-->类型不同 
比较组件类型-->类型相同
类型不同 --> 直接替换
类型相同--> 只更新属性

标签:Element-diff--> 比较标签类型
比较标签类型-->类型不同 
比较标签类型-->类型相同

 直接替换--> 深入组件做Tree-diff递归
 只更新属性--> 深入组件做Tree-diff递归
 
 深入组件做Tree-diff递归 --> Tree-diff逐层比较,找出需要更新的节点

Tree diff

  • 将新旧两棵树逐层对比,找出需要更新的节点
  • 如果节点是组件,执行 Component diff
  • 如果节点是标签,执行 Element diff

Component diff

  • 如果节点是组件,先比较双方类型是否一致(比如弹框组件变成轮播组件)
  • 类型不同直接替换(删除旧的)
  • 类型相同则只更新属性
  • 然后深入组件做Tree diff(递归)

Element diff

  • 如果节点是原生标签,比较标签名是否一致,
  • 不同直接替换,相同则只更新属性
  • 然后深入标签做Tree diff(递归)

dom diff 有一个显著的缺点:vue 横向比较存在 bug

Vue2.0 v-for 中 :key 到底有什么用?226 关注 · 23 回答问题

bug 的具体例子

[1,2,3] 对比 [1,3]

  • 1 和 1 对比,没变化,继续
  • 2 和 3 对比,把 2 变成 3,只改变了标号,并未改变内容,因为 vue 认为这两个是一个对象,仅仅是属性不一样而已
  • 3和空,对比,把3删除(包括子元素 rabbit)

解决方案:加了 key 没 bug

[{id:1, value:1}][{id:2, value:2}][{id:3, value:3}]

[{id:1, value:1}][{id:3, value:3}]

  • 比较第一个元素,没有变化。
  • 比较第二个元素,发现 id = 2 的元素已经被删除。vue 不再认为这两个是一个对象,因为 key (id) 的值并不相同,它会进行整体的替换。
  • 比较一个队列的第三个元素和第二个队列的第二个元素,发现相同,不进行更改。

默认就是 key 就是 index,绝对不可以用 index 当做 key。

在 react 中,上述的 bug 虽然不会出现,但是不使用 key 会对效率产生严重的影响:

当没有使用 key 的时候

第一个 list 中 index = 2 插入的 f,会导致三次替换操作。

使用 key 的时候,由于新加入的 f key 值不同,算法会直接输出 插入f 到 patch 中,然后指在 f 上的指针会右移到 C 上并继续比较,没有任何替换操作。

image.png