前言
学过Vue或React的肯定都知道虚拟dom是什么,vue就是通过虚拟dom与diff算法来对真实的Dom进行最小量更新,说白了就是比对虚拟dom,一样的不更新,不一样的更新dom。diff算法就是做了这样一件事。
那么了解diff算法的,肯定知道一些关键词,类似于VirtualNode,h,patch 之类的,比如别人考察你时,你也会通过几个关键词模棱两可的过关,但是我们还是要真正的会的。将真实的dom变为虚拟节点这个过程,这里不是我们要研究的,我们只是来研究虚拟节点的diff。
废话也不多说了,下面直接开始了:
封装基本功能函数
首先虚拟dom,我们只写一个简易版本的。虚拟dom就是把真实dom转为js对象。
Vnode虚拟节点
// 创建虚拟dom Vnode
/**
*
* @param {*} sel 虚拟节点名称 tagName
* @param {*} data 虚拟节点上的属性 如:key props...
* @param {*} children 虚拟节点的子节点
* @param {*} text 虚拟节点的文本 innerText
* @param {*} elm 虚拟节点的真正dom
* @returns
*/
export default function(sel,data,children,text,elm){
const key = data.key;
return {
sel,data,children,text,key,elm
}
}
真实dom:
<div>
<p>这是一个虚拟节点</p>
</div>
对应虚拟节点:
虚拟节点是由AST(抽象语法树)得到的,后面再继续更新AST的原理,本章只管阐述虚拟DOM,以及DOM-Diff
h函数生成虚拟dom
import Vnode from "./Vnode.js";
// 将tokens转成虚拟dom
//我们来写一个简易版的diff,所以就不考虑特殊的情况,这个是功能比较弱的版本,但是可以体现diff算法的
/**
* 三个参数,第一个是tagName, 第二个是节点上的属性 如:<div key='myKey' ></div>,第三个是节点内的文本或子节点
*
* 1.h('div',{},'苹果')
* 2.h('div',{},[
* h('div',{},'西瓜')
* ])
*
*/
export default function h(sel, data, kind) {
if (arguments.length != 3) alert('参数传递错误')
if (typeof kind == 'string' || typeof kind == 'number') {
//kind是文本
return Vnode(sel, data, undefined, kind, undefined);
} else if (Array.isArray(kind)) {
//kind是子节点chuildren []
let children = [];
for (let i = 0; i < kind.length; i++) {
children.push(kind[i])
};
return Vnode(sel, data, children, undefined, undefined);
} else if (Object.prototype.toString.call(kind) == '[object Object]' && kind.hasOwnproperty('sel')) {
// h()返回的是一个对象 所以用此判断
let children = [kind]
return Vnode(sel, data, children, undefined, undefined);
}
}
/**
* Array.isArray(kind) 这个地方很巧妙,但是也很绕,当kind为数组时,我们去遍历这个数组,
* 里面得 h(...)函数 ,函数是自行调用的,所以得到的其实就是vnode对象,push到children []中
* 当子节点 h(...)中也有children, 也会走到这里,得到一个子节点的子节点vnode对象(递归),直至没有children,返回的是文本节点为止
*/
我们来测试一下:
let v1 = h('div', { key: 'v1' }, [
h('p', { key: 'p' }, '这是一个虚拟节点')
]);
console.dir(v1)
没有问题,有了虚拟dom了,肯定得有将虚拟dom转换为真实dom的方法:
createElement将虚拟dom转换为真实dom
export default function createElement(Vnode) {
let tagName = document.createElement(Vnode.sel.toLowerCase());
//节点添加属性
for(let key in Vnode.data){
tagName.setAttribute(key,Vnode.data[key])
}
if (Vnode.text != undefined && (Vnode.children == undefined || Vnode.children.length == 0)) {
//没有子节点 文本节点
tagName.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];
tagName.appendChild(createElement(ch)); //递归添加
}
}
Vnode.elm = tagName;
return Vnode.elm; //elm 虚拟节点对应的真实dom
}
ok,我们来测试一下吧。
上面为虚拟节点,下面为真实的dom。
有了上面三个函数,其实下面根据这三个函数,照着流程图判断就行了。
开始
流程
我们先列出diff算法的整个流程图,也就是patch(),大致知道是经过怎么样的流程就行了。
这就是patch两个虚拟节点的整个流程。
patch函数 开启diff算法
import createElement from "./createElement.js";
import Vnode from "./Vnode.js";
/**
* diff算法开始
*
* diff算法始终是比对新旧虚拟dom,对旧dom做patch
*/
export default function patch(oldVnode, newVnode) {
/**假如oldVnode不是虚拟dom 手动去创建一个子节点*/
//如第一次初始化的时候
if (oldVnode.sel == undefined) {
oldVnode = Vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
}
//判断新旧虚拟节点sel,key是否相等
if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key ){
//相等,进一步去判断
}else{
//不相等,暴力删除旧,替换新
let newElement = createElement(newVnode);
if(oldVnode.elm){
oldVnode.elm.parentNode.insertBefore(newElement,oldVnode.elm);
}
oldVnode.elm.parentNode.removeChild(oldVnode.elm);
}
}
写代码一定对着流程图看,提问一下到了流程图哪一步了呢?
下一步也是判断,为了简介与维护,我们再抽出一个函数:
patchVnode
import createElement from "./createElement.js";
import updateChildren from './updateChildren.js'
export default function patchVnode(oldVnode,newVnode){
//判断newVnode是否为text节点
if(newVnode.text != '' && (newVnode.children == undefined || newVnode.children.length == 0)){
//newVnode是text节点
oldVnode.elm.innerText = newVnode.text;
}else{
//newVnode不是text节点 有children []
//判断oldVnode是不是文本节点
let ch = newVnode.children;
if(oldVnode.text != '' && (oldVnode.children == undefined || oldVnode.children.length == 0)){
oldVnode.elm.innerText = ''
for(let i=0;i<ch.length;i++){
oldVnode.elm.parentNode.appendChild(createElement(ch[i]));
}
}else{
//这块是最小量更新判断的核心算法
//新旧虚拟节点都有children
updateChildren(oldVnode,newVnode)
}
}
}
可以看出来,我们已经到这了
updateChildren
最下面那一块,尤为复杂,可以算diff的核心与核心了,所以我们又抽出了一个updateChildren函数了,也就是当新旧虚拟节点都有 children时,我们去比对他们的children时的具体方法。
四种比对,看是否命中,命中了做相应的操作:
先定义新旧虚拟节点的指针:
import patchVnode from './patchVnode.js';
import createElement from './createElement.js';
// 判断是否是同一个虚拟节点
function sameVnode(a, b) {
return a.sel == b.sel && a.key == b.key;
};
export default function updateChildren(oldVnode,newVnode) {
//移动是指在真实dom中移动 虚拟dom数组是不变的 新旧都不变
//旧虚拟节的elm,也是旧虚拟节点children 的父节点
let parentElm = oldVnode.elm;
//新虚拟节点的子节点
let newCh = newVnode.children;
//旧虚拟节点的子节点
let oldCh = oldVnode.children;
//定义新旧虚拟节点的头部指针
let newS = 0;
let newSNode = newCh[newS];
let oldS = 0;
let oldSNode = oldCh[oldS];
//定义新旧虚拟节点的尾部指针
let newE = newCh.length - 1;
let newENode= newCh[newE]
let oldE = oldCh.length - 1;
let oldENode = oldCh[oldE];
console.log(newSNode,oldSNode,newENode,oldENode)
};
单元测试一下:
let v1 = h('div', { key: 'key' }, [
h('div',{key:'div1'},'999'),
h('div',{key:'div2'},'888'),
h('div',{key:'div3'},'777'),
]);
let v2 = h('div', { key: 'key' }, [
h('div',{key:'div1'},'999'),
h('div',{key:'div2'},'888'),
h('div',{key:'div4'},'666'),
]);
btn.onclick = function () {
patch(v1, v2)
}
没有问题,新旧虚拟节点的首尾指针正确!下面我们来将updateChildren写出来:
这里其实也不难,就是需要判断的地方有点多,就显得代码量看起来很大,其实都是一些基础的判断,
下面需要进行三个步骤:
1.4种方式命中判断做对应操作。
2.4种方式都没命中,判断新虚拟节点是否在旧虚拟节点中,做对应操作。
3.遍历结束,新或旧虚拟节点没遍历完,做对应操作。
关于指针的移动,图上以标明,下面注释写的也很清楚啦!总结一下就是头部指针每次都需要下移,尾部指针每次都需要上移,哪个命中,就要移哪个。
四种都没有命中 看看新虚拟节点存不存在旧虚拟节点中,存在:移动到旧前,不存在:插入都旧前(判断是否存:在是将oldCh的key 存到一个对象中,value值为它在oldCh中的索引 {key:index},这样就能知道是哪个子虚拟节点了)。
遍历结束,头尾中间的就是要新增或删除的项(新虚拟节点没有循环完毕 有新增;旧虚拟节点没有循环完毕 要删除),下面都注释好了,这里再强调一下,移动是指移动真实dom,而不是旧虚拟节点,这个地方不知道的话很让人困惑。
import patchVnode from './patchVnode.js';
import createElement from './createElement.js';
// 判断是否是同一个虚拟节点
function sameVnode(a, b) {
return a.sel == b.sel && a.key == b.key;
};
export default function updateChildren(oldVnode, newVnode) {
//移动是指在真实dom中移动 虚拟dom数组是不变的 新旧都不变
//旧虚拟节的elm,也是旧虚拟节点children 的父节点
let parentElm = oldVnode.elm;
//新虚拟节点的子节点
let newCh = newVnode.children;
//旧虚拟节点的子节点
let oldCh = oldVnode.children;
//定义新旧虚拟节点的头部指针
let newS = 0;
let newSNode = newCh[newS];
let oldS = 0;
let oldSNode = oldCh[oldS];
//定义新旧虚拟节点的尾部指针
let newE = newCh.length - 1;
let newENode = newCh[newE]
let oldE = oldCh.length - 1;
let oldENode = oldCh[oldE];
// 开始比对 ,1 2 3 4 四种情况是新旧虚拟节点都存在的,只是顺序不一样
//是前面判断两个一样的虚拟节点 也就是当新旧虚拟节点sel,key相等时
//递归patchVnode(oldVnode,newVnode) 这里为什么要递归patchVnode呢?是因为子节点可能本身也有子节点,需要去diff一下,
//并且patchVnode会把新旧虚拟节点变为真正的节点,插到其父节点上的!
//1 2是不需要移动节点, 只要patchVnode变成真正的dom 插入到其父节点上
//3 4 需要移动节点,也需要patchVnode变成真正的dom 插入到其父节点上
//移动是真实dom移动,旧虚拟节点oldCh 不变,只是其前后指针变
while (newS <= newE && oldS <= oldE) {
// 后面会把部分节点置为null,直接跳过
if (newSNode == null) {
newSNode = newCh[++newS];
} else if (newENode == null) {
newENode = newCh[--newE];
} else if (oldSNode == null) {
oldSNode = oldCh[++oldS];
} else if (oldENode == null) {
oldENode = oldCh[--oldE];
}
else if (sameVnode(newSNode, oldSNode)) {
//1.新前与旧前一样
console.log('1.新前与旧前一样')
//继续 patchVnode这两个虚拟节点
patchVnode(oldSNode, newSNode);
//新前与旧前指针下移
newSNode = newCh[++newS];
oldSNode = oldCh[++oldS];
} else if (sameVnode(newENode, oldENode)) {
//2.新后与旧后一样
console.log('2.新后与旧后一样')
patchVnode(oldENode, newENode);
//新后与旧后指针上移
newENode = newCh[--newE];
oldENode = oldCh[--oldE];
} else if (sameVnode(newSNode, oldENode)) {
//3.新前与旧后一样
console.log('3.新前与旧后一样')
patchVnode(oldENode, newSNode);
//移动,要将旧后dom插入到旧前dom上(改变的是真实dom,旧children其实是没有变的,它是用了对比的),新前指针下移,旧后指针上移
parentElm.insertBefore(oldENode.elm, oldSNode.elm)
newSNode = newCh[++newS];
oldENode = oldCh[--oldE];
} else if (sameVnode(newENode, oldSNode)) {
//4.新后与旧前一样
console.log('4.新后与旧前一样')
patchVnode(oldSNode, newENode);
//移动,将旧前dom插入到旧后,新后指针上移,旧前指针下移
parentElm.insertBefore(oldSNode.elm, oldENode.elm)
newENode = newCh[--newE];
oldSNode = oldCh[++oldS];
} else {
//上面四种没有命中 看看新虚拟节点存不存在旧虚拟节点中
//这里将oldCh的key 存在一个对象中,value值为它在oldCh中的索引 {key:index}
let keyMap = {};
for (let i = 0; i < oldCh.length; i++) {
keyMap[oldCh[i].key] = i;
};
//看看新虚拟节点的key在不在keyMap中有
const keyInOldCh = keyMap[newSNode.key];
if (keyInOldCh) {
//存在 oldToMove 与newSNode一样 移动到旧前
let oldToMove = oldCh[keyInOldCh];
patchVnode(oldToMove, newSNode);
parentElm.insertBefore(oldToMove.elm, oldSNode.elm);
//这里解释一下为什么,因为这个节点已经比对过了,后面不需要再去比对了
oldCh[keyInOldCh] = null;
} else {
//不存在 是新增的节点 直接插入
parentElm.insertBefore(createElement(newSNode), oldSNode.elm)
}
//只移动新的开始指针,因为我们刚用它去比对oldCh的
newSNode = newCh[++newS];
}
}
//判断有没有剩余 删除是删除真实dom上的节点 新增是新增在真实的dom上
if (newS <= newE) {
//新虚拟节点没有循环完毕 有新增
for (let i = newS; i <= newE; i++) {
//将虚拟节点变为真实dom,添加到节点上
parentElm.appendChild(createElement(newCh[i]));
}
} else if (oldS <= oldE) {
//旧虚拟节点没有循环完毕 要删除
for (let i = oldS; i <= oldE; i++) {
//删除子节点
parentElm.removeChild(oldCh[i].elm);
}
}
};
测试
let contain = document.getElementById('contain')
let btn = document.getElementById('btn')
let v1 = h('div', { key: 'key' }, [
h('div',{key:'999'},'999'),
h('div',{key:'888'},'888'),
h('div',{key:'777'},'777'),
]);
patch(contain, v1)
let v2 = h('div', { key: 'key' }, [
h('div',{key:'111'},'111'),
h('div',{key:'999'},'999'),
h('div',{key:'888'},'888'),
h('div',{key:'666'},'666'),
h('div',{key:'777'},'777'),
h('div',{key:'333'},'333'),
h('div',{key:'222'},'222')
]);
btn.onclick = function () {
patch(v1, v2)
}
点击变成
我们在浏览器中手动更改下 999 888 777 ==> 虚拟节点999 虚拟节点888 虚拟节点77
点击按钮:
可以看到的是:节点位置改变了,但是并没有重写渲染一遍,只是移动了一下位置,并且把不存在的节点删除了,新增的节点也添加进去了,这就是diff算法,最小量更新!源代码会放在github上!