前言
在面试的时候经常被问到关于vue2源码的知识点,其中diff算法频率相对较高,本文较长,请慢慢看
diff算法
diff的原理就是当前的真实的dom生成一颗virtual DOM也就是虚拟DOM,当虚拟DOM的某个节点的数据发生改变会生成一个新的Vnode, 通过newVnode和oldVnode对比,发现有不同,直接修改在真实DOM上
snabbdom
一个虚拟的DOM库专注于简化,模块化拥有强大的功能和性能
上手
import {
init,
classModule, // 类模块
propsModule, // 属性模块
styleModule, // 样式模块
eventListenersModule, // 事件模块
h, // 虚拟节点函数
toVNode,
} from "snabbdom";
// 创建patch函数,初始化模块
let patch = init([classModule, propsModule, styleModule, eventListenersModule])
let vnode1 = h('a', {
props: {
href: 'http://www.baidu.com',
target: '_banlk'
}
}, '百度');
/*
<div id="box">
<h3>小米的爱好</h3>
<ul>
<li>篮球</li>
<li>足球</li>
<li>乒乓球</li>
</ul>
</div>
*/
let vnode2 = h(
'div', {
props: {
id: "box",
}
},
[
h("h3",{}, "小米的爱好"),
h('ul',{},[
h("li",{},'篮球'),
h("li",{},'足球'),
h("li",{},'乒乓球'),
])
]
)
console.log(vnode2)
let container = document.getElementById("container");
// 让虚拟节点上树
patch(container, vnode2)
真实dom - 虚拟dom
// 真实dom
<div class="holidays">
<h3>今天是周末</h3>
<ul>
<li>吃饭</li>
<li>睡觉</li>
<li>看电影</li>
</ul>
</div>
// 虚拟dom
{
"sel": "div",
"data": {
"class": { "holidays": true },
},
"children": [
{
"sel": "h3",
"data": {},
"text": "今天是周末",
},
{
"sel": "ul",
"data": {},
"children": [
{ "sel": "li", "data": {}, "text": "吃饭" },
{ "sel": "li", "data": {}, "text": "睡觉" },
{ "sel": "li", "data": {}, "text": "看电影" },
],
},
],
};
新虚拟的dom和老虚拟的dom进行diff,算出应该如何最小量更新,最后反映到真正的dom上
h函数
h函数用来产生虚拟节点(vnode)
// 调用h函数
h('a',{props:{href:'http://www.baidu.com'}},'百度');
// 得到的虚拟节点
{"sel":"a","data":{props:{href:'http://www.baidu.com'}},"text":"百度"}
// 真实的dom节点
<a href="http://www.baidu.com">百度</a>
虚拟节点的属性
{
children: undefined, // 是否有子元素
data: { // 属性,如class href id 等
props:{
...
}
},
elm: undefined, // elm为虚拟节点所对应的真实节点,若为undefined则是没有上DOM树
key: undefined, // 节点唯一标识
sel: "a", // 选择器 ,标签
text: "百度", // 文字
}
let vnode1 = h('a', {
props: {
href: 'http://www.baidu.com'
}
}, '百度');
console.log(vnode1)
手写h函数
vnode.js
// 参数以对象返回
export default function (sel, data, children, text, elm) {
let key = data.key;
return {
sel, // 选择器 ,标签
data, // 属性
children, // 子节点
text, // 文字
elm, // 节点 父节点,最外层节点
key,
}
}
h.js h函数
import vnode from './vnode.js'
/*
h('div','文字');
h('div',{},[]);
h('div',[]);
h('div',{},h())
h('div');
h('div',{},'文字');
h('div',h())
*/
// 低配版h函数,这个函数必须接受3个参数 ,重载功能较弱
// 也就是说,调用的时候必须是下面的三种之一:
// 1, h('div', {}, '文字')
// 2, h('div', {}, [])
// 3, h('div', {}, h())
export default function (sel, data, c) {
// 检查参数的个数
if (arguments.length != 3)
throw new Error('h函数必须传入3个参数');
// 检查参数c的类型
if (typeof c == 'string' || typeof c == 'number') {
// 第一种情况 h('div', {}, '文字')
return vnode(sel, data, undefined, c, undefined);
} else if (Array.isArray(c)) {
// 第二种情况 h('div', {}, [])
let 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],h函数会调用,返回对象
children.push(c[i]);
}
return vnode(sel, data, children, undefined, undefined); // 返回虚拟节点,它有children属性
} else if (typeof c == 'object' && c.hasOwnProperty('sel')) {
// 第三种情况 h('div', {}, h())
let children = [c]; // 转成数组传入
return vnode(sel, data, children, undefined, undefined);
} else {
throw new Error('传入的第三个参数类型不对');
}
};
测试
import h from './snabbdom-handle/h.js'
let vnode1 = h(
'div', {},
[
h('div', {}, [
h('span', {}, '111'),
h('span', {}, '444')
]),
h('span', {}, '2'),
h('span', {}, h('span', {}, '555')),
]
)
console.log(vnode1)
diff处理新节点
创建新节点,删除旧节点
patch.js
import vnode from './vnode.js';
import createElement from './createElement.js';
import patchVnode from './patchVnode.js'
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 {
console.log('不是同一个节点,暴力插入新的,删除旧的');
let newVnodeElm = createElement(newVnode);
// 插入到老节点之前
if (oldVnode.elm.parentNode && newVnodeElm) {
// oldVnode.elm 旧节点 如<ui><li></li></ul>
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm);
}
// 删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm);
}
};
createElement.js
// 创建节点
export default function createElement(vnode) {
// 创建节点
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++) {
let ch = vnode.children[i];
// 创建子节点的dom 添加到domNode,只有最外层domNode才添加上树
let newChildrem = createElement(ch);
// 添加到domNode
domNode.appendChild(newChildrem)
}
}
// 补充elm属性,并返回这个值,被引用的时候可以根据 vnode.elm的父节点姐插入
vnode.elm = domNode;
return vnode.elm;
}
测试创建新节点
import h from './snabbdom-handle/h.js'
import patch from './snabbdom-handle/patch.js'
let vnode1 = h(
'ul',{},[
h('li',{},'A'),
h('li',{},'B'),
h('li',{},'C'),
h('li',{},'D'),
]
)
let vnode2 = h(
'ul',{},'1111111'
)
let container = document.getElementById("container");
patch(container,vnode1)
从图上我们可以看到,当id为container的div和ul相比较时,不是同一个节点比较,会创建新节点,删除老节点
diff处理新旧节点text不同情况
思路
1 判断oldVnode和newVnode是不是同一个节点 --- 是同一个节点
2 判断newVnode和oldVnode是不是同一个对象 2.1 是同一对象 不需要做什么 2.2 不是同一对象
3 判断新节点有没有text属性
3.1 新节点有text属性
3.11 新节点的text替换老节点的text,如果老节点有children也会替换
3.2 新节点没有text属性 有children属性
3.2.1 判断老的节点是否有children,即新老节点都有children,复杂情况 后面没继续讨论
3.2.2 老节点没有children,新节点有children
删除旧节点 新节点添加上树
patch.js
// 判断oldVnode和newVnode是不是同一个节点
if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key) {
// 是同一个节点
// 判断newVnode和oldVnode是不是同一个对象
if(newVnode === oldVnode) return; // 不需要做什么
// 判断新节点有没有text属性
if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
// 新节点有text属性
// 判断新旧节点是否相等
if(newVnode.text != oldVnode.text) {
oldVnode.elm.innerText = newVnode.text; // 新节点的text替换老节点的text,如果老节点有children也会替换
}
} else {
// 新节点没有text属性,有children属性
// 判断老的节点是否有children,即新老节点都有children,复杂情况
if(oldVnode.children != undefined && oldVnode.children.length > 0) {
} else {
// 删除旧节点
oldVnode.elm.innerHTML = '';
// 老节点没有children,新节点有children
for (let i = 0; i < newVnode.children.length; i++) {
let dom = createElement(newVnode.children[i]);
oldVnode.elm.appendChild(dom) // 添加上树
}
}
}
}
测试
import h from './snabbdom-handle/h.js'
import patch from './snabbdom-handle/patch.js'
let vnode1 = h('section',{} ,"我是文本")
let container = document.getElementById("container");
patch(container,vnode1)
// 新节点 ,有children
let vnode2 = h('section',{}, [
h('p',{},'x'),
h('p',{},'y'),
h('p',{},'z')
])
let btn = document.getElementById('btn');
btn.onclick = function() {
patch(vnode1,vnode2)
}
点击diff按钮后,如下面所示
diff算法的子节点的更新策略
复杂情况 newVnode和oldVnode都有children
- 1 新前与旧前
- 2 新后与旧后
- 3 新后与旧前(此策略发生,新前指向的节点,移动到旧后的后面)
- 4 新前与旧后(此策略发生,新前指向的节点,移动到旧前的前面)
依次查找,先策略1去查找,策略1找不到是采用策略2,策略2找不到是采用策略3,策略3找不到是采用策略4,如果策略没有找到,则需要用循坏来寻找
图解几种情况
优化patch.js的代码
import vnode from './vnode.js';
import createElement from './createElement.js';
import patchVnode from './patchVnode.js'
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 {
console.log('不是同一个节点,暴力插入新的,删除旧的');
let newVnodeElm = createElement(newVnode);
// 插入到老节点之前
if (oldVnode.elm.parentNode && newVnodeElm) {
// oldVnode.elm 旧节点 如<ui><li></li></ul>
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm);
}
// 删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm);
}
};
patchVnode.js
import createElement from "./createElement";
import updateChildren from './updateChildren.js';
// 对比同一个虚拟节点
export default function patchVnode(oldVnode, newVnode) {
// 判断新旧vnode是否是同一个对象
if (oldVnode === newVnode) return;
// 判断新vnode有没有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,最复杂的情况。
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
} else {
// 老节点没有children,新节点有children
oldVnode.elm.innerHTML = ''; // 清空老的节点的内容
// 遍历新的vnode的子节点,
for (let i = 0; i < newVnode.children.length; i++) {
let dom = createElement(newVnode.children[i]);
oldVnode.elm.appendChild(dom); // 创建DOM,上树
}
}
}
}
updateChildren.js -核心代码
import patchVnode from './patchVnode.js';
import createElement from './createElement.js';
// 判断是否是同一个虚拟节点
function checkSameVnode(a, b) {
return a.sel == b.sel && a.key == b.key;
};
export default function updateChildren(parentElm, oldCh, newCh) {
console.log('我是updateChildren');
console.log(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了
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 首先不是判断4策略命中,而是要略过已经加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];
}
// 新前和旧前
else if (checkSameVnode(oldStartVnode, newStartVnode)) {
console.log('----------1新前和旧前------------');
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
}
// 新后和旧后
else if (checkSameVnode(oldEndVnode, newEndVnode)) {
console.log('----------2新后和旧后------------');
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
}
// 新后和旧前
else if (checkSameVnode(oldStartVnode, newEndVnode)) {
console.log('----------3新后和旧前------------');
patchVnode(oldStartVnode, newEndVnode);
// 当3新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面
// 如何移动节点? 只要你插入一个已经在DOM树上的节点,它就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
}
// 新前和旧后
else if (checkSameVnode(oldEndVnode, newStartVnode)) {
console.log('----------4新前和旧后------------');
patchVnode(oldEndVnode, newStartVnode);
// 当4新前和旧后命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧前的前面
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 四种策略都没有匹配到
// keyMap一个映射对象
if (!keyMap) {
keyMap = {};
// 从oldStartIdx开始,到oldEndIdx结束,创建keyMap映射对象
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key;
if (key != undefined) {
keyMap[key] = i;
}
}
}
// 寻找当前这项(newStartIdx)这项在keyMap中的映射的位置序号
const idxInOld = keyMap[newStartVnode.key];
if (idxInOld == undefined) {
// 判断,如果idxInOld是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];
}
}
//查看剩余的。循环结束了start还是比old小
if (newStartIdx <= newEndIdx) {
// '新节点还有剩余节点没有处理,要加项。要把所有剩余的节点,都要插入到oldStartIdx之前'
// 遍历新的newCh,添加到老的没有处理的之前
for (let i = newStartIdx; i <= newEndIdx; i++) {
// insertBefore方法可以自动识别null,如果是null就会自动排到队尾去。和appendChild是一致了。
// newCh[i]现在还没有真正的DOM,所以要调用createElement()函数变为DOM
parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
}
} else if (oldStartIdx <= oldEndIdx) {
// 老节点还有剩余节点没有处理,要删除项;
// 批量删除oldStart和oldEnd指针之间的项
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm);
}
}
}
};
部分测试
新前与旧前测试代码
import h from './snabbdom-handle/h.js'
import patch from './snabbdom-handle/patch.js'
let 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)
// 新节点 ,有children
let vnode2 = h('ul',{}, [
h('li',{key: 'A'},'AAA'),
h('li',{key: 'B'},'B'),
h('li',{key: 'C'},'C'),
h('li',{key: 'D'},'D'),
])
let btn = document.getElementById('btn');
btn.onclick = function() {
patch(vnode1,vnode2)
}
新后与旧后 + 循环对比删除,新增测试代码
import h from './snabbdom-handle/h.js'
import patch from './snabbdom-handle/patch.js'
let vnode1 = h('ul',{}, [
h('li',{ key: 'A'},'A1'),
h('li',{ key: 'B'},'B2'),
h('li',{ key: 'C'},'C3'),
h('li',{ key: 'D'},'D4')
])
patch(container,vnode1)
// 新节点 ,有children
let vnode2 = h('ul',{}, [
h('li',{ key: 'Q'},'Q'),
h('li',{ key: 'M'},'M'),
h('li',{ key: 'N'},'N'),
h('li',{ key: 'D'},'D4')
])
let btn = document.getElementById('btn');
btn.onclick = function() {
patch(vnode1,vnode2)
}
先新前与新后查找,Q与A节点不同,此时新后与旧后查找,此时指针往上移,继续查找,找不到,新后与旧前查找,找不到,继续新前与旧后查找,发现四种策略都找不到,此时开始循环查找;新节点循环有剩余节点Q,M,N,需要新增,旧节点循环有剩余节点A1,B2,C3,说明要删除
新后与旧前测试代码
import h from './snabbdom-handle/h.js'
import patch from './snabbdom-handle/patch.js'
let vnode1 = h('ul',{}, [
h('li',{ key: 'A'},'A1'),
h('li',{ key: 'B'},'B2'),
h('li',{ key: 'C'},'C3'),
h('li',{ key: 'D'},'D4'),
h('li',{ key: 'E'},'E5'),
])
patch(container,vnode1)
// 新节点 ,有children
let vnode2 = h('ul',{}, [
h('li',{ key: 'E'},'E50'),
h('li',{ key: 'D'},'D40'),
h('li',{ key: 'C'},'C30'),
h('li',{ key: 'B'},'B20'),
h('li',{ key: 'A'},'A10'),
])
let btn = document.getElementById('btn');
btn.onclick = function() {
patch(vnode1,vnode2)
}
1 新前和新后比较 E和A不相同 则
2 新后和旧后开始比较 A和E不相同 ,继续
3 新后与旧前比较 找到A 移动新前指向的节点(A)到老节点的旧后的后面
此时 新后的指针往上移 旧前的指针往下移 新一轮比较
新前和旧前比较 E和B不相同
新后与旧后比较 B和E不相同
新后与旧前比较 B找到
移动新前指向的节点(B)到老节点的旧后的后面 即A的前面
如此反复
总结
diff算法是虚拟DOM的核心一部分,同层比较,通过新老节点的对比,将改动的地方更新到真实DOM上。
具体实现的方法是patch, patchVnode以及updateChildren
1 patch函数被调用
2 判断oldVnode是虚拟节点还是dom节点?如果是dom节点转化为虚拟节点
3 判断oldVnode和newVnode是不是sel和key都相同? 如果不是,删除旧的节点,插入新的节点
4 oldVnode和newVnode是内存中的同一个对象?
-
4.1 如果是,什么都不需要处理
-
4.2 如果不是
4.2.1 newVnode有text属性,newVnode的text和oldVnode的text是不是相同 如果相同,什么都不需要处理,如果不相同,把oldVnode中的elm的innerText改为newVnode的text
4.2.2 newVnode有children属性,oldVnode有text属性,清空oldVnode的text,将newVnode的children添加到dom中;newVnode和oldVnode都有children,此情况最复杂
5 diff算法的子节点的更新策略
- 新前与旧前
- 新后与旧后
- 新后与旧前(此策略发生,新前指向的节点,移动到旧后的后面)
- 新前与旧后(此策略发生,新前指向的节点,移动到旧前的前面)
查找过程 新前与旧前比较,如果找到,新前指针和旧前指针下移(++),如果找不到,
新后与旧后开始比较,如果找到,新后指针和旧后指针上移(--);如果找不到,
新后与旧前开始比较,如果找到,新后指针上移(--),旧前指针下移(++);且新后对应的节点移到旧后的后面,如果找不到,
新前与旧后开始对比,如果找到,新前指针下移(++),旧后指针上移(--),新前指向的节点移到旧前的前面,如果找不到,
判断是否oldCh中有和newStartVnode的具有相同的key的Vnode,如果没有找到,说明是新的节点,创建一个新的节点,插入即可
如果找到了和newStartVnode具有相同的key的Vnode,命名为elmToMove,下标idxInOld,如果idxInOld是undefined 说明是新的节点,此时创建新的虚拟节点,并插入到oldStartVnode.elm前面,如果idxInOld不是undefined,那就两者再去patchVnode,把这项设置为undefined,表示我已经处理完这项了 然后插到oldStartVnode.elm前面
在经过了While循环之后,如果发现新节点数组或者旧节点数组里面还有剩余的节点,根据具体情况来进行删除或者新增的操作