简单介绍一下虚拟DOM和diff算法
需求
方法一:拆了重建
方法二:diff
主要内容
snabbdom简介和测试环境搭建
snabbdom简介
- snabbdom是瑞典语单词,单词原意“速度”;
- 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程序
- 跑通snabbdom官方git首页的demo程序,即证明调试环境已经搭建成功
- 不要忘记在index.html中放置一个div#container
虚拟DOM和h函数
虚拟DOM类似于mustache
中的token
diff是发生在虚拟DOM上的
课程不研究DOM如何变成虚拟DOM
研究内容
- 虚拟函数如何被渲染函数(h函数)产生?
手写h函数 - diff算法原理?
手写diff算法 - 虚拟DOM如何通过diff变为真正的DOM的
事实上,虚拟DOM变回真正的DOM是涵盖在diff算法里面的
虚拟函数如何被渲染函数(h函数)产生?
h函数用来产生虚拟节点
h函数用来产生虚拟节点(vnode)
比如这样会调用h函数
将得到这样的虚拟节点
{
"sel": "a",
"data": {
props: {
href: "http://www.atguigu.com"
}
},
"text": "尚硅谷"
}
它表示的真正DOM节点
一个虚拟节点都有哪些属性
{
children: undefined, // 子元素
data: {}, // 属性、样式
elm: undefined, // 对应真正DOM节点,如果为undefined,表示该节点还未上树
key: undefined, // 节点唯一标识
sel: "div", // 选择器
text: "我是一个盒子" // 文字
}
h函数可以嵌套使用,从而得到虚拟DOM树(????)
比如这样嵌套使用h函数
将得到这样的虚拟DOM树
h函数用法很活
例如
手写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 开发中,基本不会遇见,所以这是合理的优化机制。
同层比较示意图
// 创建虚拟节点
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算法处理新旧节点不是同一个节点时
如何定义"同一个节点"
旧节点的key要和新节点的key相同
且
旧节点的选择器要和新节点的选择器相同
创建节点时,所有子节点都需要递归创建
手写第一次上树时
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处理新旧节点是同一个节点时候
手写新旧节点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之后,与目的不一致。
删除的情况
更新的情况
diff算法的子节点更新策略
四种命中查找:
新前:新的虚拟节点当中的所有没有处理的开头的节点
新后:新的虚拟节点当中的所有没有处理的最后的节点
旧前:旧的虚拟节点当中的所有没有处理的开头的节点
旧后:旧的虚拟节点当中的所有没有处理的最后的节点
经典的diff算法优化策略
- 新前与旧前:如果是旧节点先循环完毕,说明新节点中有要插入的节点。
- 新后与旧后:如果是新节点先循环完毕,如果老节点中还有剩余节点(旧前和新后指针中间的节点),说明他们是要被删除的节点。
- 新后与旧前:(此种发生了,涉及移动节点,那么新前指向的节点,移动的旧后之后)
- 新前与旧后:(此种发生了,涉及移动节点,那么新前指向的节点,移动的旧前之前)
命中一种就不再进行命中判断了
如果都没有命中,就需要用循环来寻找了。移动到oldStartIdx之前。
新增的情况
删除的情况
多删除的情况
复杂的情况
手写子节点更新策略
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);
}
};
完整版手写
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);
}
};