从了解到深入虚拟DOM和实现diff算法

20,494 阅读19分钟

Virtual DOM 和 diff 算法

前言

虚拟DOMdiff 算法 ,大家有的时候就会经常听到,那么它们是什么实现的呢,这是小浪我在学习的 虚拟DOMdiff 的时候总结,在这里就来带大家来深入了解 virtual DOMdiff 算法,从 snabbdom 的基础使用 ,到自己实现一个丐版 snabbdom,自己实现 h函数(创建虚拟DOM) patch函数(通过比较新旧虚拟DOM更新视图),这里我也画了几个动图 来帮助大家理解 diff 的四种优化策略,文章有点长,希望大家耐心阅读,最后会贴出所有代码,大家可以动手试试喔

最后希望大家能给小浪一个

往期精彩:

手写一个简易vue响应式带你了解响应式原理

从使用到自己实现简单Vue Router看这个就行了

前端面试必不可少的基础知识,虽然少但是你不能不知道

1.介绍

Virtual DOM 简单的介绍

JavaScript按照DOM的结构来创建的虚拟树型结构对象,是对DOM的抽象,比DOM更加轻量型

为啥要使用Virtual DOM

  • 当然是前端优化方面,避免频繁操作DOM,频繁操作DOM会可能让浏览器回流和重绘,性能也会非常低,还有就是手动操作 DOM 还是比较麻烦的,要考虑浏览器兼容性问题,当前jQuery等库简化了 DOM操作,但是项目复杂了,DOM操作还是会变得复杂,数据操作也变得复杂
  • 并不是所有情况使用虚拟DOM 都提高性能,是针对在复杂的的项目使用。如果简单的操作,使用虚拟DOM,要创建虚拟DOM对象等等一系列操作,还不如普通的DOM 操作
  • 虚拟DOM 可以实现跨平台渲染,服务器渲染 、小程序、原生应用都使用了虚拟DOM
  • 使用虚拟DOM改变了当前的状态不需要立即的去更新DOM 而且更新的内容进行更新,对于没有改变的内容不做任何操作,通过前后两次差异进行比较
  • 虚拟 DOM 可以维护程序的状态,跟踪上一次的状态

2.snabbdom 介绍

首先来介绍下 snabbdom

我们要了解虚拟DOM ,那么就先了解它的始祖,也就是 snabbdom

snabbdom 是一个开源的项目,Vue 里面的 虚拟DOM 当初是借鉴了 snabbdom,我们可以通过了解snabbdom 的虚拟DOM 来理解 Vue 的虚拟DOM,Vue 的源码太多,snabbdom 比较简洁,所以用它来展开 虚拟 DOM 的研究

通过npm 进行安装

npm install snabbdom

1.snabbdom简单使用

下面来写个简单的例子使用下 snabbdom

<body>
  <div id="app"></div>
  <script src="./js/test.js"></script>
</body>

写个 test.js 进行使用

/* test.js */

// 导入 snabbdom
import { h, init, thunk } from 'snabbdom'
// init() 方法返回一个 patch 函数 用来比较两个虚拟DOM 的差异 然后更新到真实的DOM里
// 这里暂时传入一个空数组 []
let patch = init([])
// h 方法是用来创建 Virtual DOM
// 第一个参数是 虚拟DOM 标签
// 第二个参数是 虚拟DOM 的数据
// 第三个参数是 虚拟DOM 的子虚拟DOM
// 它有好几种传参方式 h函数做了重载 这里就 用上面的传参
// 而且可以进行嵌套使用
let vnode = h('div#box', '测试', [
  h('ul.list', [
    h('li', '我是一个li'),
    h('li', '我是一个li'),
    h('li', '我是一个li'),
  ]),
])
// 获取到 html 的 div#app
let app = document.querySelector('#app')
// 用来比较两个虚拟DOM 的差异 然后更新到真实的DOM里
let oldNode = patch(app, vnode)
// 再来模拟一个异步请求
setTimeout(() => {
  let vNode = h('div#box', '重新获取了数据', [
    h('ul.list', [
      h('li', '我是一个li'),
      h('li', '通过path判断了差异性'),
      h('li', '更新了数据'),
    ]),
  ])
  // 再来进行比较差异判断是否更新
  patch(oldNode, vNode)
}, 3000)

image-20210726224703891

可以看见把 虚拟DOM更新到了 真实DOM ,直接 把之前的 div#app 给替换更新了

9

过了3秒进行对比虚拟DOM 的 差异来添加到真实DOM ,这里改变了第二个和第三个 li 用h函数渲染成虚拟DOMoldNode 不一样所以进行了对比更新

2.介绍下 snabbdom中的模块

几个模块 这里简单过一下

模块名简介
attributesDOM 自定义属性,包括两个布尔值 checked selected,通过setAttribute() 设置
props是DOM 的 property属性,通过 element[attr] = value 设置
datasetdata- 开头的属性 data-src...
style行内样式
eventListeners用来注册和移除事件

有了上面的介绍,那我们就来简单的使用一下

/* module_test.js */

// 第一步当然是先导入 snabbdom 的 init() h()
import { h, init } from 'snabbdom'

// 导入模块
import attr from 'snabbdom/modules/attributes'
import style from 'snabbdom/modules/style'
import eventListeners from 'snabbdom/modules/eventlisteners'

// init()注册模块 返回值是 patch 函数用来比较 两个虚拟DOM 差异 然后添加到 真实DOM
let patch = init([attr, style, eventListeners])

// 使用 h() 渲染一个虚拟DOM
let vnode = h(
  'div#app',
  {
    // 自定义属性
    attrs: {
      myattr: '我是自定义属性',
    },
    // 行内样式
    style: {
      fontSize: '29px',
      color: 'skyblue',
    },
    // 事件绑定
    on: {
      click: clickHandler,
    },
  },
  '我是内容'
)

// 点击处理方法
function clickHandler() {
  // 拿到当前 DOM
  let elm = this.elm
  elm.style.color = 'red'
  elm.textContent = '我被点击了'
}

// 获取到 div#app
let app = document.querySelector('#app')

// patch 比较差异 ,然后添加到真实DOM 中
patch(app, vnode)

然后再 html 中引入

<body>
  <div id="app"></div>
  <script src="./js/module_test.js"></script>
  <script></script>
</body>

来看看效果

11

可以看见的是 自定义属性 ,行内样式 ,点击事件都被 h() 渲染出来了

上面的使用都简单地过了一遍,那么我们就来看看 snabbdom 中的源码吧

3.虚拟DOM 例子

说了这么久的 h() 函数和 虚拟DOM 那么 渲染出来的 虚拟DOM 是什么样呢

真实DOM 结构

<div class="container">
  <p>哈哈</p>
  <ul class="list">
    <li>1</li>
    <li>2</li>
  </ul>
</div>

转为为 虚拟DOM 之后的结构

{ 
  // 选择器
  "sel": "div",
  // 数据
  "data": {
    "class": { "container": true }
  },
  // DOM
  "elm": undefined,
  // 和 Vue :key 一样是一种优化
  "key": undefined,
  // 子节点
  "children": [
    {
      "elm": undefined,
      "key": undefined,
      "sel": "p",
      "data": { "text": "哈哈" }
    },
    {
      "elm": undefined,
      "key": undefined,
      "sel": "ul",
      "data": {
        "class": { "list": true }
      },
      "children": [
        {
          "elm": undefined,
          "key": undefined,
          "sel": "li",
          "data": {
            "text": "1"
          },
          "children": undefined
        },
        {
          "elm": undefined,
          "key": undefined,
          "sel": "li",
          "data": {
            "text": "1"
          },
          "children": undefined
        }
      ]
    }
  ]
}

在之前提到的 snabbdompatch方法

就是对 新的虚拟DOM老的虚拟DOM 进行diff(精细化比较),找出最小量更新 是在虚拟DOM 比较

不可能把所有的 DOM 都拆掉 然后全部重新渲染

4.h 函数

在上面我们体验了虚拟DOM的使用 ,那么我们现在来实现一个 丐版的 snabbdom

h 函数在介绍下

snabbdom 我们也使用了多次的 h 函数,主要作用是创建 虚拟节点

snabbdom 使用 TS 编写, 所以 h 函数中做了 方法重载 使用起来灵活

下面是 snabbdomh 函数,可以看出 参数的有好几种方式

export declare function h(sel: string): VNode;
export declare function h(sel: string, data: VNodeData): VNode;
export declare function h(sel: string, children: VNodeChildren): VNode;
export declare function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;

实现 vnode 函数

在写 h 函数之前 先实现 vnode 函数,vnode 函数要在 h 中使用, 其实这个 vnode 函数实现功能非常简单 在 TS 里面规定了很多类型,不过我这里和之后都是 用 JS 去写

/* vnode.js */

/**
 * 把传入的 参数 作为 对象返回
 * @param {string} sel 选择器
 * @param {object} data 数据
 * @param {array} children 子节点
 * @param {string} text 文本
 * @param {dom} elm DOM
 * @returns object
 */
export default function (sel, data, children, text, elm) {
  return { sel, data, children, text, elm }
}

实现简易 h 函数

这里写的 h 函数 只实现主要功能,没有实现重载,直接实现 3个 参数的 h 函数

/* h.js */

// 导入 vnode
import vnode from './vnode'

// 导出 h 方法
// 这里就实现简单3个参数 参数写死
/**
 *
 * @param {string} a sel
 * @param {object} b data
 * @param {any} c 是子节点 可以是文本,数组
 */
export default function h(a, b, c) {
  // 先判断是否有三个参数
  if (arguments.length < 3) throw new Error('请检查参数个数')
  // 第三个参数有不确定性 进行判断
  // 1.第三个参数是文本节点
  if (typeof c === 'string' || typeof c === 'number') {
    // 调用 vnode 这直接传 text 进去
    // 返回值 {sel,data,children,text,elm} 再返回出去
    return vnode(a, b, undefined, c, undefined)
  } // 2.第三个参数是数组 [h(),h()] [h(),text] 这些情况
  else if (Array.isArray(c)) {
    // 然而 数组里必须是 h() 函数
    // children 用收集返回结果
    let children = []
    // 先判断里面是否全是 h()执行完的返回结果 是的话添加到 chilren 里
    for (let i = 0; i < c.length; i++) {
      // h() 的返回结果 是{} 而且 包含 sel
      if (!(typeof c[i] === 'object' && c[i].sel))
        throw new Error('第三个参数为数组时只能传递 h() 函数')
      // 满足条件进行push [{sel,data,children,text,elm},{sel,data,children,text,elm}]
      children.push(c[i])
    }
    // 调用 vnode 返回 {sel,data,children,text,elm} 再返回
    return vnode(a, b, children, undefined, undefined)
  } // 3.第三个参数直接就是函数 返回的是 {sel,data,children,text,elm}
  else if (typeof c === 'object' && c.sel) {
    // 这个时候在 使用h()的时候 c = {sel,data,children,text,elm} 直接放入children
    let children = [c]
    // 调用 vnode 返回 {sel,data,children,text,elm} 再返回
    return vnode(a, b, children, undefined, undefined)
  }
}

是不是很简单呢,他说起来也不是递归,像是一种嵌套,不断地收集 {sel,data,children,text,elm}

chirldren 里面再套 {sel,data,children,text,elm}

举个例子

/* index.js */

import h from './my-snabbdom/h'

let vnode = h('div', {}, 
  h('ul', {}, [
    h('li', {}, '我是一个li'),
    h('li', {}, '我是一个li'),
    h('li', {}, '我是一个li'),
  ),
])
console.log(vnode)

<body>
  <div id="container"></div>
  <script src="/virtualdir/bundle.js"></script>
</body>

image-20210727204731661

OK,写的 h 函数没有问题,生成了虚拟DOM 树,生成了虚拟 DOM,我们之后 就会用的到

简单说下流程吧

大家都知道js 函数执行,当然是先执行最里面的 函数

  • 1.h('li', {}, '我是一个li')第一个执行 返回的 {sel,data,children,text,elm} 连续三个 li 都是这个

  • 2.接着就是 h('ul', {}, []) 进入到了第二个判断是否为数组,然后 把每一项 进行判断是否对象 和 有sel 属性,然后添加到 children 里面又返回了出去 {sel,data,children,text,elm}

  • 3.第三就是执行 h('div', {},h()) 了, 第三个参数 直接是 h()函数 = {sel,data,children,text,elm} ,他的 children 把他用 [ ] 包起来

    再返回给 vnode

5.patch 函数

简介

snabbdom 中我们 通过 init() 返回了一个 patch 函数,通过 patch 进行吧比较两个 虚拟 DOM 然后添加的 真实的 DOM 树上,中间比较就是我们等下要说的 diff

先来了解下 patch里面做了什么

image-20210728172052418

按照上面的流程我们来写个简单的 patch

1.patch

先写个sameVnode

用来对比两个虚拟DOMkeysel

/* sameVnode.js */

/**
 * 判断两个虚拟节点是否是同一节点
 * @param {vnode} vnode1 虚拟节点1
 * @param {vnode} vnode2 虚拟节点2
 * @returns boolean
 */
export default function sameVnode(vnode1, vnode2) {
  return (
    (vnode1.data ? vnode1.data.key : undefined) ===
      (vnode2.data ? vnode2.data.key : undefined) && vnode1.sel === vnode2.sel
  )
}

写个基础的patch

/* patch.js */

// 导入 vnode
import vnode from './vnode'


// 导出 patch
/**
 *
 * @param {vnode/DOM} oldVnode
 * @param {vnode} newVnode
 */
export default function patch(oldVnode, newVnode) {
  // 1.判断oldVnode 是否为虚拟 DOM 这里判断是否有 sel
  if (!oldVnode.sel) {
    // 转为虚拟DOM
    oldVnode = emptyNodeAt(oldVnode)
  }
  // 判断 oldVnode 和 newVnode 是否为同一虚拟节点
  // 通过 key 和 sel 进行判断
  if (sameVnode(oldVnode, newVnode)) {
    // 是同一个虚拟节点 调用我们写的 patchVnode.js 中的方法
    ...
  } else {
    // 不是同一虚拟个节点 直接暴力拆掉老节点,换上新的节点
    ...
  }
  newVnode.elm = oldVnode.elm

  // 返回newVnode作为 旧的虚拟节点
  return newVnode
}

/**
 * 转为 虚拟 DOM
 * @param {DOM} elm DOM节点
 * @returns {object}
 */
function emptyNodeAt(elm) {
  // 把 sel 和 elm 传入 vnode 并返回
  // 这里主要选择器给转小写返回vnode
  // 这里功能做的简陋,没有去解析 # .
  // data 也可以传 ID 和 class
  return vnode(elm.tagName.toLowerCase(), undefined, undefined, undefined, elm)
}

现在要处理是否是 同一个虚拟节点的问题

2.createElm

先来处理不是同一个虚拟节点

处理这个我们得去写个 创建节点的方法 这里就放到 createElm.js 中完成

/* createElm.js */

/**
 * 创建元素
 * @param {vnode} vnode 要创建的节点
 */
export default function createElm(vnode) {
  // 拿出 新创建的 vnode 中的 sel
  let node = document.createElement(vnode.sel)
  // 存在子节点
  // 子节点是文本
  if (
    vnode.text !== '' &&
    (vnode.children === undefined || vnode.children.length === 0)
  ) {
    // 直接添加文字到 node 中
    node.textContent = vnode.text

    // 子节点是数组
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
    let children = vnode.children
    // 遍历数组
    for (let i = 0; i < children.length; i++) {
      // 获取到每一个数组中的 子节点
      let ch = children[i]
      // 递归的方式 创建节点
      let chDom = createElm(ch)
      // 把子节点添加到 自己身上
      node.appendChild(chDom)
    }
  }
  // 更新vnode 中的 elm
  vnode.elm = node
  // 返回 DOM
  return node
}

上面的 createElm 就是使用了递归的方式去创建子节点 ,然后我们就去 patch 中 具体的调用这个 创建节点的方法

/* patch.js */

// 导入 vnode createELm
import vnode from './vnode'
import createElm from './createElm'


// 导出 patch
/**
 *
 * @param {vnode/DOM} oldVnode
 * @param {vnode} newVnode
 */
export default function patch(oldVnode, newVnode) {
  // 1.判断oldVnode 是否为虚拟 DOM 这里判断是否有 sel
  if (!oldVnode.sel) {
    // 转为虚拟DOM
    oldVnode = emptyNodeAt(oldVnode)
  }
  // 判断 oldVnode 和 newVnode 是否为同一虚拟节点
  // 通过 key 和 sel 进行判断
  if (sameVnode(oldVnode, newVnode)) {
    // 是同一个虚拟节点 调用我们写的 patchVnode.js 中的方法
    ...
  } else {
    // 不是同一虚拟个节点 直接暴力拆掉老节点,换上新的节点
    // 这里通过 createElm 递归 转为 真实的 DOM 节点
    let newNode = createElm(newVnode)
    // 旧节点的父节点
    if (oldVnode.elm.parentNode) {
      let parentNode = oldVnode.elm.parentNode
      // 添加节点到真实的DOM 上
      parentNode.insertBefore(newNode, oldVnode.elm)
      // 删除旧节点
      parentNode.removeChild(oldVnode.elm)
    }
  }
  newVnode.elm = oldVnode.elm
  return newVnode
}
...
}

在递归添加子节点 到了最后我们在 patch 添加到 真实的 DOM 中,移除之前的老节点

写到这里了来试试 不同节点 是否真的添加

/* index.js */

import h from './my-snabbdom/h'
import patch from './my-snabbdom/patch'


let app = document.querySelector('#app')

let vnode = h('ul', {}, [
  h('li', {}, '我是一个li'),
  h('li', {}, [
    h('p', {}, '我是一个p'),
    h('p', {}, '我是一个p'),
    h('p', {}, '我是一个p'),
  ]),
  h('li', {}, '我是一个li'),
])


let oldVnode = patch(app, vnode)

<body>
  <div id="app">hellow</div>
  <script src="/virtualdir/bundle.js"></script>
</body>

image-20210728164308771

div#app 给替换了,并且成功替换

3.patchVnode

我们现在来实现同一个虚拟 DOM 的处理

在 patchVnode 中

步骤都是按照 之前那个流程图进行编写,我们把比较两个相同的 虚拟 DOM 代码写在 patchVnode.js

在比较 两个相同的虚拟节点分支 有好几种情况

/* patchVnode.js */

// 导入 vnode createELm
import createElm from './createElm'

/**
 *
 * @param {vnode} oldVnode 老的虚拟节点
 * @param {vnode} newVnode 新的虚拟节点
 * @returns
 */
// 对比同一个虚拟节点
export default function patchVnode(oldVnode, newVnode) {
  // 1.判断是否相同对象
  console.log('同一个虚拟节点')
  if (oldVnode === newVnode) return
  // 2.判断newVnode上有没有text
  // 这里为啥不考虑 oldVnode呢,因为 newVnode有text说明就没children
  if (newVnode.text && !newVnode.children) {
    // 判断是text否相同
    if (oldVnode.text !== newVnode.text) {
      console.log('文字不相同')
      // 不相同就直接把 newVnode中text 给 elm.textContent
      oldVnode.elm.textContent = newVnode.text
    }
  } else {
    // 3.判断oldVnode有children, 这个时候newVnode 没有text但是有 children
    if (oldVnode.children) {
      ...这里新旧节点都存在children 这里要使用 updateChildren 下面进行实现
    } else {
      console.log('old没有children,new有children')
      // oldVnode没有 children ,newVnode 有children
      // 这个时候oldVnode 只有text 我们把 newVnode 的children拿过来
      // 先清空 oldVnode 中text
      oldVnode.elm.innerHTML = ''
      // 遍历 newVnode 中的 children
      let newChildren = newVnode.children
      for (let i = 0; i < newChildren.length; i++) {
        // 通过递归拿到了 newVnode 子节点
        let node = createElm(newChildren[i])
        // 添加到 oldVnode.elm 中
        oldVnode.elm.appendChild(node)
      }
    }
  }
}

按照流程图进行编码,现在要处理 newVnodeoldVnode 都存在 children 的情况了

在这里我们要进行精细化比较 也就是我们经常说的 diff

4.diff

经常听到的 diff(精细化比较) ,那我们先来了解下

diff四种优化策略

在这里要使用 4 个指针,从1-4的顺序来开始命中优化策略,命中一个,指针进行移动(新前和旧前向下移动,新后和旧后向上移动),没有命中,就使用下一个策略,如果四个策略都没有命中,只能靠循环来找

命中:两个节点 selkey 一样

  1. 新前与旧前
  2. 新后与旧后
  3. 新后与旧前
  4. 新前与旧后

先来说下新增的情况

四种策略都是在 循环里面执行

while(旧前<=旧后&&新前<=新后){
  ...
}

14

可以看出 旧子节点 先循环完毕,那么说明了新的子节点有需要 新增的 子节点

新前新后 的 节点 就是需要新增的字节

删除的情况1

19

这里新子节点 先循环完毕说明 旧子节点有需要删除的节点

删除的情况2

当我们删除多个,而且 4种策略都没有满足,我们得通过 while 循环 旧子节点 找到 新子节点需要寻找节点并标记为 undefined 虚拟节点是 undefined实际上在 DOM已经把它移动了 ,旧前旧后 之间的节点就是需要删除的节点

18

复杂情况1

当触发了 第四种 策略,这里就需要移动节点了,旧后指向的节点(在虚拟节点标为 undefined),实际把 新前 指向的节点 在DOM 中 移动到旧前之前

20

复杂情况2

当触发了 第三种 策略,这里也需要移动节点了,旧前 指向的节点(在虚拟节点标为 undefined),实际把 新后 指向的节点 在DOM 中 移动到旧后之后

21

注意几个点 :

  • h('li',{key:'A'} : "A"}) 比如这其中的 key 是这个节点的唯一的标识
  • 它的存在是在告诉 diff ,在更改前后它们是同一个DOM节点。
  • 只有是同一个虚拟节点,才进行精细化比较,否则就是暴力删除旧的、插入新的
  • 同一虚拟节点 不仅要 key 相同而且要 选择器相同也就是上面的 h() 函数创建的 虚拟节点 对象里的 sel
  • 只进行同层比较,不会进行跨层比较

5.updateChildren

看了上面对于 diff 的介绍,不知道我画的图 演示清楚了没,然后我们接着继续来完成 patchVnode

我们得写个 updateChildren 来进行精细化比较

这个文件就是 diff 算法的核心,我们用来比较 oldVnodenewVnode 都存在 children 的情况

这里有点绕,注释都写了,请耐心观看,流程就是按照 diff 的四种策略来写,还要处理没有命中的情况

/* updateChilren.js */

// 导入 createElm patchVnode sameVnode
import createElm from './createElm'
import patchVnode from './patchVnode'
import sameVnode from './sameVnode'

// 导出 updateChildren
/**
 *
 * @param {dom} parentElm 父节点
 * @param {array} oldCh 旧子节点
 * @param {array} newCh 新子节点
 */
export default function updateChildren(parentElm, oldCh, newCh) {
  // 下面先来定义一下之前讲过的 diff 的几个指针 和 指针指向的 节点
  // 旧前 和 新前
  let oldStartIdx = 0,
    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 (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
    console.log('---进入diff---')

    // 下面按照 diff 的4种策略来写 这里面还得调用 pathVnode
    // patchVnode 和 updateChildren 是互相调用的关系,不过这可不是死循环
    // 指针走完后就不调用了

    // 这一段都是为了忽视我们加过 undefined 节点,这些节点实际上已经移动了
    if (oldCh[oldStartIdx] == undefined) {
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (oldCh[oldEndIdx] == undefined) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (newCh[newStartIdx] == undefined) {
      newStartVnode = newCh[++newStartIdx]
    } else if (newCh[newEndIdx] == undefined) {
      newEndVnode = newCh[--newEndIdx]
    }
    // 忽视了所有的 undefined 我们这里来 判断四种diff优化策略
    // 1.新前 和 旧前
    else if (sameVnode(oldStartVnode, newStartVnode)) {
      console.log('1命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldStartVnode, newStartVnode)
      // 指针移动
      newStartVnode = newCh[++newStartIdx]
      oldStartVnode = oldCh[++oldStartIdx]
    } // 2.新后 和 旧后
    else if (sameVnode(oldEndVnode, newEndVnode)) {
      console.log('2命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldEndVnode, newEndVnode)
      // 指针移动
      newEndVnode = newCh[--newEndIdx]
      oldEndVnode = oldCh[--oldEndIdx]
    } // 3.新后 和 旧前
    else if (sameVnode(oldStartVnode, newEndVnode)) {
      console.log('3命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldStartVnode, newEndVnode)
      // 策略3是需要移动节点的 把旧前节点 移动到 旧后 之后
      // insertBefore 如果参照节点为空,就插入到最后 和 appendChild一样
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
      // 指针移动
      newEndVnode = newCh[--newEndIdx]
      oldStartVnode = oldCh[++oldStartIdx]
    }
    // 4.新前 和 旧后
    else if (sameVnode(oldEndVnode, newStartVnode)) {
      console.log('4命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldEndVnode, newStartVnode)
      // 策略4是也需要移动节点的 把旧后节点 移动到 旧前 之前
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
      // 指针移动
      newStartVnode = newCh[++newStartIdx]
      oldEndVnode = oldCh[--oldEndIdx]
    } else {
      console.log('diff四种优化策略都没命中')
      // 当四种策略都没有命中
      // keyMap 为缓存,这样就不用每次都遍历老对象
      if (!keyMap) {
        // 初始化 keyMap
        keyMap = {}
        // 从oldStartIdx到oldEndIdx进行遍历
        for (let i = oldStartIdx; i < oldEndIdx; i++) {
          // 拿个每个子对象 的 key
          const key = oldCh[i].data.key
          // 如果 key 不为 undefined 添加到缓存中
          if (!key) keyMap[key] = i
        }
      }

      // 判断当前项是否存在 keyMap 中 ,当前项时 新前(newStartVnode)
      let idInOld = keyMap[newStartIdx.data]
        ? keyMap[newStartIdx.data.key]
        : undefined

      // 存在的话就是移动操作
      if (idInOld) {
        console.log('移动节点')
        // 从 老子节点 取出要移动的项
        let moveElm = oldCh[idInOld]
        // 调用 patchVnode 进行对比 修改
        patchVnode(moveElm, newStartVnode)
        // 将这一项设置为 undefined
        oldCh[idInOld] = undefined
        // 移动 节点 ,对于存在的节点使用 insertBefore移动
        // 移动的 旧前 之前 ,因为 旧前 与 旧后 之间的要被删除
        parentElm.insertBefore(moveElm.elm, oldStartVnode.elm)
      } else {
        console.log('添加新节点')
        // 不存在就是要新增的项
        // 添加的节点还是虚拟节点要通过 createElm 进行创建 DOM
        // 同样添加到 旧前 之前
        parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.elm)
      }

      // 处理完上面的添加和移动 我们要 新前 指针继续向下走
      newStartVnode = newCh[++newStartIdx]
    }
  }
  // 我们添加和删除操作还没做呢
  // 首先来完成添加操作 新前 和 新后 中间是否还存在节点
  if (newStartIdx <= newEndIdx) {
    console.log('进入添加剩余节点')
    // 这是一个标识
    // let beforeFlag = oldCh[oldEndIdx + 1] ? oldCh[oldEndIdx + 1].elm : null
    let beforeFlag = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1] : null
    // new 里面还有剩余节点 遍历添加
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      // newCh里面的子节点还需要 从虚拟DOM 转为 DOM
      parentElm.insertBefore(createElm(newCh[i]), beforeFlag)
    }
  } else if (oldStartIdx <= oldEndIdx) {
    console.log('进入删除多余节点')
    // old 里面还有剩余 节点 ,旧前 和 旧后 之间的节点需要删除
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      // 删除 剩余节点之前 先判断下是否存在
      if (oldCh[i].elm) parentElm.removeChild(oldCh[i].elm)
    }
  }
}

到了这里我们基本写都完成了, h 函数 创建 虚拟 DOM , patch 比较 虚拟DOM 进行更新视图

6.我们来测试一下写的

其实在写代码的时候就在不断的调试。。。现在随便测试几个

1.代码

html

<body>
  <button class="btn">策略3</button>
  <button class="btn">复杂</button>
  <button class="btn">删除</button>
  <button class="btn">复杂</button>
  <button class="btn">复杂</button>
  <ul id="app">
    hellow
  </ul>

  <script src="/virtualdir/bundle.js"></script>
</body>

index.js

/* index.js */

import h from './my-snabbdom/h'
import patch from './my-snabbdom/patch'

let app = document.querySelector('#app')

let vnode = 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'),
])

let oldVnode = patch(app, vnode)

let vnode2 = h('ul', {}, [
  h('li', { key: 'E' }, 'E'),
  h('li', { key: 'D' }, 'D'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'A' }, 'A'),
])
let vnode3 = h('ul', {}, [
  h('li', { key: 'E' }, 'E'),
  h('li', { key: 'D' }, 'D'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'K' }, 'K'),
])
let vnode4 = h('ul', {}, [
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'C' }, 'C'),
])
let vnode5 = h('ul', {}, [
  h('li', { key: 'E' }, 'E'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'V' }, 'V'),
])
let vnode6 = 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' },
    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' }, h('div', { key: 'R' }, 'R')),
    ])
  ),
])
let vnodeList = [vnode2, vnode3, vnode4, vnode5, vnode6]
let btn = document.querySelectorAll('.btn')
for (let i = 0; i < btn.length; i++) {
  btn[i].onclick = () => {
    patch(vnode, vnodeList[i])
  }
}

2.演示

策略3

22

复杂

23

删除

24

复杂

25

复杂(这里是简单 。。)

26

7.结语

注释我都写了喔,大家可以对照 我上面画的图不清楚可以反复耐心的看哈

如果看的话没什么感觉,大家可以自己动手写写,下面我会贴出所有的代码

代码同样也放在 github

完整代码:

h.js

/* h.js */

// 导入 vnode
import vnode from './vnode'

// 导出 h 方法
// 这里就实现简单3个参数 参数写死
/**
 *
 * @param {string} a sel
 * @param {object} b data
 * @param {any} c 是子节点 可以是文本,数组
 */
export default function h(a, b, c) {
  // 先判断是否有三个参数
  if (arguments.length < 3) throw new Error('请检查参数个数')
  // 第三个参数有不确定性 进行判断
  // 1.第三个参数是文本节点
  if (typeof c === 'string' || typeof c === 'number') {
    // 调用 vnode 这直接传 text 进去
    // 返回值 {sel,data,children,text,elm} 再返回出去
    return vnode(a, b, undefined, c, undefined)
  } // 2.第三个参数是数组 [h(),h()] [h(),text] 这些情况
  else if (Array.isArray(c)) {
    // 然而 数组里必须是 h() 函数
    // children 用收集返回结果
    let children = []
    // 先判断里面是否全是 h()执行完的返回结果 是的话添加到 chilren 里
    for (let i = 0; i < c.length; i++) {
      // h() 的返回结果 是{} 而且 包含 sel
      if (!(typeof c[i] === 'object' && c[i].sel))
        throw new Error('第三个参数为数组时只能传递 h() 函数')
      // 满足条件进行push [{sel,data,children,text,elm},{sel,data,children,text,elm}]
      children.push(c[i])
    }
    // 调用 vnode 返回 {sel,data,children,text,elm} 再返回
    return vnode(a, b, children, undefined, undefined)
  } // 3.第三个参数直接就是函数 返回的是 {sel,data,children,text,elm}
  else if (typeof c === 'object' && c.sel) {
    // 这个时候在 使用h()的时候 c = {sel,data,children,text,elm} 直接放入children
    let children = [c]
    // 调用 vnode 返回 {sel,data,children,text,elm} 再返回
    return vnode(a, b, children, undefined, undefined)
  }
}

patch.js

/* patch.js */

// 导入 vnode createELm patchVnode sameVnode.js
import vnode from './vnode'
import createElm from './createElm'
import patchVnode from './patchVnode'
import sameVnode from './sameVnode'

// 导出 patch
/**
 *
 * @param {vnode/DOM} oldVnode
 * @param {vnode} newVnode
 */
export default function patch(oldVnode, newVnode) {
  // 1.判断oldVnode 是否为虚拟 DOM 这里判断是否有 sel
  if (!oldVnode.sel) {
    // 转为虚拟DOM
    oldVnode = emptyNodeAt(oldVnode)
  }
  // 判断 oldVnode 和 newVnode 是否为同一虚拟节点
  // 通过 key 和 sel 进行判断
  if (sameVnode(oldVnode, newVnode)) {
    // 是同一个虚拟节点 调用我们写的 patchVnode.js 中的方法
    patchVnode(oldVnode, newVnode)
  } else {
    // 不是同一虚拟个节点 直接暴力拆掉老节点,换上新的节点
    // 这里通过 createElm 递归 转为 真实的 DOM 节点
    let newNode = createElm(newVnode)
    // 旧节点的父节点
    if (oldVnode.elm.parentNode) {
      let parentNode = oldVnode.elm.parentNode
      // 添加节点到真实的DOM 上
      parentNode.insertBefore(newNode, oldVnode.elm)
      // 删除旧节点
      parentNode.removeChild(oldVnode.elm)
    }
  }
  newVnode.elm = oldVnode.elm
  // console.log(newVnode.elm)

  // 返回newVnode作为 旧的虚拟节点
  return newVnode
}

/**
 * 转为 虚拟 DOM
 * @param {DOM} elm DOM节点
 * @returns {object}
 */
function emptyNodeAt(elm) {
  // 把 sel 和 elm 传入 vnode 并返回
  // 这里主要选择器给转小写返回vnode
  // 这里功能做的简陋,没有去解析 # .
  // data 也可以传 ID 和 class
  return vnode(elm.tagName.toLowerCase(), undefined, undefined, undefined, elm)
}

createElm.js

/* createElm.js */

/**
 * 创建元素
 * @param {vnode} vnode 要创建的节点
 */
export default function createElm(vnode) {
  // 拿出 新创建的 vnode 中的 sel
  let node = document.createElement(vnode.sel)
  // 存在子节点
  // 子节点是文本
  if (
    vnode.text !== '' &&
    (vnode.children === undefined || vnode.children.length === 0)
  ) {
    // 直接添加文字到 node 中
    node.textContent = vnode.text
    // 子节点是数组
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
    let children = vnode.children
    // 遍历数组
    for (let i = 0; i < children.length; i++) {
      // 获取到每一个数组中的 子节点
      let ch = children[i]
      // 递归的方式 创建节点
      let chDom = createElm(ch)
      // 把子节点添加到 自己身上
      node.appendChild(chDom)
    }
  }
  // 更新vnode 中的 elm
  vnode.elm = node
  // 返回 DOM
  return node
}

vnode.js

/* vnode.js */

/**
 * 把传入的 参数 作为 对象返回
 * @param {string} sel 选择器
 * @param {object} data 数据
 * @param {array} children 子节点
 * @param {string} text 文本
 * @param {dom} elm DOM
 * @returns
 */
export default function (sel, data, children, text, elm) {
  return { sel, data, children, text, elm }
}

patchVnode.js

/* patchVnode.js */

// 导入 vnode createELm patchVnode updateChildren
import createElm from './createElm'
import updateChildren from './updateChildren'
/**
 *
 * @param {vnode} oldVnode 老的虚拟节点
 * @param {vnode} newVnode 新的虚拟节点
 * @returns
 */
// 对比同一个虚拟节点
export default function patchVnode(oldVnode, newVnode) {
  // 1.判断是否相同对象
  // console.log('同一个虚拟节点')
  if (oldVnode === newVnode) return
  // 2.判断newVnode上有没有text
  // 这里为啥不考虑 oldVnode呢,因为 newVnode有text说明就没children
  if (newVnode.text && !newVnode.children) {
    // 判断是text否相同
    if (oldVnode.text !== newVnode.text) {
      console.log('文字不相同')
      // 不相同就直接把 newVnode中text 给 elm.textContent
      oldVnode.elm.textContent = newVnode.text
    }
  } else {
    // 3.判断oldVnode有children, 这个时候newVnode 没有text但是有 children
    if (oldVnode.children) {
      updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
    } else {
      console.log('old没有children,new有children')
      // oldVnode没有 children ,newVnode 有children
      // 这个时候oldVnode 只有text 我们把 newVnode 的children拿过来
      // 先清空 oldVnode 中text
      oldVnode.elm.innerHTML = ''
      // 遍历 newVnode 中的 children
      let newChildren = newVnode.children
      for (let i = 0; i < newChildren.length; i++) {
        // 通过递归拿到了 newVnode 子节点
        let node = createElm(newChildren[i])
        // 添加到 oldVnode.elm 中
        oldVnode.elm.appendChild(node)
      }
    }
  }
}

sameVnode.js

/* sameVnode.js */

/**
 * 判断两个虚拟节点是否是同一节点
 * @param {vnode} vnode1 虚拟节点1
 * @param {vnode} vnode2 虚拟节点2
 * @returns boolean
 */
export default function sameVnode(vnode1, vnode2) {
  return (
    (vnode1.data ? vnode1.data.key : undefined) ===
      (vnode2.data ? vnode2.data.key : undefined) && vnode1.sel === vnode2.sel
  )
}

updateChildren.js

/* updateChilren.js */

// 导入 createElm patchVnode sameVnode
import createElm from './createElm'
import patchVnode from './patchVnode'
import sameVnode from './sameVnode'

// 导出 updateChildren
/**
 *
 * @param {dom} parentElm 父节点
 * @param {array} oldCh 旧子节点
 * @param {array} newCh 新子节点
 */
export default function updateChildren(parentElm, oldCh, newCh) {
  // 下面先来定义一下之前讲过的 diff 的几个指针 和 指针指向的 节点
  // 旧前 和 新前
  let oldStartIdx = 0,
    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 (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
    console.log('---进入diff---')

    // 下面按照 diff 的4种策略来写 这里面还得调用 pathVnode
    // patchVnode 和 updateChildren 是互相调用的关系,不过这可不是死循环
    // 指针走完后就不调用了

    // 这一段都是为了忽视我们加过 undefined 节点,这些节点实际上已经移动了
    if (oldCh[oldStartIdx] == undefined) {
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (oldCh[oldEndIdx] == undefined) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (newCh[newStartIdx] == undefined) {
      newStartVnode = newCh[++newStartIdx]
    } else if (newCh[newEndIdx] == undefined) {
      newEndVnode = newCh[--newEndIdx]
    }
    // 忽视了所有的 undefined 我们这里来 判断四种diff优化策略
    // 1.新前 和 旧前
    else if (sameVnode(oldStartVnode, newStartVnode)) {
      console.log('1命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldStartVnode, newStartVnode)
      // 指针移动
      newStartVnode = newCh[++newStartIdx]
      oldStartVnode = oldCh[++oldStartIdx]
    } // 2.新后 和 旧后
    else if (sameVnode(oldEndVnode, newEndVnode)) {
      console.log('2命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldEndVnode, newEndVnode)
      // 指针移动
      newEndVnode = newCh[--newEndIdx]
      oldEndVnode = oldCh[--oldEndIdx]
    } // 3.新后 和 旧前
    else if (sameVnode(oldStartVnode, newEndVnode)) {
      console.log('3命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldStartVnode, newEndVnode)
      // 策略3是需要移动节点的 把旧前节点 移动到 旧后 之后
      // insertBefore 如果参照节点为空,就插入到最后 和 appendChild一样
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
      // 指针移动
      newEndVnode = newCh[--newEndIdx]
      oldStartVnode = oldCh[++oldStartIdx]
    }
    // 4.新前 和 旧后
    else if (sameVnode(oldEndVnode, newStartVnode)) {
      console.log('4命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldEndVnode, newStartVnode)
      // 策略4是也需要移动节点的 把旧后节点 移动到 旧前 之前
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
      // 指针移动
      newStartVnode = newCh[++newStartIdx]
      oldEndVnode = oldCh[--oldEndIdx]
    } else {
      console.log('diff四种优化策略都没命中')
      // 当四种策略都没有命中
      // keyMap 为缓存,这样就不用每次都遍历老对象
      if (!keyMap) {
        // 初始化 keyMap
        keyMap = {}
        // 从oldStartIdx到oldEndIdx进行遍历
        for (let i = oldStartIdx; i < oldEndIdx; i++) {
          // 拿个每个子对象 的 key
          const key = oldCh[i].data.key
          // 如果 key 不为 undefined 添加到缓存中
          if (!key) keyMap[key] = i
        }
      }

      // 判断当前项是否存在 keyMap 中 ,当前项时 新前(newStartVnode)
      let idInOld = keyMap[newStartIdx.data]
        ? keyMap[newStartIdx.data.key]
        : undefined

      // 存在的话就是移动操作
      if (idInOld) {
        console.log('移动节点')
        // 从 老子节点 取出要移动的项
        let moveElm = oldCh[idInOld]
        // 调用 patchVnode 进行对比 修改
        patchVnode(moveElm, newStartVnode)
        // 将这一项设置为 undefined
        oldCh[idInOld] = undefined
        // 移动 节点 ,对于存在的节点使用 insertBefore移动
        // 移动的 旧前 之前 ,因为 旧前 与 旧后 之间的要被删除
        parentElm.insertBefore(moveElm.elm, oldStartVnode.elm)
      } else {
        console.log('添加新节点')
        // 不存在就是要新增的项
        // 添加的节点还是虚拟节点要通过 createElm 进行创建 DOM
        // 同样添加到 旧前 之前
        parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.elm)
      }

      // 处理完上面的添加和移动 我们要 新前 指针继续向下走
      newStartVnode = newCh[++newStartIdx]
    }
  }
  // 我们添加和删除操作还没做呢
  // 首先来完成添加操作 新前 和 新后 中间是否还存在节点
  if (newStartIdx <= newEndIdx) {
    console.log('进入添加剩余节点')
    // 这是一个标识
    // let beforeFlag = oldCh[oldEndIdx + 1] ? oldCh[oldEndIdx + 1].elm : null
    let beforeFlag = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1] : null
    // new 里面还有剩余节点 遍历添加
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      // newCh里面的子节点还需要 从虚拟DOM 转为 DOM
      parentElm.insertBefore(createElm(newCh[i]), beforeFlag)
    }
  } else if (oldStartIdx <= oldEndIdx) {
    console.log('进入删除多余节点')
    // old 里面还有剩余 节点 ,旧前 和 旧后 之间的节点需要删除
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      // 删除 剩余节点之前 先判断下是否存在
      if (oldCh[i].elm) parentElm.removeChild(oldCh[i].elm)
    }
  }
}