从 Virtual DOM 到 diff 算法再到 patch 函数

先来梳理一下整个文章的结构:

  1. 因为我们要分析 Vue 中的 diff 算法,所以自然是离不开 Virtual DOM,
  2. 介绍完 Virtual DOM,我们就来讲一讲,在 Virtual DOM 上,是如何应用 diff 算法的,
  3. 然后我们以 Snabbdom 库为例,先简单地介绍一下如何借助这个库创建 Virtual DOM ,
  4. 接下来介绍一下,Snabbdom 库的底层是如何创建一个 Virtual DOM 的(开始底层源码分析),
  5. Snabbdom 的核心是 patch 函数,我们会具体的讲一下里面关键的 API。

一、 Virtual DOM 

1. 什么是 Virtual DOM ?

Virtual DOM 是一个可以描述节点信息的 JS 对象。

2. 它有什么作用呢?

它的作用是以 JS 对象的形式在内存中,以描述真实的 DOM 结构,这样当页面内容需要发生变动时,React、Vue 等可以通过对前后 Virtual DOM 的比对,计算出如何以最小的代价操作真实 DOM。

3. 为什么使用Virtual DOM 就可以降低操作真实 DOM 的代价呢?

  1. 首先,我们直接通过例如 window.document.getElementById() 这些方法操作 DOM 时,其实在底层,JS 都是在调用用 C++ 写的接口。JS 是不能直接操作 DOM 的。在这时,就不免涉及到了 C++ 与 JavaScript 数据结构的转换等问题,代价往往是比较大的。
  2. 其次,我们都知道操作,当我们对一颗 DOM 树的某个节点进行修改时,经常会导致整颗 DOM 树重绘重排。对一个复杂的 DOM 树往往会有很多的 DOM 操作,那么将导致消耗大量的资源去进行重绘重排。

综合以上两点,如果可以减少对真实 DOM 树的 DOM 操作,那将可以很大程度的减少资源开销,这个时候呢,虚拟 DOM 就应运而生了。

Virtual DOM 带来了一个重要的优势,那就是我们可以在完全不访问真实 DOM 的情况下,掌握 DOM 的结构,这为框架自动优化 Dom 操作提供了可能。举例来说,如果我们本打算手动进行三次真实 Dom 操作,而框架在分析了 Virtual Dom 的结构后,把这三次 Dom 操作简化成了一次,这不就带来了性能上的提升吗?

很多时候手工优化 Dom 确实会比 Virtual Dom 效率高,对于比较简单的Dom结构用手工优化没有问题,但当页面结构很庞大,结构很复杂时,手工优化会花去大量时间,而且可维护性也不高,不能保证每个人都有手工优化的能力。至此,Virtual DOM 的解决方案应运而生,Virtual DOM 很多时候都不是最优的操作,但它具有普适性,在效率、可维护性之间达平衡。

Virtual DOM 另一个重大意义就是提供一个中间层,JS 去写UI,IOS安卓之类的负责渲染,就像 reactNative 一样。

4. Virtual DOM 的结构长什么样?

我们借助 JS 对象的结构来模拟一个真实 DOM 元素的结构,只需要将 DOM 元素的标签名、属性、文本、子元素用对象中的键值对表示出来即可,看一个简单的🌰

<!-- HTML中的结构 -->
<div>
  <h1 style="color: skyblue">测试一下虚拟Dom</h1>
  <div></div>
  <button id="btn">点一下试试</button>
</div>

那么它所对应的 Virtual DOM 的结构就是下面这样的: 

// 生成对应虚拟Dom的JS结构
{
  tag: 'div',
  props: {
    id: 'div1',
    className: 'div1'
  },
  children: [

    // <h1 style="color: skyblue">测试一下虚拟Dom</h1>
    {
      tag: 'h1',
      props: {style: 'color: skyblue'}
      children: '测试一下虚拟Dom'
    },
    // <div></div>
    {
      tag: 'div'
    },
    // <button id="btn">点一下试试</button>
    {
      tag: 'button',
      props: {
        id: 'btn'
      },
      children: '点一下试试'
    }

  ]
}

其中,

  • tag表示标签名,
  • props中保存着各种属性,例如id、class、style...
  • children表示子元素,也可以将text文本写在children中。

仔细观察我们会发现,其实除了 tag 这个键值对是必要的,对于别的键值对来说,只要属性不存在,别的键值对也可以不存在。

 

二、diff 算法

1. Virtual DOM 与 diff 算法相结合的工作原理

前面已经说了,有时候我们修改了某个数据,如果直接渲染到真实 DOM 上会引起整个 DOM 树的重绘和重排。

有没有可能我们只更新我们修改的那一小块 DOM 而不要更新整个 DOM 呢?diff 算法就能够帮助我们。

我们先根据真实 DOM 树生成一棵 Virtual DOM ,当 Virtual DOM 某个节点的数据改变后会生成一个新的 Vnode,然后新 Vnode 和老 Vnode 作对比,发现有不一样的地方就直接修改在真实的 DOM 上,然后使老 Vnode 的值为新 Vnode。

diff 的过程就是调用名为 patch 的函数,比较新旧 Virtual DOM ,一边比较一边给真实的 Dom 打补丁。

在采取 diff 算法比较新旧节点的时候,比较只会在同层级进行, 不会跨层级比较。

<div>
    <p>123</p>
</div>

<div>
    <span>456</span>
</div>

上面的代码会分别比较同一层的两个 div 以及第二层的 p 和 span,但是不会拿 div 和 span 作比较。

其次呢,就是在 Vue 中,比较只针对同一根节点下的子节点进行比较(粉色只和粉色进行比较,而不会和绿色进行比较)

2. diff 流程图

当数据发生改变时,set 方法会让调用 Dep.notify 通知所有订阅者 Watcher,订阅者就会调用 patch 给真实的 DOM 打补丁,更新相应的视图。

 

三、 Snabbdom

Snabbdom 库中有一个核心的函数:patch()。diff 的过程就是调用 patch 函数,Vue 中的 diff 算法也参考了该库中的 patch() 的思路。

所以我们就以 Snabbdom 的使用为例,一步步地分析 Vnode 的创建过程,以及 diff 的是如何被应用的。

Snabbdom 库的使用

我们可以借助 Snabbdom 创建一个 Virtual DOM,它内部的 patch 函数,在初始化时,可以将这个 Virtual DOM 的 Vnode 转化为真实 DOM,然后渲染到页面。修改数据时,可以对比差异,做到最小化更新。

我们来看一下时如何使用的。

上代码:

<!-- html -->
<body>
  <h1>感受虚拟Dom的好处</h1>
  <div id="container"></div>    <!-- 替换vnode的容器 -->
  <button id="btn">改变</button>

  <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/h.js"></script>
  <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/snabbdom-class.js"></script>
  <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/snabbdom-eventlisteners.js"></script>
  <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/snabbdom-props.js"></script>
  <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/snabbdom-style.js"></script>
  <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/snabbdom.js"></script>

  <script src="./test01.js"></script>
</body>
// text01.js

// 引入snabbdom库
const snabbdom = window.snabbdom;

// patch的主要作用是将vnode中的虚拟节点塞到对应的容器中去
const patch = snabbdom.init([
  snabbdom_class,
  snabbdom_props,
  snabbdom_style,
  snabbdom_eventlisteners,
]);

// 获取用于创建一个 Virtual DOM 的函数 h
const h = snabbdom.h;

// 拿到我们在页面生成的空的容器
const container = document.getElementById('container');


// 创建一个 Virtual DOM。 ul#list:创建一个id为list的ul标签  {}:属性为空  []:子元素
const Vnode = h('ul#list', {}, [
  h('li.item', {}, '第一项'),
  h('li.item', {}, '第二项'),
]);

// 将虚拟节点转化为真实节点, 并替换掉对应的容器
patch(container, Vnode);

🐟是,我们新建的虚拟Dom节点就被渲染进去了...

渲染过后,就变成了这样

渲染我们创建的 Virtual DOM,根本无法体现出来 Virtual DOM 和 diff 算法的强大之处,再看一个🌰:

我们为按钮绑定一个监听,点击按钮可以修改第二项的文本,并增加第三项,看看 Virtual DOM +diff 算法在这个时候会发挥什么样的魔力。

HTML 结构不变,我们只把变化的 JS 代码拿出来:

// 拿到我们在页面生成的空的容器
const container = document.getElementById('container');

//---------------  增加的部分 ---------------------
const btn = document.getElementById('btn');
//------------------------------------------------


// 创建一个虚拟节点 ul#list:创建一个 id 为 list 的 ul 标签  {}:绑定事件  []:子元素
const Vnode = h('ul#list', {}, [
  h('li#item1.item', {}, '第一项'),
  h('li#item2.item', {}, '第二项')
]);

//---------------  增加的部分 ---------------------
btn.addEventListener('click', ()=>{
  const newVnode = h('ul#list', {}, [
    h('li#item1.item', {}, '第一项'),
    h('li#item2.item', {}, '这是一个全新的第二项'),
    h('li#item3.item', {}, '还新加了第三项'),
  ]);

  // 借助 patch 进行复杂,然后进行微渲染
  patch(Vnode, newVnode);
//------------------------------------------------

})

// 将虚拟节点转化为真实节点, 并替换掉对应的容器
patch(container, Vnode);

两个 Virtual DOM 的区别在于:item2 的文本内容发生了变化,新增了 item3,不过,我们新建的 Virtual DOM 可依旧是包含整个的ul标签的。

我们点击一下按钮,来看一下效果

点一下按钮...

(高亮表示重新渲染的部分)

对比点击前和点击后,我们会发现,虽然我们创建的 Virtual DOM 包含了整个 ul 标签,但是真正被重新渲染的,就只有下面两个变化的 li 标签。这就是 diff 算法的高效所在。

 

四、h()

前面提到了,就是通过 h 函数创建的 Virtual DOM,那我们就来分析一下,h 函数是如何创建的 Virtual DOM。

首先,我们通过 yarn add snabbdom 安装好 snabbdom 库,找到文件夹中的 h.ts 文件,进入 h.ts,

在 h.ts 中,我们可以很容易地发现,总共提供给了我们五种传参方式,除了 sel,其他的参数都是可以选择性地进行传递的。

看我们自己使用 h 函数的时候是如何传参的:

  1. 'ul#list'   :对应了 sel,也就是选择器,在标签后面可以添加一系列的属性,但是这个属性是有顺序的,例如 class 选择器应该在 id 选择器之后(例如:li#item1.item),
  2. {}           :对应了 data,这里面可以写一些对这个标签绑定的监听,例如我们可以写 { on: { click: someFun } },也就给对应的标签添加上了一个点击监听,回调函数为 someFun
  3. [h(), h()]:对应了 children,这里面用 h 创建了 ul 的两个子元素。

在 h.js 的末尾,我们可以看到,它返回了一个 vnode 函数,这不难理解,我们用 h 函数创建 Virtual DOM,那这个函数在最后,当然要返回给我们一个 Virtual DOM。

sel、data、children 我们前面都说过了,很容易理解,这个 text 有什么用?有这样一种常见的情况:例如 <h1> 标签它并没有子元素,我们通常需要在 <h1> 标签中间写一些文本,这个text,指代的就是这个文本。

我们可以直接将这个文本放在 children 的位置,children 和 text 只允许有一个存在。

我们再进入这个 vnode 函数中去,仔细看这个 vnode 函数,它将我们传递的参数,放到了一个对象中,并返回给我们,这个对象,就是我们要的 Virtual DOM 了。

这个 elm 呢,就指代了 Virtual DOM  所代表的真实 DOM。

最后一个 key 作用就很大了,key 不相同,就可以直接否定两个 Vnode 表示同一个 Virtual DOM。它对 diff 算法的判定流程起了非常大的作用。

 

五、 init()

在前面我们知道,init 函数可以初始化生成 patch 函数。

定位到 init.ts 文件中的 init 函数,在 init 函数的末尾,很清晰地看到它返回了一个 patch 函数:

 return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node;
    const insertedVnodeQueue: VNodeQueue = [];
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();

    if (!isVnode(oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode);
    }

可以看到,返回的patch函数有两个参数:一个是 oldVnode,一个是 vnode。而这个老 Vnode 呢,还可以是两种选择,也就对应了上面的两种情况,可以是 Virtual DOM,也可以是真实节点

 

★★★ patch()

回顾我们自己借助 Snabbdom 创建 Virtual DOM 的代码,有一段是这样的:

// 将虚拟节点转化为真实节点,并替换掉对应的容器
patch(container, Vnode);

页面初始化时,调用最外部作用域的上面这串 patch 函数,作用是将我们创建的 Virtual DOM 先转化为真实节点,然后替换掉前面预留的容器 container。

在页面更新的时候,就不再触发上面这一句的 patch 了,而是会触发我们在监听中创建的下面这串 patch 函数。这个时候,就要开始应用 diff 算法,进行一系列的分析,最终选择修改 Dom 元素,或者是添加删除 Dom 元素。

// 用新的 Vnode 替换旧的 Vnode   一定要注意,这里的代码是我们自己书写的,Vnode指代了旧的虚拟Dom,下文查看的源码,Vnode 多用来指代新的 Virtual DOM,看到下文你自然就会明白
patch(Vnode, newVnode);

我们在使用下面这串代码来创建一个 patch 函数:

// patch的主要作用是将 Vnode 中的虚拟节点塞到对应的容器中去
const patch = snabbdom.init([
  snabbdom_class,
  snabbdom_props,
  snabbdom_style,
  snabbdom_eventlisteners,
]);

可以看到,我们就是通过 init 函数创建的 patch 函数。 

我们说调用 patch 函数的过程,其实就是在应用 diff 算法,我们一起来分析一下这个函数,看看究竟是如何应用的。

抛弃掉细枝末节,我们将重点在于框住的部分。这里也是应用 diff 算法的起点,在这里,代码的核心,就是通过 sameVnode 函数,将不同的情况进行划分,

1. ★★ sameVnode():判断两个虚拟 Dom 是否值得比较

我们先讲一下这个值得比较是什么意思。

判断是否值得比较,其实就是通过判断两个 Vnode 的 key、sel 以及 data 是否相同,来判断新旧 Vnode 是否对应同一个节点。

  • key:判断两个 Vnode 的key是否相同,
  • data?.is:判断两个 Vnode 绑定的监听是否相同,而这个 is 呢,作用就是判断是否是一个字符串,
  • sel:判断两个 Vnode 的标签名是否相同。

★★★ 通过 sameVnode 的不同的结果,我们将对 Virtual DOM 做的修改可以分为两类, ★★★

1. if(sameVnode) 返回 true

对 Virtual DOM 内的 Vnode 的内容做了更改 。

例如修改了 text 文本。这个时候,对比新老 Vnode,他们的 key、sel、data 均是相同的,也就表示两者还对应着同一个的真实 DOM,那么我们就只需要修改真实 DOM 的内容部分,或者是接着比较其内部的子 Vnode。这种情况呢,我们就称之为“值得比较”,也就是说新老 Vnode 的 key、sel、data 均相同,我们需要接着判断其内部的结构,是否也相同,

2. if(sameVnode) 返回 false

我们在新 Virtual DOM 中可能删除了一个在老 Virtual DOM 存在的 Vnode,或者是添加了一个在老 Virtual DOM 不存在的 Vnode。

对比两个 Vnode,key 不相同,这就表示,这两个 Vnode 对应的并不是同一个真实 DOM 中了,这个时候,我们没有必要再对新 Vnode 的内部结构进行比较了,只需要我们在真实 DOM 中也执行相应的操作就可以了。这种情况呢,我们就称之为“不值得比较”

 

2. is:判断是否为字符串的方法

is.ts 这个文件可以导出两个方法,有三个作用,一个方法是判断是否是数组,另一个判断是否是字符串或数字。

上面我们提到 vnode1.data?.is ,我们按住 Ctr l+左键 进去,可以看到这个 is 就是在判断是否是一个字符串。

 

3. ★★ patchVnode():更新页面某一节点

  • 如果上面我们判定的结果是:两个 Virtual DOM 值得比较,那么就只需要借助 patchVnode() 做更新的操作。

接下来,让我们按住 Ctrl+ 左键进入 patchVnode 函数...

我们将 “老 Vnode 所对应的真实 DOM 元素” 赋给 “新 Vnode 所对应的真实 DOM 元素” ,这样我们拿新 Vnode 操作真实 DOM 时,就可以知道修改的究竟是哪个真实 DOM 元素。

判断新老 Vnode 是否相等,如果相等就直接返回,不需要做任何操作。

两个 Vnode 相等当然没有任何可说的,patchVnode 的核心,就在下面这一大串的分支判断,

通过最上面我们对 h 函数的源代码分析可以知道,创建出来的 Vnode ,其内容部分可以有两种情况,可以是 Text 文本,也可以是子元素,也就是说也可以是一个 Vnode,当然还可以为空。注意哈,这个①~⑤可不是全部的情况,

★★★ 整个判定逻辑被分为两大分支,8 种情况。下面罗列的这 8 种情况和源代码的顺序相同 ★★★

  • 新 Vnode 无 Text,无 Text 还包括两种情况
    • 新Vnode 无 Text,有 Chileren
      • 老 Vnode 也有 Children 但和 新 Vnode Children 不相同。【updateChildren 更新子元素】
      • 老 Vnode 无 Children,这分为两种情况,
        • ② 老 Vnode 无 Children,有 Text。【setTextContent 置空文本】【addVnodes 添加子元素】
        • 老 Vnode 什么都没有。【addVnodes 添加子元素】
    • 新Vnode 什么都没有
      • 老 Vnode 有 children。【removeVnodes移除子元素】
      • 老 Vnode 有 Text。【setTextContent置空文本】
  • 新 Vnode 有 Text
    • 老 Vnode 有 Children。【removeVnodes移除子元素】【setTextContent更新文本】
    • 老 Vnode 无 Children,【setTextContent更新文本】
      • 老 Vnode 无 Children,有 Text
      • 老 Vnode 什么都没有

源码中只 5 个分支,为什么上面足足被我分出了 8 个分支呢?

我们以 ⑥ 处为例。

前提条件是:上一层的判断为新 Vnode 不包含 Text 但是有 Children

在 ② 处做了 if 判断,筛选出来了老 Vnode 包含 Text 的情况,因为新 Vnode 不包含 Text,那我们就需要清除掉老 Vnode 中的这个 Text 文本。而 ⑥ 处表示:老 Vnode 本来就什么都没有的情况,自然也就不包含文本和子元素。到达这一行,② 和 ⑥ 也都表示空的 Vnode,自然就可以执行同样的操作了。

整个分支略显复杂,但是不知道你有没有看出来规律。这其实就是一个简单的排列组合问题。

就是老 Vnode 可以有 Text 文本,可以有 Children,也可以什么都没有。而我们新 Vnode 呢,当然也是这样。然后对新老 Vnode 的情况进行排列组合。

不过,这里面我们一定要注意了,整个判断逻辑,针对的都是新旧Vnode不相同的情况。所以一些不需要修改的情况,我们自然就不需要写进去,就比如新旧虚拟Dom都是空的。

 

4. ★★ updateChildren():更新子元素

我们这次依旧站在全局的角度去分析 updateChildren 的核心while 循环。

while 循环的退出条件:新、老 Vnode 的 start 指针跑到了 end 的右边。

第三种情况的详细逻辑:

 

★★★ 整个循环体判断逻辑我们可以将其分为三个分支: ★★★

  • oldS、oldE、S、E 为空的 Vnode,这个时候只需要移动指针即可,
  • oldS 和 S 相等、oldS 和 E 相等、oldE 和 S 相等、oldE 和 E 相等的处理部分,这个时候需要再次借助 patchVnode 进行递归判断两者的子 Vnode 是否相等,
  • 这四者不为空,但是又找不到相等的 Vnode,先找与 S 的 key 相等的老 Vnode,
    • 没找到,就表示这个新 Vnode 是个新添加的 Vnode,对应需要执行创建操作,
    • 找到了,判断 sel 是否相等
      • 不相等,依旧是两个不相等的 Vnode,仍需要创建
      • 相等,就表示新老 Vnode 只是内容不相等,只需要执行更新操作,如有子Vnode,还需要判断其是否也相等。

 

5. createElm():将一个 Virtual DOM 映射到一个真实 DOM 上

  • 假如两个 Vnode 不值得比较,就会通过 createElm() 创建一个新的真实 DOM 元素,将 Vnode 映射上去。

这个 Vnode,其实就是我们我们在最上面写的 ul 标签所对应的虚拟 DOM ,而这个新的真实 DOM,其实就是根据这个虚拟 DOM,创建出来的带有两个 li 标签的 ul 标签。

我们进入 createElm 中查看一下,究竟是不是这样的。被 createElm 函数返回这个 vnode.elm 的含义,就是根据这个 Virtual DOM,创建出对应的真实 DOM。

 

6. removeVnodes():删除页面节点及其子节点

7. insertedVnodeQueue...:将节点插入页面

创建完 Virtual DOM 对应的真实 DOM 了,那么下一步自然就需要先删除页面的 container 节点及其子节点,然后将这个真实的 DOM 插入进页面:

至此,我们通过 Virtual DOM 创建的带有两个 li 标签的 ul 标签,也就被完整的渲染到也面上了。

 


参考文章:

CSDN_为什么需要虚拟DOM?

详解vue的diff算法

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

麦田里的POLO桔

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值