简单介绍一下虚拟DOM和diff算法

需求

Vue源码:虚拟DOM和diff算法_子节点

方法一:拆了重建

Vue源码:虚拟DOM和diff算法_i++_02

方法二:diff

Vue源码:虚拟DOM和diff算法_递归_03

Vue源码:虚拟DOM和diff算法_i++_04

主要内容

Vue源码:虚拟DOM和diff算法_子节点_05

snabbdom简介和测试环境搭建

snabbdom简介


  • snabbdom是瑞典语单词,单词原意“速度”;
    Vue源码:虚拟DOM和diff算法_i++_06
  • snabbdom 是著名的虚拟DOM 库,是diff 算法的鼻祖,Vue 源码借鉴了snabbdom ;
  • 官方git :snabbdom

安装snabbdom


  • 在git 上的snabbdom 源码是用TypeScript写的,git 上并不提供编译好的JavaScript 版本;
  • 如果要直接使用build出来的JavaScript 版的snabbdom库,可以从npm上下载:
    ​npm i -S snabbdom ​
  • 学习库底层时,建议大家阅读原汁原味的TS代码,最好带有库作者原注释,这样对你的源码阅读能力会有很大的提升。

snabbdom测试环境搭建


  • snabbdom 库是DOM库,不能在nodejs环境运行,所以需要搭建webpack和webpack-dev-server开发环境,不需要安装任何loader
  • 这里需要注意,必须安装最新版webpack@5,不能安装webpack@4,因为webpack4中没有读取exports的能力
    ​npm i -S webpack@5 webpack-cli@3 webpack-dev-server@3 ​
  • 参考webpack官网,书写好webpack.config.js文件

跑通官方git首页的demo程序


  • 跑通s​​nabbdom官方git首页​​的demo程序,即证明调试环境已经搭建成功
  • 不要忘记在index.html中放置一个div#container

虚拟DOM和h函数

Vue源码:虚拟DOM和diff算法_递归_07

虚拟DOM类似于​​mustache​​​中的​​token​

diff是发生在虚拟DOM上的

Vue源码:虚拟DOM和diff算法_递归_08

课程不研究DOM如何变成虚拟DOM

Vue源码:虚拟DOM和diff算法_i++_09

研究内容


  • 虚拟函数如何被渲染函数(h函数)产生?
    手写h函数
  • diff算法原理?
    手写diff算法
  • 虚拟DOM如何通过diff变为真正的DOM的
    事实上,虚拟DOM变回真正的DOM是涵盖在diff算法里面的

虚拟函数如何被渲染函数(h函数)产生?

h函数用来产生虚拟节点

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

比如这样会调用h函数

Vue源码:虚拟DOM和diff算法_递归_10

将得到这样的虚拟节点

Vue源码:虚拟DOM和diff算法_递归_11

{
"sel": "a",
"data": {
props: {
href: "http://www.atguigu.com"
}
},
"text": "尚硅谷"
}

它表示的真正DOM节点

Vue源码:虚拟DOM和diff算法_子节点_12

一个虚拟节点都有哪些属性

{
children: undefined, // 子元素
data: {}, // 属性、样式
elm: undefined, // 对应真正DOM节点,如果为undefined,表示该节点还未上树
key: undefined, // 节点唯一标识
sel: "div", // 选择器
text: "我是一个盒子" // 文字
}

h函数可以嵌套使用,从而得到虚拟DOM树(????)

比如这样嵌套使用h函数

Vue源码:虚拟DOM和diff算法_子节点_13

将得到这样的虚拟DOM树

Vue源码:虚拟DOM和diff算法_递归_14

h函数用法很活

例如

Vue源码:虚拟DOM和diff算法_递归_15

手写h函数

vnode

vnode.js

/**
* vnode函数的功能非常简单,就是把传入的5个参数组合对象返回
*/
export default function (sel, data, children, text, elm) {
return {
sel, data, children, text, elm
};
}

h函数

h.js

import vnode from "./vnode";

/*
* 编写一个低配版本的h函数,这个函数必须要接收3个参数,缺一不可 —— 重载功能较弱
* 也就是说,调用的时候形态必须是下面三种之一:
* 形态① h('div', {}, '文字')
* 形态② h('div', {}, [])
* 形态③ h('div', {}, h())
* */
export default function (sel, data, c) {
// 检查参数的个数
if (arguments.length !== 3)
throw new Error('对不起,h函数必须传入3个参数,我们是低配版h函数');
// 检查参数c的类型
if (typeof c === 'string' || typeof c === 'number') {
return vnode(sel, data, undefined, c, undefined);
} else if (Array.isArray(c)) {
// 说明现在调用h函数是形态②
let children = [];
// 遍历c,手机children
for (let i = 0; i < c.length; i++) {
// 检查c[i]必须是一个对象
if (!(typeof c[i] === 'object' && c[i].hasOwnProperty('sel')))
throw new Error('传入的数组参数中有项不是h函数');
// 这里不用执行c[i],因为测试语句中已经执行了
// 只需要收集好children
children.push(c[i]);
}
// 循环结束了,说明children收集完毕了,此时可以返回虚拟节点,有children节点
return vnode(sel, data, children, undefined, undefined);
} else if (typeof c === 'object' && c.hasOwnProperty('sel')) {
// 说明现在调用h函数是形态③
// 即传入的c是唯一的children. 不用执行c,因为测试语句中已经执行了c
return vnode(sel, data, [c], undefined, undefined);
} else {
throw new Error('传入的参数类型有误');
}
};

看TS代码,写JS代码


  • 看源码的TS版代码,然后仿写JS代码
  • 只要主干功能,放弃实现一些细节

感受diff算法

通过更改li标签内容得知,diff算法为最小量更新

通过key可以唯一标识节点,服务于最小量更新

import {init} from 'snabbdom/init';
import {classModule} from "snabbdom/modules/class";
import {propsModule} from "snabbdom/modules/props";
import {styleModule} from "snabbdom/modules/style";
import {eventListenersModule} from "snabbdom/modules/eventlisteners";
import {h} from 'snabbdom/h';

// 创建出patch函数
const patch = init([classModule, propsModule, styleModule, eventListenersModule]);

// 得到盒子和按钮
const container = document.getElementById('container');
const btn = document.getElementById('btn');

// 创建虚拟节点
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)

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

// 点击按钮时,将vnode1变为vnode2
btn.onclick = () => {
patch(vnode1, vnode2)
}

心得


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

diff 并不是那么的“无微不至”啊!真的影响效率么??

答:上面2、3操作在实际Vue 开发中,基本不会遇见,所以这是合理的优化机制。

同层比较示意图

Vue源码:虚拟DOM和diff算法_子节点_16

// 创建虚拟节点
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)

const vnode2 = h('ul', {}, h('section', {}, [
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'),
]))

如上述代码中操作,增加一层节点,将不再进行最小量更新,而是重新构造。

diff算法处理新旧节点不是同一个节点时

Vue源码:虚拟DOM和diff算法_子节点_17

如何定义"同一个节点"

Vue源码:虚拟DOM和diff算法_递归_18

旧节点的key要和新节点的key相同

旧节点的选择器要和新节点的选择器相同

创建节点时,所有子节点都需要递归创建

Vue源码:虚拟DOM和diff算法_递归_19

手写第一次上树时

patch.js

import vnode from "./vnode";
import createElement from "./createElement";

export default function (oldVnode, newVnode) {
// 判断传入的第一个参数,是DOM节点还是虚拟节点
if (oldVnode.sel === '' || oldVnode.sel === undefined) {
// 传入的第一个参数是DOM节点,此时要包装为虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode);
}
// 判断oldVnode和newVnode是不是同一个节点
if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
// 是同一个节点
// TODO:精细化比较
} else {
// 不是同一个节点
createElement(newVnode, oldVnode.elm);
}
};

createElement.js

// 真正创建节点。将vnode创建为DOM,插入到pivot元素之前
export default function (vnode, pivot) {
// 目的是把虚拟节点vnode插入到标杆pivot之前
// 创建一个DOM节点,这个节点目前还是孤儿节点
let domNode = document.createElement(vnode.sel);
// 有子节点还是文本
if (vnode.text !== "" && (vnode.children === undefined || vnode.children.length === 0)) {
// 内部是文字
domNode.innerText = vnode.text;
// 将孤儿节点上树。让标杆节点的父元素调用insertBefore方法,将新的孤儿节点插入到标签节点之前
pivot.parentNode.insertBefore(domNode, pivot);
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {

}
};

手写递归创建子节点

为了适应递归操作,将插入操作放入到patch.js中,而不是在createElement中进行

这里处理的是diff处理新旧节点不是同一个节点的情况,创建新的插入并暴力删除

index.js

import h from './mySnabbdom/h';
import patch from './mySnabbdom/patch'

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

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

const myVnode2 = h('ul', {}, [
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, [
h('div', {}, [
h('ol', {}, [
h('li', {}, '哈哈哈'),
h('li', {}, '嘿嘿嘿'),
h('li', {}, '呵呵呵'),
])
])
]),
h('li', {}, 'D'),
])

const myVnode3 = h('section', {}, [
h('h1', {}, '我是新的h1'),
h('h2', {}, '我是新的h2'),
h('h3', {}, '我是新的h3'),
])

patch(container, myVnode2);

btn.onclick = function () {
patch(myVnode2, myVnode3);
}

createElement.js

// 真正创建节点。将vnode创建为DOM,是孤儿节点,不进行插入
export default function createElement(vnode) {
// console.log(`目的是把虚拟节点${vnode}变成真正的DOM`)
// 创建一个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) {
// 它内不是子节点,就要递归创建节点
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,elm是一个纯DOM对象
return vnode.elm;
};

patch.js

import vnode from "./vnode";
import createElement from "./createElement";

export default function (oldVnode, newVnode) {
// 判断传入的第一个参数,是DOM节点还是虚拟节点
if (oldVnode.sel === '' || oldVnode.sel === undefined) {
// 传入的第一个参数是DOM节点,此时要包装为虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode);
}
// 判断oldVnode和newVnode是不是同一个节点
if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
// 是同一个节点
// TODO:精细化比较
} else {
// 不是同一个节点
let newVnodeElm = createElement(newVnode);
// 插入到老节点之前
if (oldVnode.elm.parentNode && newVnodeElm)
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm);
// 删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm);
}
};

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

Vue源码:虚拟DOM和diff算法_递归_20

手写新旧节点text的不同情况

patch.js

import vnode from "./vnode";
import createElement from "./createElement";

export default function (oldVnode, newVnode) {
// 判断传入的第一个参数,是DOM节点还是虚拟节点
if (oldVnode.sel === '' || oldVnode.sel === undefined) {
// 传入的第一个参数是DOM节点,此时要包装为虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode);
}
// 判断oldVnode和newVnode是不是同一个节点
if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
console.log('是同一个节点')
// 判断新旧node是否是同一个对象
if (oldVnode === newVnode) return;
// 判断newVnode有没有text属性
if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
// 新vnode有text属性
// console.log('新vnode有text属性')
if (newVnode.text !== oldVnode.text)
// 如果新的虚拟节点中的text和老的虚拟节点的text不同,那么直接让新的text写入老的elm中即可,如果老的elm中是children,那么也会立即消失掉
oldVnode.elm.innerText = newVnode.text;
} else {
// 新vnode没有text属性,有children
// console.log('新vnode没有text属性')
// 判断老的有没有children
if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
// 老的有children,此时就是最复杂的情况。就是新老都有children
} else {
// 老的没有children,新的有children
// 清空老的节点的内容
oldVnode.elm.innerHTML = '';
// 遍历newVnode的子节点,创建dom,循环上树
newVnode.children.forEach(node => {
let dom = createElement(node);
oldVnode.elm.appendChild(dom);
})
}
}
} else {
// 不是同一个节点
let newVnodeElm = createElement(newVnode);
// 插入到老节点之前
if (oldVnode.elm.parentNode && newVnodeElm)
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm);
// 删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm);
}
};

vnode中添加key

如果data中存在key,将key也绑定在vnode上

vnode.js

/**
* vnode函数的功能非常简单,就是把传入的5个参数组合对象返回
*/
export default function (sel, data, children, text, elm) {
const key = data.key;
return {
sel, data, children, text, elm, key
};
}

尝试书写diff更新子节点

patchVnode.js

import createElement from "./createElement";

export default function patchVnode(oldVnode, newVnode) {
// 判断新旧node是否是同一个对象
if (oldVnode === newVnode) return;
// 判断newVnode有没有text属性
if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
// 新vnode有text属性
// console.log('新vnode有text属性')
if (newVnode.text !== oldVnode.text)
// 如果新的虚拟节点中的text和老的虚拟节点的text不同,那么直接让新的text写入老的elm中即可,如果老的elm中是children,那么也会立即消失掉
oldVnode.elm.innerText = newVnode.text;
} else {
// 新vnode没有text属性,有children
// console.log('新vnode没有text属性')
// 判断老的有没有children
if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
// 老的有children,此时就是最复杂的情况。就是新老都有children
// 所有未处理的节点的开头
let un = 0;
for (let i = 0; i < newVnode.children.length; i++) {
let ch = newVnode.children[i];
// 再次遍历,看看oldVnode中有没有节点和它是same的
let isExist = false;
for (let j = 0; j < oldVnode.children.length; j++) {
if (oldVnode.children[j].sel === ch.sel && oldVnode.children[j].key === ch.key) {
isExist = true;
}
}
if (!isExist) {
let dom = createElement(ch);
ch.elm = dom;
if (un < oldVnode.children.length)
oldVnode.elm.insertBefore(dom, oldVnode.children[un].elm);
else
oldVnode.elm.appendChild(dom);
} else {
// 让处理的节点下移一位
un++;
// 判断前后节点位置是否一致
}
}
} else {
// 老的没有children,新的有children
// 清空老的节点的内容
oldVnode.elm.innerHTML = '';
// 遍历newVnode的子节点,创建dom,循环上树
newVnode.children.forEach(node => {
let dom = createElement(node);
oldVnode.elm.appendChild(dom);
})
}
}
}

如下面三种情况,所有情况非常复杂,杂糅在一起,因此需要一个优秀的更新算法

新增的情况

新创建的节点要插入到所有未处理节点之前,而不是所有已处理节点之后。

例如途中,如果插入MN,所有已处理节点为AB,则MN会依次插入到B之后,与目的不一致。

Vue源码:虚拟DOM和diff算法_递归_21

删除的情况

Vue源码:虚拟DOM和diff算法_子节点_22

更新的情况

Vue源码:虚拟DOM和diff算法_子节点_23

diff算法的子节点更新策略

四种命中查找:

新前:新的虚拟节点当中的所有没有处理的开头的节点

新后:新的虚拟节点当中的所有没有处理的最后的节点

旧前:旧的虚拟节点当中的所有没有处理的开头的节点

旧后:旧的虚拟节点当中的所有没有处理的最后的节点

经典的diff算法优化策略


  • 新前与旧前:如果是旧节点先循环完毕,说明新节点中有要插入的节点。
  • 新后与旧后:如果是新节点先循环完毕,如果老节点中还有剩余节点(旧前和新后指针中间的节点),说明他们是要被删除的节点。
  • 新后与旧前:(此种发生了,涉及移动节点,那么新前指向的节点,移动的旧后之后
  • 新前与旧后:(此种发生了,涉及移动节点,那么新前指向的节点,移动的旧前之前

命中一种就不再进行命中判断了

如果都没有命中,就需要用循环来寻找了。移动到oldStartIdx之前。

新增的情况

Vue源码:虚拟DOM和diff算法_递归_24

Vue源码:虚拟DOM和diff算法_i++_25

删除的情况

Vue源码:虚拟DOM和diff算法_子节点_26

多删除的情况

Vue源码:虚拟DOM和diff算法_子节点_27

复杂的情况

Vue源码:虚拟DOM和diff算法_i++_28

Vue源码:虚拟DOM和diff算法_递归_29

手写子节点更新策略

updateChildren.js

import patchVnode from "./patchVnode";
import createElement from "./createElement";

// 判断是否是同一个虚拟节点
function checkSameVnode(vnodeA, vnodeB) {
return vnodeA.sel === vnodeB.sel && vnodeA.key === vnodeB.key;
}

export default function updateChildren(parentElm, oldCh, newCh) {
// console.log('我是updateChildren')
// console.log(oldCh, newCh)
// 定义旧前、新前、旧后、新后 编号
let oldStartIdx = 0, newStartIdx = 0, oldEndIdx = oldCh.length - 1, newEndIdx = newCh.length - 1;
// 定义旧前、旧后、新前、新后 节点
let oldStartVnode = oldCh[oldStartIdx], oldEndVnode = oldCh[oldEndIdx], newStartVnode = newCh[newStartIdx],
newEndVnode = newCh[newEndIdx];
let keyMap = null;
// 开始大while
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 首先不是判断①②③④命中,而是要略过已经加undefined标记的东西
if (oldStartVnode == null || oldCh[oldStartIdx] === undefined) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (oldEndVnode == null || oldCh[oldEndIdx] === undefined) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null || newCh[newStartIdx] === undefined) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null || newCh[newEndIdx] === undefined) {
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(newStartVnode, oldStartVnode)) {
// 新前与旧前
console.log("①新前和旧前命中")
patchVnode(oldStartVnode, newStartVnode);
newStartVnode = newCh[++newStartIdx];
oldStartVnode = oldCh[++oldStartIdx];
} else if (checkSameVnode(newEndVnode, oldEndVnode)) {
// 新后与旧后
console.log("②新后与旧后命中")
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(newEndVnode, oldStartVnode)) {
// 新后与旧前
console.log("③新后与旧前命中")
patchVnode(oldStartVnode, newEndVnode);
// 当③新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面
// 如何移动节点??只要插入一个已经在DOm树上的节点,它就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
newEndVnode = newCh[--newEndIdx];
oldStartVnode = oldCh[++oldStartIdx];
} else if (checkSameVnode(newStartVnode, oldEndVnode)) {
// 新前与旧后
console.log("④新前与旧后命中")
patchVnode(oldEndVnode, newStartVnode);
// 当④新前与旧后命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧前的前面
// 如何移动节点??只要插入一个已经在DOm树上的节点,它就会被移动
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
oldEndVnode = oldCh[--oldEndIdx];
} else {
// 四种命中都没有命中
// 制作key的map映射对象,这样就不用每次都遍历老对象了。
if (!keyMap) {
keyMap = {};
// 从oldStartIdx开始,到oldEndIdx结束,创建KeyMap映射对象
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key;
if (key !== undefined)
keyMap[key] = i;
}
}
//console.log(keyMap);
// 寻找当前这项(nextStartIdx)这项在keyMap中的映射的位置序号
const idxInOld = keyMap[newStartVnode.key];
// console.log(idxInOld);
if (idxInOld === undefined) {
// 判断,如果idxInOlx是undefined表示它是全新的项,只需要插入即可
// 被加入的项(就是newStartVnode这项),目前还不是真实DOM节点
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
} else {
// 如果不是undefined,需要移动
const elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
// 把这项设置为undefined,表示已经处理完这项了
oldCh[idxInOld] = undefined;
// 移动,调用insertBefore也可以实现移动
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
// 指针下移,只移新的头
newStartVnode = newCh[++newStartIdx];
}
}
// while结束后,需要继续看看有没有剩余,判断是否需要删除或新增节点
if (newStartIdx <= newEndIdx) {
// console.log('new还有剩余节点没有处理,要加项。要把所有剩余的节点,都要插入到oldStart之前');
// 循环结束后,start还是比old小
// before是插入的标杆
// const before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
// 遍历新的newCh,添加到老的没有处理的之前
for (let i = newStartIdx; i <= newEndIdx; i++) {
// insertBefore方法可以自动识别null,如果是null就会自动排到队尾去。和appendChild是一致了。
// newCh[i]现在还没有真正的DOM,所以要调用createElement()函数变为DOM
// parentElm.insertBefore(createElement(newCh[i]), before);
parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
}
} else if (oldStartIdx <= oldEndIdx) {
// console.log('old还有剩余节点没有处理,要删项')
// 批量删除oldStart和oldEnd指针之间的项
for (let i = oldStartIdx; i <= oldEndIdx; i++)
if (oldCh[i]) parentElm.removeChild(oldCh[i].elm);
}
};

完整版手写

Vue源码:虚拟DOM和diff算法_i++_30

patch.js

import vnode from "./vnode";
import createElement from "./createElement";
import patchVnode from "./patchVnode";

export default function patch(oldVnode, newVnode) {
// 判断传入的第一个参数,是DOM节点还是虚拟节点
if (oldVnode.sel === '' || oldVnode.sel === undefined) {
// 传入的第一个参数是DOM节点,此时要包装为虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode);
}
// 判断oldVnode和newVnode是不是同一个节点
if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
// console.log('是同一个节点')
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);
}
};

vnode.js

/**
* vnode函数的功能非常简单,就是把传入的5个参数组合对象返回
*/
export default function (sel, data, children, text, elm) {
const key = data.key;
return {
sel, data, children, text, elm, key
};
}

createElement.js

// 真正创建节点。将vnode创建为DOM,是孤儿节点,不进行插入
export default function createElement(vnode) {
// console.log(`目的是把虚拟节点${vnode}变成真正的DOM`)
// 创建一个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) {
// 它内不是子节点,就要递归创建节点
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,elm是一个纯DOM对象
return vnode.elm;
};

patchVnode.js

import createElement from "./createElement";
import updateChildren from "./updateChildren";

// 对比同一个虚拟节点
export default function patchVnode(oldVnode, newVnode) {
// 判断新旧node是否是同一个对象
if (oldVnode === newVnode) return;
// 判断newVnode有没有text属性
if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
// 新vnode有text属性
// console.log('新vnode有text属性')
if (newVnode.text !== oldVnode.text)
// 如果新的虚拟节点中的text和老的虚拟节点的text不同,那么直接让新的text写入老的elm中即可,如果老的elm中是children,那么也会立即消失掉
oldVnode.elm.innerText = newVnode.text;
} else {
// 新vnode没有text属性,有children
// console.log('新vnode没有text属性')
// 判断老的有没有children
if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
// 老的有children,新的也有children,此时就是最复杂的情况。就是新老都有children
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
} else {
// 老的没有children,新的有children
// 清空老的节点的内容
oldVnode.elm.innerHTML = '';
// 遍历newVnode的子节点,创建dom,循环上树
newVnode.children.forEach(node => {
let dom = createElement(node);
oldVnode.elm.appendChild(dom);
})
}
}
}

h.js

import vnode from "./vnode";

/*
* 编写一个低配版本的h函数,这个函数必须要接收3个参数,缺一不可 —— 重载功能较弱
* 也就是说,调用的时候形态必须是下面三种之一:
* 形态① h('div', {}, '文字')
* 形态② h('div', {}, [])
* 形态③ h('div', {}, h())
* */
export default function (sel, data, c) {
// 检查参数的个数
if (arguments.length !== 3)
throw new Error('对不起,h函数必须传入3个参数,我们是低配版h函数');
// 检查参数c的类型
if (typeof c === 'string' || typeof c === 'number') {
return vnode(sel, data, undefined, c, undefined);
} else if (Array.isArray(c)) {
// 说明现在调用h函数是形态②
let children = [];
// 遍历c,手机children
for (let i = 0; i < c.length; i++) {
// 检查c[i]必须是一个对象
if (!(typeof c[i] === 'object' && c[i].hasOwnProperty('sel')))
throw new Error('传入的数组参数中有项不是h函数');
// 这里不用执行c[i],因为测试语句中已经执行了
// 只需要收集好children
children.push(c[i]);
}
// 循环结束了,说明children收集完毕了,此时可以返回虚拟节点,有children节点
return vnode(sel, data, children, undefined, undefined);
} else if (typeof c === 'object' && c.hasOwnProperty('sel')) {
// 说明现在调用h函数是形态③
// 即传入的c是唯一的children. 不用执行c,因为测试语句中已经执行了c
return vnode(sel, data, [c], undefined, undefined);
} else {
throw new Error('传入的参数类型有误');
}
};

updateChildren.js

import patchVnode from "./patchVnode";
import createElement from "./createElement";

// 判断是否是同一个虚拟节点
function checkSameVnode(vnodeA, vnodeB) {
return vnodeA.sel === vnodeB.sel && vnodeA.key === vnodeB.key;
}

export default function updateChildren(parentElm, oldCh, newCh) {
// console.log('我是updateChildren')
// console.log(oldCh, newCh)
// 定义旧前、新前、旧后、新后 编号
let oldStartIdx = 0, newStartIdx = 0, oldEndIdx = oldCh.length - 1, newEndIdx = newCh.length - 1;
// 定义旧前、旧后、新前、新后 节点
let oldStartVnode = oldCh[oldStartIdx], oldEndVnode = oldCh[oldEndIdx], newStartVnode = newCh[newStartIdx],
newEndVnode = newCh[newEndIdx];
let keyMap = null;
// 开始大while
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 首先不是判断①②③④命中,而是要略过已经加undefined标记的东西
if (oldStartVnode == null || oldCh[oldStartIdx] === undefined) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (oldEndVnode == null || oldCh[oldEndIdx] === undefined) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null || newCh[newStartIdx] === undefined) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null || newCh[newEndIdx] === undefined) {
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(newStartVnode, oldStartVnode)) {
// 新前与旧前
console.log("①新前和旧前命中")
patchVnode(oldStartVnode, newStartVnode);
newStartVnode = newCh[++newStartIdx];
oldStartVnode = oldCh[++oldStartIdx];
} else if (checkSameVnode(newEndVnode, oldEndVnode)) {
// 新后与旧后
console.log("②新后与旧后命中")
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(newEndVnode, oldStartVnode)) {
// 新后与旧前
console.log("③新后与旧前命中")
patchVnode(oldStartVnode, newEndVnode);
// 当③新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面
// 如何移动节点??只要插入一个已经在DOm树上的节点,它就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
newEndVnode = newCh[--newEndIdx];
oldStartVnode = oldCh[++oldStartIdx];
} else if (checkSameVnode(newStartVnode, oldEndVnode)) {
// 新前与旧后
console.log("④新前与旧后命中")
patchVnode(oldEndVnode, newStartVnode);
// 当④新前与旧后命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧前的前面
// 如何移动节点??只要插入一个已经在DOm树上的节点,它就会被移动
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
oldEndVnode = oldCh[--oldEndIdx];
} else {
// 四种命中都没有命中
// 制作key的map映射对象,这样就不用每次都遍历老对象了。
if (!keyMap) {
keyMap = {};
// 从oldStartIdx开始,到oldEndIdx结束,创建KeyMap映射对象
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key;
if (key !== undefined)
keyMap[key] = i;
}
}
//console.log(keyMap);
// 寻找当前这项(nextStartIdx)这项在keyMap中的映射的位置序号
const idxInOld = keyMap[newStartVnode.key];
// console.log(idxInOld);
if (idxInOld === undefined) {
// 判断,如果idxInOlx是undefined表示它是全新的项,只需要插入即可
// 被加入的项(就是newStartVnode这项),目前还不是真实DOM节点
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
} else {
// 如果不是undefined,需要移动
const elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
// 把这项设置为undefined,表示已经处理完这项了
oldCh[idxInOld] = undefined;
// 移动,调用insertBefore也可以实现移动
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
// 指针下移,只移新的头
newStartVnode = newCh[++newStartIdx];
}
}
// while结束后,需要继续看看有没有剩余,判断是否需要删除或新增节点
if (newStartIdx <= newEndIdx) {
// console.log('new还有剩余节点没有处理,要加项。要把所有剩余的节点,都要插入到oldStart之前');
// 循环结束后,start还是比old小
// before是插入的标杆
// const before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
// 遍历新的newCh,添加到老的没有处理的之前
for (let i = newStartIdx; i <= newEndIdx; i++) {
// insertBefore方法可以自动识别null,如果是null就会自动排到队尾去。和appendChild是一致了。
// newCh[i]现在还没有真正的DOM,所以要调用createElement()函数变为DOM
// parentElm.insertBefore(createElement(newCh[i]), before);
parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
}
} else if (oldStartIdx <= oldEndIdx) {
// console.log('old还有剩余节点没有处理,要删项')
// 批量删除oldStart和oldEnd指针之间的项
for (let i = oldStartIdx; i <= oldEndIdx; i++)
if (oldCh[i]) parentElm.removeChild(oldCh[i].elm);
}
};