虚拟DOM和diff算法

529 阅读17分钟

虚拟 DOM 和 diff 算法

虚拟 DOM 和 diff 算法其实对于大家并不陌生,不管我们开发中使用的什么框架,其实都离不开虚拟 DOM 和 diff。

当有人问你:你了解虚拟 DOM 和 diff 算法吗? 我相信,大部分人其实都能说出一点相关知识。但是,请各位亲们扪心自问,你真的懂虚拟 DOM 和 diff 算法吗?

本次分享将带大家一起探索虚拟 DOM 和 diff 算法的奥秘。我们将通过代码演示以及手写相关实现代码来一起打开 diff 算法的神秘面纱。核心代码每一行都安排得明明白白,由简入深,循序渐进,让大家都能听懂,干货满满!

一、简单认识虚拟 DOM 和 diff 算法

虚拟 DOM:用 js 对象形式描述真实 DOM

<div class="box">
  <h3>我是一个标题</h3>
  <ul>
    <li>java</li>
    <li>php</li>
    <li>python</li>
  </ul>
</div>
{
  "sel": "div",
  "data": { "class": { "box": true } },
  "children": [
    {
      "sel": "h3",
      "data": {},
      "text": "我是一个标题",
    },
    {
      "sel": "ul",
      "data": {},
      "children": [
        { "sel": "li", "data": {}, "text": "java" },
        { "sel": "li", "data": {}, "text": "php" },
        { "sel": "li", "data": {}, "text": "python" },
      ],
    },
  ],
};

diff: 最小量更新

变为

<div class="box">
  <h3>我是一个标题</h3>
  <span>我是一个新的span</span>
  <ul>
    <li>java</li>
    <li>php</li>
    <li>python</li>
    <li>javascript</li>
  </ul>
</div>

这种场景在实际开发中经常出现,那么 diff 算法怎么做处理的呢?有人说,直接拆掉整个 DOM,然后重新上树,计算机是很快的嘛 ~ 但是,涉及到比较庞大的 DOM 结构时,还是会有很多性能问题。就好比你要在家里客厅放一个桌子,你总不可能把家拆了重新装修吧。

我们来看 DOM 结构,其实就是多了一个 span,多了一个 li,其余东西并没有发生任何变化,diff 算法的核心就在于 进行精细化比较,实现最小量更新

二、snabbdom

  • snabbdom 简介
  • snabbdom 的 h 函数如何工作
  • 感受diff 算法

2.1 snabbdom 简介

snabbdom 是瑞典语单词,单词原意“速度”

snabbdom 是著名的虚拟 DOM 库,是 diff 算法的鼻祖,Vue 源码借鉴了 snabbdom

snabbdom github 地址

2.1.1 搭建 snabbdom 环境

1.用npm init构建项目即可
2.安装以下几个包 snabbdomwebpackwebpack-cliwebpack-dev-server

"dependencies": {
"snabbdom": "^3.0.3",
"webpack": "^5.48.0",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.2"
}

3.新建src/index.js
4.新建www/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Document</title>
</head>
<body>
  <div id="container"></div>
  <script src="xuni/bundle.js"></script>
</body>
</html>

5.创建webpack.config.js

```javascript
module.exports = {
  entry: "./src/index.js",
  output: {
    publicPath: "xuni",
    filename: "bundle.js",
  },
  devServer: {
    port: 9999,
    contentBase: "www",
  },
};
```

复制github上的demo代码到index.js

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from "snabbdom";

const patch = init([
  // Init patch function with chosen modules
  classModule, // makes it easy to toggle classes
  propsModule, // for setting properties on DOM elements
  styleModule, // handles styling on elements with support for animations
  eventListenersModule, // attaches event listeners
]);

const container = document.getElementById("container");

const someFn = () => {
console.log(1)
}

const vnode = h("div#container.two.classes", {
  on: {
    click: someFn
  }
}, [
  h("span", {
    style: {
      fontWeight: "bold"
    },
  }, "This is bold"),
  " and this is just normal text",
  h("a", {
    props: {
      href: "/foo"
    }
  }, "I'll take you places!"),
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
  patch(container, vnode);

const newVnode = h(
  "div#container.two.classes",
  { on: { click: someFn } },
  [
    h(
      "span",
      { style: { fontWeight: "normal", fontStyle: "italic" } },
      "This is now italic type"
    ),
    " and this is still just normal text",
    h("a", { props: { href: "/bar" } }, "I'll take you places!"),
  ]
);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state

注意,需要把demo中的someFn定义一下。如果页面显示下面的内容,则环境基本搭建完成:

2.2 虚拟DOM和h函数

  • 虚拟DOM

    用JavaScript对象描述DOM的层次结构。DOM中的一切属性都在虚拟DOM中有对应的属性。

  • diff是发生在虚拟DOM上的

    新虚拟DOM和老虚拟DOM进行diff,算出如何最小化更新,最后反映到真实的DOM上。

  • 研究1:虚拟DOM如何通过渲染函数(h函数)产生的

    手写h函数。

  • 研究2: diff算法原理

    手写diff算法。

  • 研究3:虚拟DOM如何通过diff变成真正的DOM

    事实上,虚拟DOM变为真正DOM,是涵盖在diff算法里的。

注意: DOM如何变为虚拟DOM,是属于模板编译原理范畴,我们这里不做讨论。

2.2.1 h函数

h函数用来产生虚拟节点(vnode)

比如这样调用h函数:

h('a', {props: { href: 'https://www.baidu.com' }}, '百度一下,你还是不知道')

得到这样的虚拟节点: 他表示真正的DOM节点:

<a href='https://www.baidu.com'>百度一下,你还是不知道</a>

字段解释:

{
  children: undefined, // 子元素
  data: {}, // 属性 样式 等
  elm: undefined, // 有没有对应的DOM节点,有没有上树
  key: undefined, // 唯一标识
  sel: 'div', // 选择器
  text: '我是一个盒子' // 文字
}

调用patch函数(后面会讲到),可以使虚拟DOM上树。

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from "snabbdom";

const patch = init([
  // Init patch function with chosen modules
  classModule, // makes it easy to toggle classes
  propsModule, // for setting properties on DOM elements
  styleModule, // handles styling on elements with support for animations
  eventListenersModule, // attaches event listeners
]);

const container = document.getElementById("container");

// 创建虚拟节点
const vnode = h('a', { props: { href: 'https://www.baidu.com' } }, '百度一下,你还是不知道')

// 让虚拟节点上树
patch(container, vnode)

console.log(vnode)

h函数可以嵌套,从而得到虚拟DOM树。

const vnode = h('ul', {}, [
  h('li', { style: { color: '#f00' } }, 'java'),
  h('li', 'php'),
  h('li', 'python'),
])

原版h函数TS版本核心代码

h.ts

export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData | null): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(
  sel: string,
  data: VNodeData | null,
  children: VNodeChildren
): VNode;
export function h(sel: any, b?: any, c?: any): VNode {
  let data: VNodeData = {};
  let children: any;
  let text: any;
  let i: number;
  if (c !== undefined) {
    if (b !== null) {
      data = b;
    }
    if (is.array(c)) {
      children = c;
    } else if (is.primitive(c)) {
      text = c;
    } else if (c && c.sel) {
      children = [c];
    }
  } else if (b !== undefined && b !== null) {
    if (is.array(b)) {
      children = b;
    } else if (is.primitive(b)) {
      text = b;
    } else if (b && b.sel) {
      children = [b];
    } else {
      data = b;
    }
  }
  if (children !== undefined) {
    for (i = 0; i < children.length; ++i) {
      if (is.primitive(children[i]))
        children[i] = vnode(
          undefined,
          undefined,
          undefined,
          children[i],
          undefined
        );
    }
  }
  if (
    sel[0] === "s" &&
    sel[1] === "v" &&
    sel[2] === "g" &&
    (sel.length === 3 || sel[3] === "." || sel[3] === "#")
  ) {
    addNS(data, children, sel);
  }
  return vnode(sel, data, children, text, undefined);
}

vnode.ts

export function vnode(
  sel: string | undefined,
  data: any | undefined,
  children: Array<VNode | string> | undefined,
  text: string | undefined,
  elm: Element | Text | undefined
): VNode {
  const key = data === undefined ? undefined : data.key;
  return { sel, data, children, text, elm, key };
}

从h.ts里面的代码来看,h函数有很多种用法(TS的函数重载):

h('div')
h('div', '文字')
h('div', [])
h('div', h())
h('div', {}, '文字')
h('div', {}, [])
h('div', {}, h())

下面到了手写h函数的环节,我们在这里只实现阉割版的h函数,只实现3个参数的情况,因为原版代码里就是给a、b、c三个参数赋值而已。

手写h函数

看原版的TS代码,仿写JS代码。因为我们今天重点不在TS。

只要主干功能,放弃实现一些细节,保证让大家都能理解核心是怎么实现的。

vnode.js

// 非常简单,只是把传入参数合组合成对象返回
export default function (sel, data, children, text, elm) {
  const key = data === undefined ? undefined : data.key;
  return {
    sel, data, children, text, elm, key
  }
}

h.js

import vnode from './vnode.js'

export default function (sel, data, c) {
  if (arguments.length !== 3) {
    throw new Error('对不起,我们是阉割版的h函数,只实现3个参数的情况,QAQ')
  }
  if (typeof c === 'string' || typeof c === 'number') {
    return vnode(sel, data, undefined, c, undefined)
  } else if (Array.isArray(c)) {
    const children = []
    for (let i = 0; i < c.length; i++) {
      // 检查c[i]必须是一个对象
      if (!(typeof c[i] === 'object' && c[i].hasOwnProperty('sel'))) {
        throw new Error('传入的数组参数中有项不是h函数')
      }
      children.push(c[i])
    }
    return vnode(sel, data, children, undefined, undefined)
  } else if (typeof c === 'object' && c.hasOwnProperty('sel')) {
    const children = [c]
    return vnode(sel, data, children, undefined, undefined)
  }
  throw new Error('对不起,参数错误')
}

这样,我们已经实现了一个阉割版的h函数,直接上例子:

import h from "./mysnabbdom/h.js";

const vnode = h('div', {}, [
  h('h2', {}, '我是一个标题'),
  h('div', {}, h('p', {}, '我是一个p')),
  h('ul', {}, [
    h('li', {}, 'java'),
    h('li', {}, 'php'),
    h('li', {}, 'python'),
  ])
])

console.log(vnode)

当当当当~~~

完美收官!

2.3 感受diff算法

一直都在说diff,那么diff到底干了什么?
通过这一章节的学习(例子以及演示),你将会对diff算法有了进一步了解,知道它干了什么。
看个例子:

const container = document.getElementById("container");
const btn = document.getElementById("btn");

const vnode1 = h('ul', {}, [
  h('li', {}, 'A'),
  h('li', {}, 'B'),
  h('li', {}, 'C'),
  h('li', {}, 'D'),
])

patch(container, vnode1)

// 点击按钮时 将 vnode1 换成 vnode2 
btn.onclick = function () {
  const vnode2 = h('ul', {}, [
    h('li', {}, 'A'),
    h('li', {}, 'B'),
    h('li', {}, 'C'),
    h('li', {}, 'D'),
    h('li', {}, 'E'),
  ])
  patch(vnode1, vnode2)
}

效果:

我们成功的把vnode1替换成了vnode2。
那diff算法内部到底干了什么?按我们已经知道的知识,diff应该知道ABCD是不会变的,那么真的是这样吗?怎么验证?
看下面的图:

很明显的看到,我们手动把li的文字改掉,点击按钮后,文字并没有发生变化(没有变为ABCD),故diff知道了ABCD没有任何变化,所以复用并保留了它们。说明diff是最小量更新,diff真是太牛啦!

那么,diff真的有这么智能吗?
看下面的例子:

const container = document.getElementById("container");
const btn = document.getElementById("btn");

const vnode1 = h('ul', {}, [
  h('li', {}, 'A'),
  h('li', {}, 'B'),
  h('li', {}, 'C'),
  h('li', {}, 'D'),
])

patch(container, vnode1)

// 点击按钮时 将 vnode1 换成 vnode2 
btn.onclick = function () {
  const vnode2 = h('ul', {}, [
    h('li', {}, 'E'),
    h('li', {}, 'A'),
    h('li', {}, 'B'),
    h('li', {}, 'C'),
    h('li', {}, 'D'),
  ])
  patch(vnode1, vnode2)
}

我们在前面插入一个E,看看会发生什么。

好像。。。没啥毛病。那么问题来了,diff到底是在前面插入E,还是在后面插入一个节点,依次把文字替换呢?
即A=>E B=>A C=>B D=>C ''=>E

不卖关子了,我们用同样的方式上图:

我们发现,E并不是在前面插入的,所以,并没有那么智能。但是,diff真的这么笨吗?
我们来继续看下面的例子:

const vnode1 = h('ul', {}, [
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'D' }, 'D'),
])

patch(container, vnode1)

// 点击按钮时 将 vnode1 换成 vnode2 
btn.onclick = function () {
  const vnode2 = h('ul', {}, [
    h('li', { key: 'E' }, 'E'),
    h('li', { key: 'A' }, 'A'),
    h('li', { key: 'B' }, 'B'),
    h('li', { key: 'C' }, 'C'),
    h('li', { key: 'D' }, 'D'),
  ])
  patch(vnode1, vnode2)
}

我们加了一个key,看到这,你是不是脑子里灵光一闪,突然好像明白了什么。
上图!

很直观的发现,加上key后,diff就真的在前面插入了E。因为我们告诉了diff,A就是A、B就是B。。。
key的作用就是为了服务于最小量更新,这就知道为什么我们写项目的时候列表为什么必须要加key的原因。

再来一个例子:

const vnode1 = h('ul', {}, [
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'D' }, 'D'),
])

patch(container, vnode1)

// 点击按钮时 将 vnode1 换成 vnode2 
btn.onclick = function () {
  const vnode2 = h('ol', {}, [
    h('li', { key: 'A' }, 'A'),
    h('li', { key: 'B' }, 'B'),
    h('li', { key: 'C' }, 'C'),
    h('li', { key: 'D' }, 'D'),
  ])
  patch(vnode1, vnode2)
}

我们加了一个key,看到这,你是不是脑子里灵光一闪,突然好像明白了什么。
上图!

我们把ul换成了ol,整个DOM被替换掉了。

最后一个例子:

const vnode1 = h('div', {}, [
  h('p', { key: 'A' }, 'A'),
  h('p', { key: 'B' }, 'B'),
  h('p', { key: 'C' }, 'C'),
  h('p', { key: 'D' }, 'D'),
])

patch(container, vnode1)

// 点击按钮时 将 vnode1 换成 vnode2 
btn.onclick = function () {
  const vnode2 = h('div', {}, h('div', {}, [
    h('p', { key: 'A' }, 'A'),
    h('p', { key: 'B' }, 'B'),
    h('p', { key: 'C' }, 'C'),
    h('p', { key: 'D' }, 'D'),
  ]))
  patch(vnode1, vnode2)
}

心得:
1、最小量更新真是太厉害啦,真的是最小量更新!当然,key很重要。key是这个节点的唯一标识,告诉diff算法,在更改前后,它们是用一个DOM节点。
2、只有是同一个虚拟节点,才会进行精细化比较。否则就是暴力删除旧的、插入新的。如何定义是同一个虚拟节点:选择器相同且key相同。
3、只进行同层比较,不会进行跨层比较。即使是同一片虚拟节点,但是跨层了,对不起,精细化比较不diff你,而是暴力删除旧的、插入新的。

可能你会问,diff并不是那么牛逼啊!但真的影响效率吗?
答:相关的操作在实际开发中,基本不会遇到,所以这是合理的优化机制。
就好比你在开发中,并不会写这种代码:

<ul v-if='flag'>
   <li>A</li>
   <li>B</li>
   <li>C</li>
</ul>
<ol v-else>
   <li>A</li>
   <li>B</li>
   <li>C</li>
</ol>

<div>
   <div v-if='flag'>
      <p></p>
      <p></p>
      <p></p>
   </div>
   <p v-if='!flag'></p>
   <p v-if='!flag'></p>
   <p v-if='!flag'></p>
</div>

虽然这一章节只是感受diff算法,但我相信你肯定会有收获的~~~

三、手写diff

我们手写之前先简单看下源码。
从一开始的demo中,我们可以发现调用了init方法返回了patch函数。

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from "snabbdom";

const patch = init([
  // Init patch function with chosen modules
  classModule, // makes it easy to toggle classes
  propsModule, // for setting properties on DOM elements
  styleModule, // handles styling on elements with support for animations
  eventListenersModule, // attaches event listeners
]);

init.ts 源码截图

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);
    }

    if (sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    } else {
      elm = oldVnode.elm!;
      parent = api.parentNode(elm) as Node;

      createElm(vnode, insertedVnodeQueue);

      if (parent !== null) {
        api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
        removeVnodes(parent, [oldVnode], 0, 0);
      }
    }

    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]);// 不用看
    }
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();// 不用看
    return vnode;
  };

看patch方法源码我们可以知道简单的流程:

3.1 新建patch函数

import vnode from './vnode.js'
import api from './htmldomapi.js'

// 是不是虚拟节点
function isVnode(vnode) {
  return vnode.sel === '' || vnode.sel !== undefined;
}

// 把oldVnode包装成虚拟节点
function emptyNodeAt(elm) {
  return vnode(
    api.tagName(elm).toLowerCase(),
    {},
    [],
    undefined,
    elm
  );
}

// 是不是同一个虚拟节点
function sameVnode(vnode1, vnode2) {
  return vnode1.sel === vnode2.sel && vnode1.key === vnode2.key
}

export default function patch(oldVnode, newVnode) {
  // 判断是不是虚拟节点
  if (!isVnode(oldVnode)) {
    // 不是虚拟节点 则包装成虚拟节点
    oldVnode = emptyNodeAt(oldVnode)
  }

  if (sameVnode(oldVnode, newVnode)) {
    // 精细化比较
  } else {
    // 暴力插入新的,删除旧的
  }
  
}

其中,里面的vnode就是之前定义的vnode函数,api是操作dom的api,这里就不放代码了,大家应该能看懂。
目前已经实现了上面流程图里的部分功能,接下来到了第一个比较难的部分,就是插入新的,删除旧的。

3.2 createElement函数

我们先考虑最简单的,h函数第三个参数是文本的情况。

import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'

const container = document.getElementById("container");

const vnode1 = h('h1', {}, '你好')

patch(container, vnode1)
// 真正创建节点,将 vnode 创建为 DOM ,插入到 pivot 元素之前
function createElement(vnode, pivot) {
  // 创建DOM节点
  let domNode = document.createElement(vnode.sel)
  // 有子节点还是有文本
  if (vnode.text !== '' && (vnode.children === undefined || vnode.children.length === 0)) {
    // 他内部是文本
    domNode.innerText = vnode.text
    // 上树 让是 pivot 的父元素调用 insertBefore 方法 ,将新的节点 domNode 插入到 pivot 之前
    pivot.parentNode.insertBefore(domNode, pivot)
  }
}

export default function patch(oldVnode, newVnode) {
  // 判断是不是虚拟节点
  if (!isVnode(oldVnode)) {
    // 不是虚拟节点 则包装成虚拟节点
    oldVnode = emptyNodeAt(oldVnode)
  }

  if (sameVnode(oldVnode, newVnode)) {
    // 精细化比较
  } else {
    // 暴力插入新的,删除旧的
    createElement(newVnode, oldVnode.elm)
  }

}

页面已经成功显示 你好。我们完成了第一次上树。

我们接下来考虑h函数第三个参数是数组的情况。

const vnode1 = h('ul', {}, [
  h('li', {}, 'A'),
  h('li', {}, 'B'),
  h('li', {}, 'C'),
  h('li', {}, 'D'),
])

patch(container, vnode1)

由于有很多子节点,我们需要递归创建节点,同时递归需要有个结束条件,也就是h函数第三个参数为文本的时候。
这种情况我们会发现,createElement第二个参数在这里不知道传什么,所以我们先改造一下createElement函数,第二个参数我们不传,我们只用createElement创建节点,不进行插入操作,我们在patch函数里执行插入。

function createElement(vnode) {
  // 创建DOM节点
  let domNode = document.createElement(vnode.sel)
  // 有子节点还是有文本
  if (vnode.text !== '' && (vnode.children === undefined || vnode.children.length === 0)) {
    // 他内部是文本
    domNode.innerText = vnode.text
    // 补充elm属性
    vnode.elm = domNode
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
    // 内部是子节点,就要递归创建节点
  }
  // 返回elm 纯dom对象
  return vnode.elm
}

同时,我们要改造上面第一个简单的例子:

function patch(oldVnode, newVnode) {
  // 判断是不是虚拟节点
  if (!isVnode(oldVnode)) {
    // 不是虚拟节点 则包装成虚拟节点
    oldVnode = emptyNodeAt(oldVnode)
  }

  if (sameVnode(oldVnode, newVnode)) {
    // 精细化比较
  } else {
    // 暴力插入新的,删除旧的
    let newVnodeElm = createElement(newVnode)
    oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
  }

}

我们在页面上成功显示 你好。

我们这样改造的目的就是为了让createElement只做创建节点的事情,不做其他事情,便于后续递归。
那么,我们便可以开始写递归了。

import vnode from './vnode.js'
import api from './htmldomapi.js'

// 是不是虚拟节点
function isVnode(vnode) {
  return vnode.sel === '' || vnode.sel !== undefined;
}

// 把oldVnode包装成虚拟节点
function emptyNodeAt(elm) {
  return vnode(
    api.tagName(elm).toLowerCase(),
    {},
    [],
    undefined,
    elm
  );
}

// 是不是同一个虚拟节点
function sameVnode(vnode1, vnode2) {
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}

// 真正创建节点,将 vnode 创建为 DOM 但不进行插入操作
function createElement(vnode) {
  // 创建DOM节点
  let domNode = document.createElement(vnode.sel)
  // 有子节点还是有文本
  if (vnode.text !== '' && (vnode.children === undefined || vnode.children.length === 0)) {
    // 他内部是文本
    domNode.innerText = vnode.text
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
    // 内部是子节点,就要递归创建节点
    for (let i = 0; i < vnode.children.length; i++) {
      // 得到当前children
      let ch = vnode.children[i]
      // 创建出它的dom,一旦调用了createElement意味着:创建出dom了,并且它的elm属性指向了创建出的dom,但是还没有上树
      let chDom = createElement(ch)
      // 上树
      domNode.appendChild(chDom)
    }
  }
  // 补充elm属性
  vnode.elm = domNode
  // 返回elm 纯dom对象
  return vnode.elm
}

export default function patch(oldVnode, newVnode) {
  // 判断是不是虚拟节点
  if (!isVnode(oldVnode)) {
    // 不是虚拟节点 则包装成虚拟节点
    oldVnode = emptyNodeAt(oldVnode)
  }

  if (sameVnode(oldVnode, newVnode)) {
    // 精细化比较
  } else {
    // 暴力插入新的,删除旧的
    let newVnodeElm = createElement(newVnode)

    if (oldVnode.elm.parentNode && newVnodeElm) {
      oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
    }
  }
}

页面成功显示:

例2:

const vnode1 = h('ul', {}, [
  h('li', {}, 'A'),
  h('li', {}, 'B'),
  h('li', {}, 'C'),
  h('li', {}, [
    h('div',{},'DD1'),
    h('div',{},'DD2'),
    h('div',{},'DD3'),
  ]),
])

patch(container, vnode1)

到此,我们的递归写得差不多了。

噢,对了,我们还没有删除老节点,只需要加上一行代码即可。

if (oldVnode.elm.parentNode && newVnodeElm) {
   oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
   // 删除老节点
   oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}

我们再把按钮加上,改变DOM:

import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'

const container = document.getElementById("container");
const btn = document.getElementById("btn");

const vnode1 = h('ul', {}, [
  h('li', {}, 'A'),
  h('li', {}, 'B'),
  h('li', {}, 'C'),
])

patch(container, vnode1)

btn.onclick = function () {
  const vnode2 = h('ol', {}, [
    h('li', {}, 'AA'),
    h('li', {}, 'BB'),
    h('li', {}, 'CC'),
  ])

  patch(vnode1, vnode2)
}

over。

3.3 diff处理新旧节点是同一个节点时

先上个流程图:

3.3.1 newVnode有text的情况

if (sameVnode(oldVnode, newVnode)) {
    // 在内存中是同一个对象
    if (oldVnode === newVnode) return
    if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
      // 新vnode有text属性
      if (newVnode.text !== oldVnode.text) {
        oldVnode.elm.innerText = newVnode.text
      }
    } else {
      // 新vnode没有text属性
    }
}

我们实现了newVnode有text的情况,下面两段代码都能实现效果,比较简单,这里就不上图了。

// 第一段
const vnode1 = h('h2', {}, 'hh')

patch(container, vnode1)

btn.onclick = function () {
  const vnode2 = h('h2', {}, 'xx')
  patch(vnode1, vnode2)
}

// 第二段
const vnode1 = h('h2', {}, [
  h('p', {}, 'A'),
  h('p', {}, 'B'),
  h('p', {}, 'C'),
])

patch(container, vnode1)

btn.onclick = function () {
  const vnode2 = h('h2', {}, 'xx')
  patch(vnode1, vnode2)
}

3.3.2 newVnode没有text的情况(有children)

1、oldVnode没有children

if (sameVnode(oldVnode, newVnode)) {
    // 在内存中是同一个对象
    if (oldVnode === newVnode) return
    if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
      // 新vnode有text属性
      if (newVnode.text !== oldVnode.text) {
        oldVnode.elm.innerText = newVnode.text
      }
    } else {
      // 新vnode没有text属性
      if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
        // 老的有children
        // 最复杂的情况
      } else {
        // 老的没有children 新的有children
        // 第一步:清空老节点内容
        oldVnode.elm.innerText = ''
        // 第二步:遍历新的vnode子节点 创建DOM上树
        for (let i = 0; i < newVnode.children.length; i++) {
          const dom = createElement(newVnode.children[i])
          oldVnode.elm.appendChild(dom)
        }
      }
    }
}

实现效果:

const vnode1 = h('h2', {}, 'hh')

patch(container, vnode1)

btn.onclick = function () {
  const vnode2 = h('h2', {}, [
    h('p', {}, 'A'),
    h('p', {}, 'B'),
    h('p', {}, 'C')
  ])
  patch(vnode1, vnode2)
}

2、oldVnode有children

这一块是diff算法最核心的部分。
为了便于后续操作,我们先把上面的代码单独抽出来,新增patchVnode方法:

function patchVnode(oldVnode, newVnode) {
  // 在内存中是同一个对象
  if (oldVnode === newVnode) return
  if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
    // 新vnode有text属性
    if (newVnode.text !== oldVnode.text) {
      oldVnode.elm.innerText = newVnode.text
    }
  } else {
    // 新vnode没有text属性
    if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
      // 老的有children
      // 最复杂的情况
    } else {
      // 老的没有children 新的有children
      // 第一步:清空老节点内容
      oldVnode.elm.innerText = ''
      // 第二步:便利新的vnode子节点 创建DOM上树
      for (let i = 0; i < newVnode.children.length; i++) {
        let dom = createElement(newVnode.children[i])
        oldVnode.elm.appendChild(dom)
      }
    }
  }
}

export default function patch(oldVnode, newVnode) {
  // 判断是不是虚拟节点
  if (!isVnode(oldVnode)) {
    // 不是虚拟节点 则包装成虚拟节点
    oldVnode = emptyNodeAt(oldVnode)
  }
  if (sameVnode(oldVnode, newVnode)) {
    patchVnode(oldVnode, newVnode)
  } else {
    // 暴力插入新的,删除旧的
    let newVnodeElm = createElement(newVnode)
    if (oldVnode.elm.parentNode && newVnodeElm) {
      oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
      // 删除老节点
      oldVnode.elm.parentNode.removeChild(oldVnode.elm)
    }
  }
}

3.3.3 分析diff算法 更新子节点操作(重要)

diff提供了4种命中查找方式(4个指针):
1、新前与旧前
2、新后与旧后
3、新后与旧前(涉及移动节点,新后指向的节点,移动到旧后之后)
4、新前与旧后(涉及移动节点,新前指向的节点,移动到旧前之前)
命中判断由上往下,命中一种就不会再命中判断了。
如果都没有命中,就循环来寻找。

1、新前 newStart 与旧前 oldStart
如果命中 1 了,patch之后就移动指针,newStart++,oldStart++

新增 updateChildren 方法。

function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0 // 旧前
  let newStartIdx = 0 // 新前
  let oldEndIdx = oldCh.length - 1 // 旧后
  let newEndIdx = newCh.length - 1 // 新后
  let oldStartVnode = oldCh[0] // 旧前节点
  let oldEndVnode = oldCh[oldEndIdx] // 旧后节点
  let newStartVnode = newCh[0] // 新前节点
  let newEndVnode = newCh[newEndIdx] // 新后节点
}

function patchVnode(oldVnode, newVnode) {
  // 在内存中是同一个对象
  if (oldVnode === newVnode) return
  if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
    // 新vnode有text属性
    if (newVnode.text !== oldVnode.text) {
      oldVnode.elm.innerText = newVnode.text
    }
  } else {
    // 新vnode没有text属性
    if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
      // 老的有children
      // 最复杂的情况
      updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
    } else {
      // 老的没有children 新的有children
      // 第一步:清空老节点内容
      oldVnode.elm.innerText = ''
      // 第二步:便利新的vnode子节点 创建DOM上树
      for (let i = 0; i < newVnode.children.length; i++) {
        let dom = createElement(newVnode.children[i])
        oldVnode.elm.appendChild(dom)
      }
    }
  }
}

开始循环判断:

function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0 // 旧前
  let newStartIdx = 0 // 新前
  let oldEndIdx = oldCh.length - 1 // 旧后
  let newEndIdx = newCh.length - 1 // 新后
  let oldStartVnode = oldCh[0] // 旧前节点
  let oldEndVnode = oldCh[oldEndIdx] // 旧后节点
  let newStartVnode = newCh[0] // 新前节点
  let newEndVnode = newCh[newEndIdx] // 新后节点
  
  // 开始循环
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode)
      // 移动指针
      oldStartVnode = oldCh(++oldStartIdx)
      newStartVnode = newCh(++newStartIdx)
    }
  }
}

如果没命中,就接着比较下一种情况。

2、新后 newEnd 和 旧后 oldEnd
如果命中 1 了,patch之后就移动指针,newEnd--,oldEnd--

if (sameVnode(newEndVnode, oldEndVnode)) { // 新后与旧后
    patchVnode(oldEndVnode, newEndVnode)
    // 移动指针
    oldEndVnode = oldCh[--oldEndIdx]
    newEndVnode = newCh[--newEndIdx]
}

如果没命中,就接着比较下一种情况。

3、新后 newEnd 与旧前 oldStart
如果命中 3 了,将 新后newEnd 指向的节点移动到 旧后oldEnd 之后,移动指针oldStart++,newEnd--

if (sameVnode(newEndVnode, oldStartVnode)) { // 新后与旧前
    patchVnode(oldStartVnode, newEndVnode);
    // 当 新后与旧前 命中的时候,此时要移动节点。移动 新后(旧前)指向的这个节点到老节点的 旧后的后面
    // 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
    parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
    oldStartVnode = oldCh[++oldStartIdx];
    newEndVnode = newCh[--newEndIdx];
}

如果没命中,就接着比较下一种情况。

4、新前 newStart 与旧后 oldEnd
如果命中 4 了,将 新前newStart 指向的节点移动到 旧前oldStart 之前,oldEnd--,newStart++

if (sameVnode(newStartVnode, oldEndVnode)) { // 新前与旧后
    patchVnode(oldEndVnode, newStartVnode);
    // 当 新前与旧后 命中的时候,此时要移动节点。移动 新前(旧后)指向的这个节点到老节点的 旧前的前面
    // 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
    parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
    oldEndVnode = oldCh[--oldEndIdx];
    newStartVnode = newCh[++newStartIdx];
}

5、四种都没命中,遍历oldVnode中的key 找到了就移动位置,移动指针newStart++ 用例子分析如下:

const vnode1 = h('ul', {}, [
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'D' }, 'D'),
  h('li', { key: 'E' }, 'E'),
])

patch(container, vnode1)

btn.onclick = function () {
  const vnode2 = h('ul', {}, [
    h('li', { key: 'B' }, 'B'),
    h('li', { key: 'Q' }, 'Q'),
    h('li', { key: 'A' }, 'A'),
    h('li', { key: 'D' }, 'D'),
  ])
  patch(vnode1, vnode2)
}

通过这个流程,我们可以写出下面的代码:

let keyMap = null;
// 开始循环
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 首先不是判断四种命中,而是要略过已经加undefined标记的东西
    if (oldStartVnode == null) {
      oldStartVnode = oldCh[++oldStartIdx];
    } else if (oldEndVnode == null) {
      oldEndVnode = oldCh[--oldEndIdx];
    } else if (newStartVnode == null) {
      newStartVnode = newCh[++newStartIdx];
    } else if (newEndVnode == null) {
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldStartVnode, newStartVnode)) { // 新前与旧前
      patchVnode(oldStartVnode, newStartVnode)
      // 移动指针
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(newEndVnode, oldEndVnode)) { // 新后与旧后
      patchVnode(oldEndVnode, newEndVnode)
      // 移动指针
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(newEndVnode, oldStartVnode)) { // 新后与旧前
      patchVnode(oldStartVnode, newEndVnode);
      // 当 新后与旧前 命中的时候,此时要移动节点。移动 新后(旧前)指向的这个节点到老节点的 旧后的后面
      // 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(newStartVnode, oldEndVnode)) { // 新前与旧后
      patchVnode(oldEndVnode, newStartVnode);
      // 当 新前与旧后 命中的时候,此时要移动节点。移动 新前(旧后)指向的这个节点到老节点的 旧前的前面
      // 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
    } else {
      // 用这种方式替换每次遍历操作 很优雅
      if (!keyMap) {
        keyMap = {}
        for (let i = oldStartIdx; i <= oldEndIdx; i++) {
          const key = oldCh[i].key
          if (key) {
            keyMap[key] = i
          }
        }
      }
      // 寻找当前项(newStartIdx)在keyMap中映射的序号
      const idxInOld = keyMap[newStartVnode.key];
      if (!idxInOld) {
        // 如果 idxInOld 是 undefined 说明是全新的项,要插入
        // 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
        const dom = createElement(newStartVnode)
        parentElm.insertBefore(dom, oldStartVnode.elm);
      } else {
        // 说明不是全新的项,要移动
        const elmToMove = oldCh[idxInOld];
        patchVnode(elmToMove, newStartVnode);
        // 把这项设置为undefined,表示我已经处理完这项了
        oldCh[idxInOld] = undefined;
        // 移动
        parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
      }
      newStartVnode = newCh[++newStartIdx];
    }
  }

6、while循环结束后

循环结束后,有两种情况:

  • newStartIdx<=newEndIdx
    说明newVnode还有剩余节点没处理完成,所以要添加这些节点
  • oldStartIdx<=oldEndIdx
    说明oldVnode还有剩余节点没处理完成,所以要删除这些节点
// 循环结束
if (newStartIdx <= newEndIdx) {
  // 说明newVndoe还有剩余节点没有处理,所以要添加这些节点
  const before = oldCh[oldStartIdx] == null ? null : oldCh[oldStartIdx].elm;
  for (let i = newStartIdx; i <= newEndIdx; i++) {
    const dom = createElement(newCh[i])
    parentElm.insertBefore(dom, before);
  }
} else if (oldStartIdx <= oldEndIdx) {
  // 说明oldVnode还有剩余节点没有处理,所以要删除这些节点
  for (let i = oldStartIdx; i <= oldEndIdx; i++) {
    if (oldCh[i]) {
      parentElm.removeChild(oldCh[i].elm);
    }
  }
}

至此,diff算法已大工告成!!!

用上面的例子演示:

const vnode1 = h('ul', {}, [
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'D' }, 'D'),
  h('li', { key: 'E' }, 'E'),
])

patch(container, vnode1)

btn.onclick = function () {
  const vnode2 = h('ul', {}, [
    h('li', { key: 'B' }, 'B'),
    h('li', { key: 'Q' }, 'Q'),
    h('li', { key: 'A' }, 'A'),
    h('li', { key: 'D' }, 'D'),
  ])
  patch(vnode1, vnode2)
}

四、patch函数整体代码

import vnode from './vnode.js'
import api from './htmldomapi.js'

// 是不是虚拟节点
function isVnode(vnode) {
  return vnode.sel === '' || vnode.sel !== undefined;
}

// 把oldVnode包装成虚拟节点
function emptyNodeAt(elm) {
  return vnode(
    api.tagName(elm).toLowerCase(),
    {},
    [],
    undefined,
    elm
  );
}

// 是不是同一个虚拟节点
function sameVnode(vnode1, vnode2) {
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}

// 真正创建节点,将 vnode 创建为 DOM 但不进行插入操作
function createElement(vnode) {
  // 创建DOM节点
  let domNode = document.createElement(vnode.sel)
  // 有子节点还是有文本
  if (vnode.text !== '' && (vnode.children === undefined || vnode.children.length === 0)) {
    // 他内部是文本
    domNode.innerText = vnode.text
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
    // 内部是子节点,就要递归创建节点
    for (let i = 0; i < vnode.children.length; i++) {
      // 得到当前children
      let ch = vnode.children[i]
      // 创建出它的dom,一旦调用了createElement意味着:创建出dom了,并且它的elm属性指向了创建出的dom,但是还没有上树
      let chDom = createElement(ch)
      // 上树
      domNode.appendChild(chDom)
    }
  }
  // 补充elm属性
  vnode.elm = domNode
  // 返回elm 纯dom对象
  return vnode.elm
}

function patchVnode(oldVnode, newVnode) {
  // 在内存中是同一个对象
  if (oldVnode === newVnode) return
  if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
    // 新vnode有text属性
    if (newVnode.text !== oldVnode.text) {
      oldVnode.elm.innerText = newVnode.text
    }
  } else {
    // 新vnode没有text属性
    if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
      // 老的有children
      // 最复杂的情况
      updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
    } else {
      // 老的没有children 新的有children
      // 第一步:清空老节点内容
      oldVnode.elm.innerText = ''
      // 第二步:便利新的vnode子节点 创建DOM上树
      for (let i = 0; i < newVnode.children.length; i++) {
        let dom = createElement(newVnode.children[i])
        oldVnode.elm.appendChild(dom)
      }
    }
  }
}

function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0 // 旧前
  let newStartIdx = 0 // 新前
  let oldEndIdx = oldCh.length - 1 // 旧后
  let newEndIdx = newCh.length - 1 // 新后
  let oldStartVnode = oldCh[0] // 旧前节点
  let oldEndVnode = oldCh[oldEndIdx] // 旧后节点
  let newStartVnode = newCh[0] // 新前节点
  let newEndVnode = newCh[newEndIdx] // 新后节点

  let keyMap = null

  // 开始循环
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 首先不是判断四种命中,而是要略过已经加undefined标记的东西
    if (oldStartVnode == null) {
      oldStartVnode = oldCh[++oldStartIdx];
    } else if (oldEndVnode == null) {
      oldEndVnode = oldCh[--oldEndIdx];
    } else if (newStartVnode == null) {
      newStartVnode = newCh[++newStartIdx];
    } else if (newEndVnode == null) {
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldStartVnode, newStartVnode)) { // 新前与旧前
      patchVnode(oldStartVnode, newStartVnode)
      // 移动指针
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(newEndVnode, oldEndVnode)) { // 新后与旧后
      patchVnode(oldEndVnode, newEndVnode)
      // 移动指针
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(newEndVnode, oldStartVnode)) { // 新后与旧前
      patchVnode(oldStartVnode, newEndVnode);
      // 当 新后与旧前 命中的时候,此时要移动节点。移动 新后(旧前)指向的这个节点到老节点的 旧后的后面
      // 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(newStartVnode, oldEndVnode)) { // 新前与旧后
      patchVnode(oldEndVnode, newStartVnode);
      // 当 新前与旧后 命中的时候,此时要移动节点。移动 新前(旧后)指向的这个节点到老节点的 旧前的前面
      // 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
    } else {
      if (!keyMap) {
        keyMap = {}
        for (let i = oldStartIdx; i <= oldEndIdx; i++) {
          const key = oldCh[i].key
          if (key) {
            keyMap[key] = i
          }
        }
      }
      // 寻找当前项(newStartIdx)在keyMap中映射的序号
      const idxInOld = keyMap[newStartVnode.key];
      if (!idxInOld) {
        // 如果 idxInOld 是 undefined 说明是全新的项,要插入
        // 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
        const dom = createElement(newStartVnode)
        parentElm.insertBefore(dom, oldStartVnode.elm);
      } else {
        // 说明不是全新的项,要移动
        const elmToMove = oldCh[idxInOld];
        patchVnode(elmToMove, newStartVnode);
        // 把这项设置为undefined,表示我已经处理完这项了
        oldCh[idxInOld] = undefined;
        // 移动,调用insertBefore也可以实现移动。
        parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
      }
      newStartVnode = newCh[++newStartIdx];
    }
  }

  // 循环结束
  if (newStartIdx <= newEndIdx) {
    // 说明newVndoe还有剩余节点没有处理,所以要添加这些节点
    const before = oldCh[oldStartIdx] == null ? null : oldCh[oldStartIdx].elm;
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      const dom = createElement(newCh[i])
      parentElm.insertBefore(dom, before);
    }
  } else if (oldStartIdx <= oldEndIdx) {
    // 说明oldVnode还有剩余节点没有处理,所以要删除这些节点
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      if (oldCh[i]) {
        parentElm.removeChild(oldCh[i].elm);
      }
    }
  }
}

export default function patch(oldVnode, newVnode) {
  // 判断是不是虚拟节点
  if (!isVnode(oldVnode)) {
    // 不是虚拟节点 则包装成虚拟节点
    oldVnode = emptyNodeAt(oldVnode)
  }
  if (sameVnode(oldVnode, newVnode)) {
    patchVnode(oldVnode, newVnode)
  } else {
    // 暴力插入新的,删除旧的
    let newVnodeElm = createElement(newVnode)
    if (oldVnode.elm.parentNode && newVnodeElm) {
      oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
      // 删除老节点
      oldVnode.elm.parentNode.removeChild(oldVnode.elm)
    }
  }
}

如果这篇文章对您有帮助,请给个赞吧~~~