vue底层之虚拟Dom和Diff算法
一. 简单介绍
真实dom和虚拟dom对比,也就是将真实dom转化为一个js对象,这个js对象就是真实dom的相关描述:
前后虚拟dom进行diff找到最小量更新,然后再用patch方法将变化更新到真实dom上:
简单来说,diff算法是根据beforeUpdate和updated前后改变的虚拟dom进行精细化比对找到虚拟dom的改变,然后使用patch将最小量改变更新到真实的dom树上。
二. snabbdom简介
snabbdom是著名的虚拟dom库,是diff算法的鼻祖,vue源码就借鉴了snabbdom。
snabbdom的h函数如何工作
官方snabbdom库中的一些函数的操作演示:
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
const patch = init([
// Init patch function with chosen modules
classModule, // makes it easy to toggle classes
propsModule, // for setting properties on DOM elements
styleModule, // handles styling on elements with support for animations
eventListenersModule, // attaches event listeners
]);
const container = document.getElementById("container");
console.log(container);
const vnode = h("div#container.two.classes", {
on: {
click: function () {
}
}
}, [
h("span", { style: { fontWeight: "bold" } }, "This is bold"),
" and this is just normal text",
h("a", { props: { href: "/foo" } }, "I'll take you places!"),
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);
const newVnode = h(
"div#container.two.classes",
{
on: {
click: function () {
}
}
},
[
h(
"span",
{ style: { fontWeight: "normal", fontStyle: "italic" } },
"This is now italic type"
),
" and this is still just normal text",
h("a", { props: { href: "/bar" } }, "I'll take you places!"),
]
);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state
三. h函数
1. h函数的使用
h函数的作用是产生虚拟dom,即产生虚拟节点(Vnode)
比如:创建一个a标签<a href: "http://www.baidu.com">百度</a>
h("a", {
props:{//放置一些属性
href: "http://www.baidu.com"
}
}, "百度")
//以上h函数创建的dom节点将会得到这样的虚拟节点:
{
"sel": "a",
"data": {
props: {
href: "http://www.baidu.com"
}
},
"text": "百度"
}
//表示真正的的节点:<a href: "http://www.baidu.com">百度</a>
一个虚拟节点有哪些属性:
{
children: undefined,//undefined表示此节点没有子节点元素
data: {//用于存放一些属性、样式等等
props: {},
style: {}
},
elm: undefined, //表示此虚拟节点对应的真正的的dom节点,如果为undefined则表示这个虚拟dom还没有上树
key: undefined, //key表示这个节点的唯一标识,例如vue中的v-for的key服务于最小量更新
sel: "div", //选择器
text: "我是一个盒子" //text表示盒子中的文字
}
h函数的简单使用:
首先需要初始化init一个用于将虚拟dom上树的patch函数,然后通过h函数创建虚拟节点,最后将创建的虚拟节点使用patch函数将其上树的对应的container容器中。
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
// 创建出patch函数,patch的作用就是让虚拟dom上树,渲染到真实dom上
// 初始化时需要传入一些模块
const patch = init([classModule, propsModule, styleModule, eventListenersModule])
// 使用h函数创建一个a标签虚拟节点
let vnode = h("a", { props: { href: "http://www.baidu.com", target: "_blank" } }, "百度")
console.log(vnode);
/*vnode:
{
children: undefined
data: { props: {… } }
elm: undefined
key: undefined
sel: "a"
text: "百度"
}
*/
//让虚拟节点上树
const container = document.querySelector("#container")
// 使用patch使虚拟dom上树 patch(盒子, 虚拟节点)
patch(container, vnode)
dom上树(虚拟dom渲染为真实dom)结果:
h函数可以嵌套使用,从而得到虚拟Dom树(!important)
// h函数嵌套使用构成虚拟dom树
const vnode2 = h("ul", [
h("li", "wh"),
h("li", "xxx"),
h("li", "wmg"),
h("li", [ //用数组进行包裹多个子元素
h("div", [
h("p", "嘻嘻"),
h("p", "哈哈")
])
]),
h("li", h("p", "嗷嗷")) //仅仅含有一个子元素就不用以数组形式了
])
// 使用patch使虚拟dom上树 patch(盒子, 虚拟节点)
patch(container, vnode2)
h函数创建的虚拟dom:
dom上树(虚拟dom渲染为真实dom)结果:
2手写h函数
这里创建一个自己写的snabbdom库mySnabbdom,其需要实现手写一个阉割版即简易版的h函数、patch函数等。
根据传入的参数创建虚拟节点:vnode.js:
// 根据收到的参数来创建一个虚拟节点vnode
export default function (sel, data, children, text, elm) {
return { sel, data, children, text, elm }
}
用于创建虚拟dom树的h函数:h.js:
import vnode from "./vnode"; //引入创建虚拟节点的函数
let vnode1 = vnode("div", {}, [], "我是谁", true)
// console.log(vnode1);
// 注意!这里是编写一个低配版的h函数,这个函数必须接收3个参数,缺一不可
// 相当于它的重载功能较弱
// 也就是说,调用的时候形态必须是下面的三种之一:
//形态1 h("div", {}, "文字")
//形态2 h("div", {}, [])
//形态3 h("div", {}, h())
export default function h(sel, data, c) {
if (arguments.length !== 3) {
throw new Error("hxd我这是低配版h函数,规定传递3个参数哇")
}
// 检查c的类型
if (typeof c == "string" || typeof c == "number") {
// 那么就调用形态1的h函数
return vnode(sel, data, undefined, c, false)
} else if (Array.isArray(c)) {//c为数组
// 说明是形态2
for (let i = 0; i < c.length; i++) {
if (typeof c[i] == "object" && c[i].hasOwnProperty("sel")) {
return vnode(sel, data, c, undefined, false)
} else {
throw new Error("你传入h()函数数组中有一项不符合h函数")
}
}
} else if (typeof c == "object" && c.hasOwnProperty("sel")) {//因为h()的返回结果c是个对象,并且c拥有sel属性
// 说明这是形态3
let children = [c] //用于收集子节点
return vnode(sel, data, children)
} else {
throw new Error("传入的第三个参数的类型不对")
}
}
四. diff算法
1. 感受diff算法
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
// 一个场景,就是点击按钮,修改dom树
// 获取按钮和盒子
let btn = document.querySelector("button")
let container = document.querySelector("#container")
// 创建出patch函数,patch的作用就是让虚拟dom上树,渲染到真实dom上
// 初始化时需要传入一些模块
const patch = init([classModule, propsModule, styleModule, eventListenersModule])
const vnode1 = h("ul", {}, [
// 用key来做每一个dom节点的唯一标识,vue框架中v-for的key就是这个原理实现最小量更新
// 也就是key服务于虚拟dom的最小量更新的
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", {}, [
//如果不用key,那么在patch时,会在原先的vnode1的最后append一个节点,然后再依次修改A->E,B->A,C->B,D->C,D
//用key属性之后,在patch的时候就会对比前后虚拟dom的key,如果父元素相同且key相同则认为一些元素未改变,那么就直接插入改变的虚拟dom节点就可以了,这样就使用key实现了最小量更新
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.addEventListener("click", () => {
// 在更改dom时,只需要传入新旧dom就可以了,不需要再传container了
patch(vnode1, vnode2)
}, false)
心得:
- 虚拟dom中key很重要,key就是这个节点的唯一标识,就是告诉diff算法,在更改前后他们是同一个Dom节点。
- 只有是同一个虚拟节点,才进行精细化比较,否则就暴力删除旧的、插入新的。是同一个虚拟节点等价于两个节点选择器相同且key属性相同。
- 并且只进行同层比较,不会跨层比较。也就是说父级元素变了,那么子级元素无论如何都会改变。也就是会删除原先的,然后插入新的。
所以说diff算法不是真的无微不至,不会考虑上面所说的几种情况即同一个虚拟节点(选择器相同和key完全相同)和不同层的虚拟dom不会进行diff,但这并不影响性能,因为在实际开发中,基本不会遇见这几种情形,所以这是合理的优化机制。
2. 手写diff算法
patch的过程:
- patch判断传入的第一个参数是真实dom元素节点还是虚拟dom节点取决于此对象是否有"sel"属性,如果存在”sel”属性则是虚拟节点否则就是真实的dom元素节点
- 如果旧节点和新节点的选择器相同并且key属性也相同,则认为新旧节点是相同节点。
- 在创建节点时,所有节点需要递归创建。
1.1 oldVnode和newVnode不是同一个节点,暴力删除旧的dom,插入新的dom
新旧虚拟dom进行diff:patch.js:
import vnode from "./vnode"; //用于创建虚拟节点
import createElement from "./createElement"; //用于创建真实dom节点
// 手写patch函数
export default function (oldVnode, newVnode) {
// 首先判断传入的第一个参数是dom节点还是虚拟dom节点
if (oldVnode.sel == undefined) {// 如果sel属性为空,则他是真实的dom节点
// 那么此时就对这个dom节点包装为虚拟dom节点
// 需要传入sel、data、children、text、elm属性
// dom.tagName获取dom元素的标签名
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
// 提问?这里为什么不用h()函数来创建虚拟节点呢
// 答:这里目的仅仅是得到此单个dom元素的虚拟节点,
// 而不包含它的子元素,所以大可不必用h函数分多种情况来创建
}
// 判断oldVnode进而newVnode是不是同一个节点
// 就是对比vnode的sel和key是否完全相同
if (oldVnode.sel === newVnode.sel && oldVnode.key === newVnode.key) {
// 是同一个虚拟dom节点,进行diff
console.log("是同一个节点");
} else {// 不是同一个虚拟dom节点,则暴力拆除
// 先创建真实dom节点
let newVnodeElm = createElement(newVnode)
//基准节点的父元素将孤儿节点插入到基准元素之前
if (oldVnode.elm.parentNode && newVnode.elm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}
// 移除基准元素
oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
}
通过虚拟dom创建真实dom:createElement.js:
// 用于真正创建dom节点
// 真正创建节点。将vnode创建为真实dom
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
domNode.innerHTML = vnode.text
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
console.log("我有孩子节点");
// 内部为子节点,就要递归创建节点
for (let i = 0; i < vnode.children.length; i++) {
// 得到当前的一个孩子虚拟节点
let ch = vnode.children[i]
// console.log(ch);
// 创建这个孩子节点的真实dom,并且也拥有elm属性了
let chDom = createElement(ch)
domNode.appendChild(chDom)
}
}
// 补充elm属性,不论该节点内容是文本节点还是元素节点,都需要返回自己的
// elm属性为当前节点的真实dom元素
vnode.elm = domNode
return vnode.elm
}
测试:index.js:
import h from "./mySnabbdom/h" //引入自己写的h函数
import patch from "./mySnabbdom/patch" //引入自己写的patch函数
let container = document.querySelector("#container")
let vnode1 = h("ul", {}, [
h("li", {}, "ww"),
h("li", {}, "ee"),
h("li", {}, "rr"),
])
// let vnode1 = h("p", {}, "www")
patch(container, vnode1)
####1.2新旧虚拟dom是同一个节点时,进行diff最小量更新
1.2 新旧虚拟dom是同一个节点时,进行diff最小量更新
diff精细化比较的过程:
最难的就是五角星框里面内容的实现。
尝试手写,但是执行不顺畅
在进行精细化diff比较新旧dom节点下子节点children时,新创建的节点要插入到所有未处理的节点之前,而不是所有已处理节点之后。
//此代码是尝试手写diff精细化比较算法,但思路比较复杂,还是需要使用官方提供的思路,通过使用四个指针新前、新后、旧前、旧后来实现虚拟dom更新。
import createElement from "./createElement";
// 此函数为当新旧虚拟dom节点为同一个节点进行diff时的逻辑函数
export default function patchVnode(oldVnode, newVnode) {
// 是同一个虚拟dom节点,进行diff
console.log("是同一个节点");
// 判断新旧节点是否是同一个对象
if (oldVnode == newVnode) {
return
}
// 然后判断新的vnode是否有text属性,如果有的话直接用新dom的内容插入到旧dom的innerHTML
if (newVnode.text) {//有text属性
console.log("新的vnode有文本");
if (newVnode.text != oldVnode.text) {
// 无论老的虚拟dom中是文本还是还多子节点,通过innerHTML最后都会编程新dom的text
oldVnode.elm.innerHTML = newVnode.text
}
} else {//无text属性,即新的虚拟dom含有children孩子节点
console.log("新的vnode无text属性,");
// 判断旧的dom有没有text属性、或者也可以按照流程图中的判断旧dom中是否含有children子节点
// 这里我是判断是否有文本节点
if (oldVnode.children.length == 0) {//旧dom无children(这是简易版patch)
console.log("新的vnode无text属性,且旧的vnode是文本节点");
// 有text文本,那么就先清除旧虚拟dom的文本,再往里面追加孩子节点
oldVnode.elm.innerHTML = "" //先清空旧dom的文本节点
// 遍历新的vnode的子节点,再往就dom里追加新元素
for (let i = 0; i < newVnode.children.length; i++) {
console.log(newVnode.children[i]);
oldVnode.elm.appendChild(createElement(newVnode.children[i]))
}
} else { //旧dom有child孩子节点
// 此时就是最复杂的情况,就是新旧dom都有children
console.log("新旧dom都有children孩子节点");
// 所有未处理的节点的开头
let un = 0
for (let i = 0; i < newVnode.children.length; i++) {
let ch = newVnode.children[i]
let isExist = false //标志位表示该新节点children下的新子节点是否存在于老节点children中
// 再次遍历,看看oldnode中有没有和它时候一样的
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) {//表示新节点的children中的当前子节点不存在于老节点中
// 如果不存在的话,那么就将它推入
console.log(ch, i);
let dom = createElement(ch)
if (un < oldVnode.children.length) {
oldVnode.elm.insertBefore(dom, oldVnode.children[ul].elm)
} else {
oldVnode.elm.appendChild(dom)
}
} else {
// 让处理过的节点指针下移
un++
}
}
}
}
}
1.3 diff算法的子节点更新策略
四中命中查找,按顺序进行命中,如果当前子节点命中一种就不用再让当前子节点接着往下寻找命中了,下一步操作是让当前节点的下一个节点从头开始查找命中
策略:
- 新前与旧前(命中之后就让新前和旧前指针++往后移动)
- 新后与旧后 (命中之后就让新后与旧后指针–往前移动)
- 新后与旧前 (此种情况发生了,就涉及到移动节点,将新后指向的节点插入到旧后之后)
- 新前与旧后(此种情况发生了,就涉及到移动节点,将新前指向的节点插入到旧前之前)
新前指代新虚拟节点的子节点中没有处理的开头的节点
新后指代新虚拟节点的子节点中的没有处理节点的最后一个节点
旧前指代旧虚拟节点的子节点中没有处理的开头的节点
旧后指代旧虚拟节点的子节点中没有处理节点的最后一个节点
新虚拟dom子节点一共含有以几种情况:
新虚拟dom新增节点的情况
新虚拟dom删除节点的情况
新虚拟dom删除节点的情况
比较复杂的情况
复杂过程插入过程举例,便于理解移动和插入的过程:
初始新旧dom:
dom更新完毕:
1.4 手写子节点更新策略
updateChildren.js为新前旧前、新后旧后、新后旧前、新前旧后的子节点更新策略(重要!):
import createElement from "./createElement";
import patchVnode from "./patchVnode";
// 此函数为新dom子节点的更新策略,即新前、新后、旧前、旧后进行移动和插入节点的过程
export default function updateChildren(parentElm, oldch, newch) {
// parentElm为父节点,oldch为旧节点数组,newch为新虚拟节点数组
console.log("我是updateChildren", oldch, newch);
// 节点编号
// 新前
let newStartIndex = 0
// 新后
let newEndIndex = newch.length - 1
// 旧前
let oldStartIndex = 0
// 旧后
let oldEndIndex = oldch.length - 1
// 真正的节点
// 新前节点
let newStartVnode = newch[0]
// 新后节点
let newEndVnode = newch[newEndIndex]
// 旧前节点
let oldStartVnode = oldch[0]
// 旧后节点
let oldEndVnode = oldch[oldEndIndex]
// 用于缓存索引
let keyMap = {}
// 开始执行更新策略,通过while来执行节点的移动
while (newEndIndex >= newStartIndex && oldEndIndex >= oldStartIndex) {
// 对于旧节点打上undefined标记的直接略过就可以
if (!oldStartVnode || !oldch[oldStartIndex]) {
oldStartVnode = oldch[++oldStartIndex]
} else if (!oldEndVnode || !oldch[oldEndIndex]) {
oldEndVnode = oldch[--oldEndIndex]
} else if (!newEndVnode || !newch[newEndIndex]) {
newEndVnode = newch[--newEndIndex]
} else if (!newStartVnode || !newch[newStartIndex]) {
newStartVnode = newch[++newStartIndex]
} else if (checkSameVnode(oldStartVnode, newStartVnode)) {
// 先新前旧前进行相比
console.log("①新前旧前命中");
// 两个新旧Vnode是同一个,那么此时调用patchVnode将新子节点vnode更新到旧vnode上
patchVnode(oldStartVnode, newStartVnode)
newStartVnode = newch[++newStartIndex]
oldStartVnode = oldch[++oldStartIndex]
} else if (checkSameVnode(oldEndVnode, newEndVnode)) {
//新后与旧后
console.log("②新后与旧后命中");
patchVnode(oldEndVnode, newEndVnode)
newEndVnode = newch[--newEndIndex]
oldEndVnode = oldch[--oldEndIndex]
} else if (checkSameVnode(oldStartVnode, newEndVnode)) {
//新后与旧前
console.log("③新后与旧前命中");
patchVnode(oldStartVnode, newEndVnode)
// 当③新后与旧前命中的时候,此时需要移动节点。
// 移动此新后/旧前(新后也就是旧前,已经进行更新他两是同一个节点了已经)节点到旧后的后面
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibing)
newEndVnode = newch[--newEndIndex]
oldStartVnode = oldch[++oldStartIndex]
} else if (checkSameVnode(newStartVnode, oldEndVnode)) {
// 新前与旧后
console.log("④命中新前与旧后");
patchVnode(newStartVnode, oldEndVnode)
// 当④命中新前与旧后,则就将新前这个节点(现在也就是旧后)插入到旧前的前面
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
newStartVnode = newch[++newEndIndex]
oldEndVnode = oldch[--oldEndIndex]
} else {// 都没有匹配到
// 表示前四种命中都没有匹配到
console.log("前面四种命中都没有匹配到哇!");
// console.log(newStartIndex);
// newStartIndex++
// 制作keyMap。为一个映射对象,这样就不用
// 首先先判断keymap是不是空的,如果是空的那么就初始化它
if (Object.keys(keyMap) == 0) {
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
if (oldch[i]) {
let key = oldch[i].key //获取旧vnode的唯一标识key
if (key != undefined) {
keyMap[key] = i
}
}
}
console.log(keyMap);
}
// 寻找当前新虚拟dom的子节点(newStartIndex)在keyMap中未处理旧节点的映射的位置
let indexInOld = keyMap[newStartVnode.key]
console.log(indexInOld, newStartVnode.key);
// 现在开始判断indexInOld是否是undefined,如果是undefined则当前节点是全新的节点,直接插入即可
if (indexInOld == undefined) {//插入的节点为全新的节点
// 被加入的项(即newStartVnode节点)现在不是真正的dom节点
console.log(oldStartVnode.key);
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
} else {
// 插入的节点不是全新节点,需要更新旧节点,并移动
let elmToMove = oldch[indexInOld]
console.log("不是全新节点");
// patchVnode(oldch[indexInOld], newch[newStartIndex])
patchVnode(elmToMove, newch[newStartIndex])
// 将这一项设置为undefined,以后就不会处理这一项了
oldch[indexInOld] = undefined
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
// parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
}
// 指针下移
newStartVnode = newch[++newStartIndex]
}
}
// 继续看看有没有剩余的,有剩余则将剩余的新dom中子节点插入进去
if (newStartIndex <= newEndIndex) {
console.log("新节点数组中还有剩余节点没有处理,要加项。把所有剩余的节点,都要插入到oldStartIndex之前");
// 插入的标杆
// let before = newch[newEndIndex + 1] == null ? null : newch[newEndIndex + 1].elm
// console.log(before);
// 剩余的没有处理的新虚拟dom的子节点,需要插入到
for (let i = newStartIndex; i <= newEndIndex; i++) {
parentElm.insertBefore(createElement(newch[i]), oldch[oldStartIndex].elm)
}
} else if (oldStartIndex <= oldEndIndex) {
//表明旧的虚拟dom中的子节点还没有处理完,这时则需要删除未处理的节点
console.log("旧dom还有未处理的节点,要删除项");
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
if (oldch[i]) {
parentElm.removeChild(oldch[i].elm) //删除此虚拟节点
}
}
}
}
// 用于判断新旧vnode是否是同一个虚拟节点的方法
function checkSameVnode(a, b) {
return a.sel === b.sel && a.key === b.key
}
五. 全部文件:
文件目录:
index.js:此文件用于测试新旧vnode上树:
import h from "./mySnabbdom/h" //引入自己写的h函数
import patch from "./mySnabbdom/patch" //引入自己写的patch函数
// const vnode1 = h("div", {}, h("p", {}, "ww"))
// const vnode2 = h("ul", {}, [
// h("li", {}, "wh"),
// h("li", {}, "xxx"),
// h("li", {}, "wmg"),
// h("li", {}, [ //用数组进行包裹多个子元素
// h("div", {}, [
// h("p", {}, "嘻嘻"),
// h("p", {}, "哈哈")
// ])
// ]),
// h("li", {}, h("p", {}, "嗷嗷")) //仅仅含有一个子元素就不用以数组形式了
// ])
// console.log(vnode1);
// console.log(vnode2);
let container = document.querySelector("#container")
let btn = document.querySelector("button")
let vnode1 = h("ul", {}, [
h("li", { key: "B" }, "B"),
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"),
])
// 第一次上树
patch(container, vnode1)
let vnode2 = h("ul", {}, [
h("li", { key: "A" }, "A"),
h("li", { key: "B" }, "B"),
// h("li", { key: "C" }, "C"),
// h("li", { key: "E" }, "E"),
// h("li", { key: "B" }, "B"),
// h("li", { key: "A" }, "A"),
// h("li", { key: "Q" }, "Q"),
// h("li", { key: "D" }, "D"),
// h("li", { key: "C" }, "C"),
// h("li", { key: "B" }, "B"),
// h("li", { key: "D" }, "D"),
])
// let vnode1 = h("div", {}, "www")
btn.addEventListener("click", () => {
patch(vnode1, vnode2)
})
vnode.js:创建虚拟节点
// 根据收到的参数来创建一个虚拟节点vnode
export default function (sel, data, children, text, elm) {
let key = data.key
return { sel, data, children, text, elm, key }
}
h.js文件:用于根据真实dom创建一个虚拟dom对象:
import vnode from "./vnode"; //引入创建虚拟节点的函数
// let vnode1 = vnode("div", {}, [], "我是谁", undefined)
// console.log(vnode1);
// 注意!这里是编写一个低配版的h函数,这个函数必须接收3个参数,缺一不可
// 相当于它的重载功能较弱
// 也就是说,调用的时候形态必须是下面的三种之一:
//形态1 h("div", {}, "文字")
//形态2 h("div", {}, [])
//形态3 h("div", {}, h())
export default function h(sel, data, c) {
if (arguments.length !== 3) {
throw new Error("hxd我这是低配版h函数,规定传递3个参数哇")
}
// 检查c的类型
if (typeof c == "string" || typeof c == "number") {
// 那么就调用形态1的h函数
return vnode(sel, data, undefined, c, undefined)
} else if (Array.isArray(c)) {//c为数组
// 说明是形态2
for (let i = 0; i < c.length; i++) {
if (typeof c[i] == "object" && c[i].hasOwnProperty("sel")) {
return vnode(sel, data, c, undefined, undefined)
} else {
throw new Error("你传入h()函数数组中有一项不符合h函数")
}
}
} else if (typeof c == "object" && c.hasOwnProperty("sel")) {//因为h()的返回结果c是个对象,并且c拥有sel属性
// 说明这是形态3
let children = [c] //用于收集子节点
return vnode(sel, data, children, undefined, undefined)
} else {
throw new Error("传入的第三个参数的类型不对")
}
}
patch.js:此文件为patch的过程函数:
import vnode from "./vnode"; //用于创建虚拟节点
import createElement from "./createElement"; //用于创建真实dom节点
import patchVnode from "./patchVnode";
// 手写patch函数
export default function (oldVnode, newVnode) {
// 首先判断传入的第一个参数是dom节点还是虚拟dom节点
if (oldVnode.sel == undefined) {// 如果sel属性为空,则他是真实的dom节点
// 那么此时就对这个dom节点包装为虚拟dom节点
// 需要传入sel、data、children、text、elm属性
// dom.tagName获取dom元素的标签名
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], "", oldVnode)
// 提问?这里为什么不用h()函数来创建虚拟节点呢
// 答:这里目的仅仅是得到此单个dom元素的虚拟节点,
// 而不包含它的子元素,所以大可不必用h函数分多种情况来创建
}
// 判断oldVnode进而newVnode是不是同一个节点
// 就是对比vnode的sel和key是否完全相同
if (oldVnode.sel === newVnode.sel && oldVnode.key === newVnode.key) {
// 是同一个虚拟dom节点,进行diff
patchVnode(oldVnode, newVnode)
} else {// 不是同一个虚拟dom节点,则暴力拆除
// 先创建真实dom节点
let newVnodeElm = createElement(newVnode)
//基准节点的父元素将孤儿节点插入到基准元素之前
if (oldVnode.elm.parentNode && newVnode.elm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}
// 移除基准元素
oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
}
createElement.js:由虚拟节点vnode创建出真实节点的方法:
// 用于真正创建dom节点
// 真正创建节点。将vnode创建为真实dom
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
domNode.innerHTML = vnode.text
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
console.log("我有孩子节点");
// 内部为子节点,就要递归创建节点
for (let i = 0; i < vnode.children.length; i++) {
// 得到当前的一个孩子虚拟节点
let ch = vnode.children[i]
// console.log(ch);
// 创建这个孩子节点的真实dom,并且也拥有elm属性了
let chDom = createElement(ch)
domNode.appendChild(chDom)
}
}
// 补充elm属性,不论该节点内容是文本节点还是元素节点,都需要返回自己的 elm属性为当前节点的真实dom元素
vnode.elm = domNode
return vnode.elm
}
patchNode.js:此文件为将newVnode在oldVnode的基础上更新oldVnode并呈现在真实dom上
import createElement from "./createElement";
import updateChildren from "./updateChildren";
// 此函数为当新旧虚拟dom节点为同一个节点进行diff时的逻辑函数
// 注意oldVnode是vdom的一个父节点,newVnode为新Vdom的最外层父级节点
// 执行此函数最后的结果是将newVnode在oldVnode的基础上更新oldVnode
export default function patchVnode(oldVnode, newVnode) {
// 是同一个虚拟dom节点,进行diff
console.log("是同一个节点");
// 判断新旧节点是否是同一个对象
if (oldVnode == newVnode) {
return
}
// 然后判断新的vnode是否有text属性,如果有的话直接用新dom的内容插入到旧dom的innerHTML
if (newVnode.text) {//有text属性
console.log("新的vnode有文本");
if (newVnode.text != oldVnode.text) {
// 无论老的虚拟dom中是文本还是还多子节点,通过innerHTML最后都会编程新dom的text
oldVnode.elm.innerHTML = newVnode.text
}
} else {//无text属性,即新的虚拟dom含有children孩子节点
console.log("新的vnode无text属性,");
// 判断旧的dom有没有text属性、或者也可以按照流程图中的判断旧dom中是否含有children子节点
// 这里我是判断是否有文本节点
if (oldVnode.children.length == 0) {//旧dom无children(这是简易版patch)
console.log("新的vnode无text属性,且旧的vnode是文本节点");
// 有text文本,那么就先清除旧虚拟dom的文本,再往里面追加孩子节点
oldVnode.elm.innerHTML = "" //先清空旧dom的文本节点
// 遍历新的vnode的子节点,再往就dom里追加新元素
for (let i = 0; i < newVnode.children.length; i++) {
console.log(newVnode.children[i]);
oldVnode.elm.appendChild(createElement(newVnode.children[i]))
}
} else { //旧dom有child孩子节点
// 此时就是最复杂的情况,就是新旧dom都有children
console.log("新旧dom都有children孩子节点");
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
}
}
}
updateChildren.js为新前旧前、新后旧后、新后旧前、新前旧后的子节点更新策略(重要!):
import createElement from "./createElement";
import patchVnode from "./patchVnode";
// 此函数为新dom子节点的更新策略,即新前、新后、旧前、旧后进行移动和插入节点的过程
export default function updateChildren(parentElm, oldch, newch) {
// parentElm为父节点,oldch为旧节点数组,newch为新虚拟节点数组
console.log("我是updateChildren", oldch, newch);
// 节点编号
// 新前
let newStartIndex = 0
// 新后
let newEndIndex = newch.length - 1
// 旧前
let oldStartIndex = 0
// 旧后
let oldEndIndex = oldch.length - 1
// 真正的节点
// 新前节点
let newStartVnode = newch[0]
// 新后节点
let newEndVnode = newch[newEndIndex]
// 旧前节点
let oldStartVnode = oldch[0]
// 旧后节点
let oldEndVnode = oldch[oldEndIndex]
// 用于缓存索引
let keyMap = {}
// 开始执行更新策略,通过while来执行节点的移动
while (newEndIndex >= newStartIndex && oldEndIndex >= oldStartIndex) {
// 对于旧节点打上undefined标记的直接略过就可以
if (!oldStartVnode || !oldch[oldStartIndex]) {
oldStartVnode = oldch[++oldStartIndex]
} else if (!oldEndVnode || !oldch[oldEndIndex]) {
oldEndVnode = oldch[--oldEndIndex]
} else if (!newEndVnode || !newch[newEndIndex]) {
newEndVnode = newch[--newEndIndex]
} else if (!newStartVnode || !newch[newStartIndex]) {
newStartVnode = newch[++newStartIndex]
} else if (checkSameVnode(oldStartVnode, newStartVnode)) {
// 先新前旧前进行相比
console.log("①新前旧前命中");
// 两个新旧Vnode是同一个,那么此时调用patchVnode将新子节点vnode更新到旧vnode上
patchVnode(oldStartVnode, newStartVnode)
newStartVnode = newch[++newStartIndex]
oldStartVnode = oldch[++oldStartIndex]
} else if (checkSameVnode(oldEndVnode, newEndVnode)) {
//新后与旧后
console.log("②新后与旧后命中");
patchVnode(oldEndVnode, newEndVnode)
newEndVnode = newch[--newEndIndex]
oldEndVnode = oldch[--oldEndIndex]
} else if (checkSameVnode(oldStartVnode, newEndVnode)) {
//新后与旧前
console.log("③新后与旧前命中");
patchVnode(oldStartVnode, newEndVnode)
// 当③新后与旧前命中的时候,此时需要移动节点。
// 移动此新后/旧前(新后也就是旧前,已经进行更新他两是同一个节点了已经)节点到旧后的后面
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibing)
newEndVnode = newch[--newEndIndex]
oldStartVnode = oldch[++oldStartIndex]
} else if (checkSameVnode(newStartVnode, oldEndVnode)) {
// 新前与旧后
console.log("④命中新前与旧后");
patchVnode(newStartVnode, oldEndVnode)
// 当④命中新前与旧后,则就将新前这个节点(现在也就是旧后)插入到旧前的前面
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
newStartVnode = newch[++newEndIndex]
oldEndVnode = oldch[--oldEndIndex]
} else {// 都没有匹配到
// 表示前四种命中都没有匹配到
console.log("前面四种命中都没有匹配到哇!");
// console.log(newStartIndex);
// newStartIndex++
// 制作keyMap。为一个映射对象,这样就不用
// 首先先判断keymap是不是空的,如果是空的那么就初始化它
if (Object.keys(keyMap) == 0) {
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
if (oldch[i]) {
let key = oldch[i].key //获取旧vnode的唯一标识key
if (key != undefined) {
keyMap[key] = i
}
}
}
console.log(keyMap);
}
// 寻找当前新虚拟dom的子节点(newStartIndex)在keyMap中未处理旧节点的映射的位置
let indexInOld = keyMap[newStartVnode.key]
console.log(indexInOld, newStartVnode.key);
// 现在开始判断indexInOld是否是undefined,如果是undefined则当前节点是全新的节点,直接插入即可
if (indexInOld == undefined) {//插入的节点为全新的节点
// 被加入的项(即newStartVnode节点)现在不是真正的dom节点
console.log(oldStartVnode.key);
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
} else {
// 插入的节点不是全新节点,需要更新旧节点,并移动
let elmToMove = oldch[indexInOld]
console.log("不是全新节点");
// patchVnode(oldch[indexInOld], newch[newStartIndex])
patchVnode(elmToMove, newch[newStartIndex])
// 将这一项设置为undefined,以后就不会处理这一项了
oldch[indexInOld] = undefined
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
// parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
}
// 指针下移
newStartVnode = newch[++newStartIndex]
}
}
// 继续看看有没有剩余的,有剩余则将剩余的新dom中子节点插入进去
if (newStartIndex <= newEndIndex) {
console.log("新节点数组中还有剩余节点没有处理,要加项。把所有剩余的节点,都要插入到oldStartIndex之前");
// 插入的标杆
// let before = newch[newEndIndex + 1] == null ? null : newch[newEndIndex + 1].elm
// console.log(before);
// 剩余的没有处理的新虚拟dom的子节点,需要插入到
for (let i = newStartIndex; i <= newEndIndex; i++) {
parentElm.insertBefore(createElement(newch[i]), oldch[oldStartIndex].elm)
}
} else if (oldStartIndex <= oldEndIndex) {
//表明旧的虚拟dom中的子节点还没有处理完,这时则需要删除未处理的节点
console.log("旧dom还有未处理的节点,要删除项");
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
if (oldch[i]) {
parentElm.removeChild(oldch[i].elm) //删除此虚拟节点
}
}
}
}
// 用于判断新旧vnode是否是同一个虚拟节点的方法
function checkSameVnode(a, b) {
return a.sel === b.sel && a.key === b.key
}
六. diff新旧节点更新策略总结
可以看着流程图理解。
首先进入patch方法,新旧vnode进行diff。如果旧节点为dom对象,那么需要将其封装为虚拟dom对象,再与新的vnode进行比较;如果新旧vnode不是同一个节点(选择器sel和vnode唯一标识符key不相同),那么就直接暴力拆除旧得dom,然后插入新的dom;另一方面如果新旧虚拟dom是同一个节点(即sel属性和key全都相同),那么开始进行diff精细化比较;首先如果新旧节点是同一个对象的话直接return返回不用处理;然后再看新vnode是否是文本节点即不含有children,如果是则直接让旧节点的innerHTML=newVnode.text来覆盖旧节点;如果新vnode节点是含有子节点children,此时如果老节点为text文本节点也好处理,即先让老节点内容置空,然后再插入新节点的children;如果老节点也有很多子节点children,那么此时就是比较复杂的新旧子节点更新策略了,此策略包括4个步骤按序执行命中,即新前旧前、新后旧后、新后旧前、新前旧后,一旦新老子节点命中一个,就停止判断下面的步骤了,然后按照这个策略规定移动指针和插入节点(查看子节点更新策略);然后如果指针移动结束后,newVnode还有节点未处理,那么就是需要插入的节点,遍历将这些节点插入到旧dom的oldEndNode之后就可以了;另一方面如果oldvnode还剩余节点未处理,那么就删除oldVnode未处理的节点就可以了。