【自说自话】为什么在snabbdom中不允许在patch时重用虚拟节点?

468 阅读5分钟

学习snabbdom有助于拓宽我们对虚拟DOM的认知。当下最流行之一的Vue,它的虚拟DOM技术就源于snabbdom。

作者最近最近学习了snabbdom,并手写snabbdomd的核心部分,GitHub地址:study_snabbdom 不到300行

有兴趣可以拉下来看看,有详细注解和学习说明,将持续更新配图。

├─study_snabbdom                 
    ├─app
    │	└─index.html       # 静态的html模板
    ├─src                        # 项目源代码
    │    ├─snabbdom              # 手写源码的文件目录
    │    │  ├─createElement.js   # 虚拟dom对象定义
    │    │  ├─h.js               # 将表达式转换成以对象简单定义的dom
    │    │  ├─htmldomapi.js      # 操作DOM方法的简单封装
    │    │  ├─patch.js           # 开启diff的入口
    │    │  ├─updateChildren.js  # diff算法最核心的部分。
    │    │  ├─vnode.js           # 返回虚拟节点的模块
    │    ├─index.js              # 入口文件
    └─package.json               # 
    └─RMEADME.md                 # 
    └─webpack.config.js          # webpack 配置

不过今天要讨论的不是如何手写snabbdom库的核心内容。而是想说说在它官方GitHub的文档上唯一的一个常见错误,“为什么不允许在patch时重用虚拟节点

由于Vue的虚拟DOM和diff算法核心和snabbdom几乎是一样的,所以讨论它就像是在讨论Vue。

一、为了说明什么这个问题,需要对虚拟DOM和diff算法有一个基本的了解。

(1)真实的DOM以li标签为例,它至少有200个属性

我知道就算你无所谓什么是virtual DOM和diff算法,也不想在调试栏中点开这个玩意儿。

(2)Virtual DOM最少只需要5个属性

virtual DOM就是一个用JS对象来表示一个真实DOM最重要的特征。

{
  children: undefined    // 一个数组 ,存放子虚拟DOM
  data: {key: "A"}	 // 一个对象 ,用于存放一些属性和key,
  elm: li	         // ⭐一个引用 , 对应自身的真实DOM引用(如果在DOM树上的话)👆
  key: "A"               // 一个字符串 , 用于标识自己
  sel: "li"              // 一个字符串 , 用于表明自身的 tagName
  text: "A2"             // 一个字符串 , 通常是本文属性值
}
@以上就是构成一个虚拟DOM的最小可能。

(3)虚拟DOM中的diff算法是通过比对两个虚拟DOM,找出它们的差异再进行较少次数DOM操作的一个算法。

一般遵循以下原则

1. 同层比较 。 比如。

2. 同节点比较 。 比如。

二、回到正题,为什么不允许重用虚拟节点呢?

1、我们对patch()做一个实验。

(1)对html中那个容器进行patch。

开场一首苏轼的《水调歌头》window.onload完成调一个 patch()

window.onload  = function (){
	patch(container, newVnode1)
}

第1次patch 把《水调歌头》修改成了A B C D E。

(2)接下点击按钮2,对刚刚newVnode1进行patch

window.onload  = function (){
	btn.onclick = patch(newVnode1,newVnode2)
}

第2次patch 把 A B C D E 修改成了B2 A2 C2 E2 D2。

(3)接下点击按钮1,对刚刚newVnode2进行patch,并传入newVnode1作为第2参数

第3次patch 把 B2 A2 C2 E2 D2 修改成了A C D E。

这时候发现少了一个 B 。??

2、起初我以为这是我的代码逻辑有问题。但是使用源码也是一样

通过在代码中输出信息,发现无论是源码还是我手写的实现,输出的位置时间和内容都是一致的。

3、答案就在于 diff核心算法,updateChildren函数中。

4中常规命中方式失败之后,会通过查找的方式,来搜寻旧节点中是否有新节点,如果正好有,而且是同 key 同 sel就会继续对这两个节点调用patch(),调用完成后会为这个旧子节点的elm打上 undefined的标识 。

因此,当重用虚拟节点去patch的时候,就可能出现某一个虚拟节点的elm属性已经是undefined了。

重点来了,由于网络上diff算法都用文字说明,看着太费解了作者自己做了动图,并且在图里找到了答案。

本图是仓库的配图,虽然和例子不一样,但是足以说明。

(这里把旧子节点列表同时当成真实DOM来演示,可以把它们看成是一致的。)

看看第2次patch和第3次patch后,虚拟节点的情况。

可以看到第2次patch之后,newVnode1虚拟节点列表中下标为1的元素已经是undefined了,第3次patch之后,newVnode2中下标 1 2也是undefined了。因此当你再使用这个虚拟节点调用patch的时候,肯定会发生意外。

一些心得

DOM中的diff是算法为了减少DOM操作的次数。它在比较的时候,是对虚拟DOM进行比较,而将真实DOM的引用保存在虚拟DOM的elm属性中,并在发生差异时才操作DOM。

官方的解释

常见错误

Uncaught NotFoundError: Failed to execute 'insertBefore' on 'Node':
    The node before which the new node is to be inserted is not a child of this node.

此错误的原因是在调用patch时重用 vnodes(请参阅代码示例),出于性能的考虑,snabbdom 将实际 dom 节点的引用,存储到对应的虚拟节点上,因此不支持在程序patch之间重用节点。

您可以通过创建对象的浅副本(此处使用对象传播语法)来解决此问题:

var vnode2 = h('div', [  h('div', {}, ['One']),
  h('div', {}, [{ ...sharedNode }]),
  h('div', {}, ['Three']),
])

另一个解决方案是将共享 vnodes 包装在工厂函数中:

var sharedNode = () => h('div', {}, 'Selected')
var vnode1 = h('div', [
  h('div', {}, ['One']),
  h('div', {}, ['Two']),
  h('div', {}, [sharedNode()]),
])