一、虚拟DOM简介
所谓虚拟DOM,就是用JavaScript对象的方式去描述真实DOM。由于真实DOM的创建、修改、删除会造成页面的重排和重绘,频繁操作真实DOM会影响页面的性能,页面中会有数据、样式的更新,操作真实DOM是不可避免的,而虚拟DOM的产生是为了最大限度的减少对真实DOM的操作,因为虚拟DOM可以将真实DOM操作映射为JavaScript对象操作,尽量复用真实的DOM。
二、虚拟DOM如何描述真实DOM
比如以下一段HTML代码,我们可以看到这是一个div元素节点,这个div元素节点上有一个属性id,值为container,并且这个div元素节点有两个子节点,一个子节点是span元素节点,span元素节点有style属性,属性值为color: red,span元素节点内也有一个子节点,hello文本节点;另一个子节点是world文本节点
<div id="container">
hello world
</div>
// 对应的JavaScript对象描述为
{
_type: "VNODE_TYPE",
tag: "div",
key: undefined,
props: {
"id": "container"
},
children: [
{
_type: "VNODE_TYPE",
tag: undefined,
key: undefined,
props: undefined,
children: undefined,
text: "hello world",
domNode: undefined
}
],
text: undefined,
domNode: undefined
}
三、项目初始化
本项目需要通过webpack进行打包、并通过webpack-dev-server启动项目,所以需要安装webpack
、webpack-cli
、webpack-dev-server
。
① 新建一个dom-diff项目,并执行npm init --yes
生成项目的package.json文件。
② 修改package.json文件,添加build和dev脚本,build用于webpack打包项目,dev用于webpack-dev-server启动项目,如:
// 修改package.json 文件的scripts部分
{
"scripts": {
"build": "webpack --mode=development",
"dev": "webpack-dev-server --mode=development --contentBase=./dist"
}
}
③ 在项目根目录下新建一个src目录,然后在src目录下,新建一个index.js文件,webpack默认入口文件为src目录下的index.js,默认输出目录为 项目根目录下的dist目录
// index.js文件初始化内容
console.log("hello virtual dom-diff.");
④ 首先执行npm run bulid打包输出,会在项目根目录下生成一个dist目录,并在dist目录下打包输出一个main.js,然后在dist目录下,新建一个index.html文件,其引入打包输出后的main.js,如:
// dist/index.html文件内容
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Vue DOM DIFF</title>
<style>
</style>
</head>
<body>
<div id="app"></div>
<script src="./main.js"></script>
</body>
</html>
⑤ 执行npm run dev启动项目,然后在浏览器中输入http://localhost:8080
,如果控制台中输出了hello virtual dom-diff.
表示项目初始化成功。
四、创建虚拟DOM节点
由于虚拟DOM本质就是一个JavaScript对象,所以创建虚拟DOM节点就是创建一个JavaScript对象,关键在于这个JavaScript对象上有哪些属性。Vue中创建虚拟DOM节点使用的是h()方法,所以要创建虚拟DOM,主要就是实现这个h()方法。我们需要知道要创建的虚拟DOM的标签名tag、属性名对象props(有多个属性)、子节点数组children(有多个子节点)、key(节点的唯一标识)、text(如果是文本节点则有对应的text)、真实DOM节点domNode、还有一个就是节点类型_type(是否是虚拟DOM节点),如:
① 在src目录下新建一个vdom
目录,创建一个index.js、vnode.js、h.js。
// src/vdom/index.js主要是导出h.js中暴露的h()方法
import h from "./h"; // 引入h方法
export {
h // 对外暴露h方法
}
// src/vdom/vnode.js主要就是提供了一个vnode方法,用于接收虚拟DOM节点的属性并生成对应的虚拟DOM节点
const VNODE_TYPE = "VNODE_TYPE"; // 虚拟DOM节点
function vnode(tag, key, props, children = [], text, domNode) {
return {
_type: VNODE_TYPE, // 表示这是一个虚拟DOM节点
tag, // 对应的标签类型
key, // DOM节点的唯一标识
props, // DOM节点上对应的属性集
children, // DOM节点的子节点
text, // DOM节点(文本节点)对应的文本内容
domNode // 创建的真实DOM节点
}
}
export default vnode;
// src/vdom/h.js主要就是提供了一个h()方法用于解析传递过来的参数,即从全部属性中分离出key,然后创建对应的vnode
import vnode from "./vnode";
const hasOwnProperty = Object.prototype.hasOwnProperty;
function h(tag, attrs, ...children) {
const props = {}; // 属性对象,移除key后的属性集
let key; // 从全部属性中分离出key值
if (attrs && attrs.key) {
key = attrs.key;
}
// 迭代attrs中的每一个属性,生成一个将key移除后的属性集对象
for(let propName in attrs) {
if (hasOwnProperty.call(attrs, propName) && propName !== "key") {
props[propName] = attrs[propName];
}
}
return vnode(tag, key, props, children.map((child) => {
// 如果子节点是一个纯文本节点,那么生成一个文本节点对应的vnode(其他属性均为undefined,但是text属性为对应文本)
// 如果已经是虚拟节点了,那么直接返回即可
return typeof child == "string" || typeof child == "number" ? vnode(
undefined, undefined, undefined, undefined, child
) : child;
}));
}
export default h;
② 之后我们就可以通过h()方法创建虚拟节点了,修改项目根目录下的index.js并创建对应的虚拟DOM节点,如:
// src/index.js
import { h } from "./vdom"; // 引入h()方法,用于创建虚拟DOM
const oldVnode = h("div", {id: "container"},
h("span", {style: {color: "red"}}, "hello"), // 参数中的函数会先执行
"world"
);
console.log(oldVnode);
五、将虚拟DOM节点mount
要想将虚拟DOM节点mount出来,那么必须将虚拟DOM节点转换为真实的DOM节点,然后将其添加进真实的DOM中。挂载DOM节点非常简单,只需要获取到真实的挂载点DOM元素,然后通过其append()方法即可挂载上去,所以其关键点就在于将虚拟DOM转换为真实的DOM节点。
① 在vdom目录中新建一个mount.js文件,里面对外暴露一个mount()方法和createDOMElementByVnode()方法,如:
// src/vdom/mount.js
// 传入一个新的虚拟DOM节点,和旧的虚拟DOM的props进行比较并更新
export function updateProperties(newVnode, oldProps = {}) {
}
// 通过虚拟DOM节点创建真实的DOM节点
export function createDOMElementByVnode(vnode) {
}
// mount方法用于接收一个虚拟DOM节点,和一个真实的父DOM节点,即挂载点
// mount方法内部会首先将这个虚拟DOM节点转换为真实的DOM节点,然后将其添加到真实的挂载点元素上
function mount(vnode, parentNode) {
let newDOMNode = createDOMElementByVnode(vnode); // 将虚拟DOM转换为真实的DOM
parentNode.append(newDOMNode); // 再将真实的DOM挂载到父节点中
}
export default mount;
② 在src/vdom/index.js文件中引入mount.js中的mount()方法并对外暴露
// src/vdom/index.js文件
import h from "./h";
import mount from "./mount";
export {
h,
mount
}
③ src/index.js中引入mount()方法并传入虚拟DOM和挂载点对虚拟DOM进行挂载
// src/index.js文件
import { h, mount } from "./vdom"; // 引入h()方法,用于创建虚拟DOM
const oldVnode = h("div", {id: "container"},
h("span", {style: {color: "red"}}, "hello"), // 参数中的函数会先执行
"world"
);
console.log(oldVnode);
// 挂载虚拟DOM
const app = document.getElementById("app");
mount(oldVnode, app);
④ 接下来就是要实现createDOMElementByVnode()方法,将虚拟DOM转换为真实的DOM节点,就可以将其挂载到id为app的元素内了。其转换过程主要为:
- 根据虚拟DOM的tag类型判断,如果tag存在则是元素节点,创建出对应的元素节点;如果tag为undefined则是文本节点,创建出对应的文本节点;
- 然后更新DOM节点上的属性,
- 然后遍历子节点,通过递归调用createDOMElementByVnode()方法,创建出子节点对应的真实DOM节点并添加到父节点内。
// createDOMElementByVnode()方法实现
export function createDOMElementByVnode(vnode) {
// 从虚拟DOM节点中获取到对应的标签类型及其中的子节点
const {tag, children} = vnode;
if (tag) { // 如果虚拟DOM上存在tag,说明是元素节点,需要根据这个tag类型创建出对应的DOM元素节点
// 创建真实DOM元素并保存到虚拟DOM节点上的domNode属性上,方便操作DOM添加属性
vnode.domNode= document.createElement(tag); // 根据虚拟DOM的type创建出对应的DOM节点
// DOM节点创建出来之后,就需要更新DOM节点上的属性了
updateProperties(vnode); // 更新虚拟DOM上的属性,更新节点属性
// DOM节点上的属性更新完成后,就需要更新子节点了
if (Array.isArray(children)) { // 如果有children属性,则遍历子节点,将子节点添加进去,即更新子节点
children.forEach((child) => {
const domNode = createDOMElementByVnode(child); // 递归遍历子节点并继续创建子节点对应的真实DOM元素
vnode.domNode.appendChild(domNode);
});
}
} else { // 如果虚拟DOM上不存在tag,说明是文本节点,直接创建一个文本节点即可
vnode.domNode = document.createTextNode(vnode.text);
}
return vnode.domNode;
}
⑤ 此时已经把真实的DOM节点创建出来了,但是DOM节点上的属性未更新,所以需要实现updateProperties()方法,其更新过程为:
- 更新属性,意味着是在同一个DOM节点上进行操作,即比较同一个节点上属性的变化,由于样式style也是一个对象,所以首先遍历老的样式,如果老的样式在新的样式中不存在了,那么需要操作DOM移除该样式属性
- 接着更新非style属性,同样如果老的属性在新的属性中不存在了,那么需要操作DOM移除该属性
- 移除了不存在的样式和属性后,那么接下来就要更新都存在的样式和属性了(都有该属性,但是值不同)。遍历新属性对象进行一一覆盖旧值即可。
// 传入一个新的虚拟DOM节点,和旧的虚拟DOM的props进行比较并更新
export function updateProperties(newVnode, oldProps = {}) { // 如果未传递旧节点属性,那么将旧节点属性设置空对象
const newProps = newVnode.props; // 取出新虚拟DOM节点上的属性对象
const domNode = newVnode.domNode; // 取出新虚拟DOM上保存的真实DOM节点方便属性更新
// 先处理样式属性, 因为style也是一个对象
const oldStyle = oldProps.style || {};
const newStyle = newProps.style || {};
// 遍历节点属性对象中的style,如果老的样式属性在新的style样式对象里面没有,则需要删除,
// 即新节点上没有该样式了,那么需要删除该样式
for (let oldAttrName in oldStyle) {
if (!newStyle[oldAttrName]) {
domNode.style[oldAttrName] = ""; // 老节点上的样式属性,新节点上已经没有了,则清空真实DOM节点上不存在的老样式属性
}
}
// 再处理非style属性,把老的属性对象中有,新的属性对象中没有的删除
// 即新节点上没有该属性了,就需要删除该属性
for (let oldPropName in oldProps) {
if (!newProps[oldPropName]) {
domNode.removeAttribute(oldPropName); // 老节点上的属性,新节点上已经没有了,那么删除不存在的属性
}
}
// 移除新节点上不存在的样式和属性后,遍历新节点上的属性,并将其更新到节点上
for (let newPropName in newProps) {
if (newPropName === "style") {
let styleObject = newProps.style; // 取出新的样式对象
for (let newAttrName in styleObject) {
domNode.style[newAttrName] = styleObject[newAttrName]; // 更新新老节点上都存在的样式
}
} else {
domNode.setAttribute(newPropName, newProps[newPropName]); // 更新新老节点上都存在的属性
}
}
}
六、实现DOM-DIFF算法
DOM-DIFF算法的核心就是对新旧虚拟DOM节点进行比较,根据新旧虚拟DOM节点是否发生变化来决定是否复用该DOM。为了模拟新旧节点变化,首先我们创建一个旧的虚拟DOM节点并mount出来,然后通过定时器,设置3秒后创建一个新的虚拟DOM节点并进行比较更新。
① 首先在src/vdom目录下新建一个patch.js,里面对外暴露一个patch(oldVnode, newVnode)方法,传入新旧节点进行比较更新,patch方法具体实现后面实现,同样的方式将patch()方法暴露出去,以便src/index.js能够引入这个patch()方法,这里同上不重复了。
// src/vdom/patch.js
// 用于比较新旧虚拟DOM节点并进行相应的更新
function patch(oldVnode, newVnode) {
}
export default patch;
// 更新src/index.js
import { h, mount, patch } from "./vdom"; // 引入h()方法,用于创建虚拟DOM
const oldVnode = h("ul", {id: "container"},
h("li", {style: {background: "red"}, key: "A"}, "A"), // 参数中的函数会先执行
h("li", {style: {background: "green"}, key: "B"}, "B"),
h("li", {style: {background: "blue"}, key: "C"}, "C"),
h("li", {style: {background: "yellow"}, key: "D"}, "D")
);
console.log(oldVnode);
// 挂载虚拟DOM
const app = document.getElementById("app");
mount(oldVnode, app); // 首先将旧虚拟DOM节点mount出来
setTimeout(() => {
const newVnode = h("div", {id: "container"}, "hello world"); // 3秒后创建一个新的虚拟DOM节点
patch(oldVnode, newVnode); // 新建虚拟DOM进行比较并更新
}, 3000);
② 实现patch()方法
patch主要用于比较新旧虚拟DOM节点的变化,根据不同的变化决定是否复用真实DOM,其存在比较多种情况:
- 新旧虚拟DOM节点的tag不一样的情况,由于新旧节点的tag不一样,所以这两个DOM节点肯定无法复用,必须新创建一个DOM节点,替换调用旧的DOM节点。比如上面新的虚拟DOM节点的tag变成了div,而原先是ul。
import {createDOMElementByVnode} from "./mount";
// 用于比较新旧虚拟DOM节点并进行相应的更新
function patch(oldVnode, newVnode) {
// 1. 如果新的虚拟DOM节点类型tag不一样,必须重建DOM
if(oldVnode.tag !== newVnode.tag) {
// 通过旧虚拟DOM的domNode获取到其父节点然后调用createDOMElementByVnode()方法创建出新虚拟DOM节点对应的真实DOM,并替换掉旧节点
oldVnode.domNode.parentNode.replaceChild(createDOMElementByVnode(newVnode), oldVnode.domNode);
}
}
export default patch;
- 新旧虚拟DOM节点的tag一样,但是子节点不一样,子节点不一样,还有三种情况: ① 新旧节点都有子节点;② 旧节点有子节点,新节点没有子节点;③ 旧节点没有子节点,但是新节点有子节点,其中②和③比较简单,主要第①种比较复杂,对于②,直接清空即可,对于③创建出新的子节点挂载上去即可。这里先实现②和③的情况
function patch(oldVnode, newVnode) {
// 1. 如果新的虚拟DOM节点类型tag不一样,必须重建DOM
if(oldVnode.tag !== newVnode.tag) {
// 通过旧虚拟DOM的domNode获取到其父节点然后调用createDOMElementByVnode()方法创建出新虚拟DOM节点对应的真实DOM,并替换掉旧节点
oldVnode.domNode.parentNode.replaceChild(createDOMElementByVnode(newVnode), oldVnode.domNode);
}
// 如果类型一样,则复用当前父元素domElement,要继续往下比较
const domNode = newVnode.domNode = oldVnode.domNode; // 获取到新的或老的真实DOM节点,因为类型一致,所以新旧节点是一样的可以直接复用
// 首先判断是元素节点还是文本节点, 比如比较的是两个文本节点,但是值不同,则直接更新文本节点的值即可
if (typeof newVnode.text !== "undefined") { // 如果新节点是一个文本节点
return oldVnode.domNode.textContent = newVnode.text;
}
// 父节点复用后,传入新的虚拟DOM节点和老的属性对象,更新DOM节点上的属性
updateProperties(newVnode, oldVnode.props);
// 更新子节点
let oldChildren = oldVnode.children; // 老的虚拟DOM节点的子节点数组
let newChildren = newVnode.children; // 新的虚拟DOM节点的子节点数组
if (oldChildren.length > 0 && newChildren.length > 0) { // 如果两个li标签并且都有儿子,那么接着比较两个儿子节点
// 如果新旧节点都有子节点,那么继续比较儿子节点,并进行相应更新
updateChildren(domNode, oldChildren, newChildren);
} else if (oldChildren.length > 0) { // 老节点有子节点,新节点没子节点
domNode.innerHTML = ""; // 直接清空
} else if (newChildren.length > 0) { // 老节点没有子节点,新节点有子节点
for (let i = 0; i < newChildren.length; i++) { // 遍历新节点上的子节点
domNode.appendChild(createDOMElementByVnode(newChildren[i])); // 创建对应的真实DOM并添加进去
}
}
}
③ 实现updateChildren()方法
对于上一步中提到第一种情况,就新旧虚拟DOM节点中都有子节点的情况,那么我们需要进一步比较其子节点,看子节点能否复用,子节点的比较又分为五种情况:
这里先定义一下什么的节点才算是相同的节点?即标签名相同并且key也相同,所以需要在src/vdom/vnode.js
中添加一个isSameNode()方法,传递新旧虚拟DOM节点比较两个节点是否是相同的节点。
// src/vdom/vnode.js中添加一个isSameNode方法并对外暴露
export function isSameNode(oldVnode, newVnode) {
// 如果两个虚拟DOM节点的key一样并且tag一样,说明是同一种节点,可以进行深度比较
return oldVnode.key === newVnode.key && oldVnode.tag === newVnode.tag;
}
- 从新旧节点的头部开始比较,并且头部节点相同,这里以特殊情况为例: 比如ul节点中原先是A一个节点,后面增加了一个B节点变成了A、B,这样新旧节点头部的A是相同节点,可以复用,直接在后面添加一个B节点即可,如:
function updateChildren(parentDomNode, oldChildren, newChildren) {
let oldStartIndex = 0; // 老的虚拟DOM节点子节点开始索引
let oldStartVnode = oldChildren[0]; // 老的虚拟DOM节点开始子节点(第一个子节点)
let oldEndIndex = oldChildren.length - 1; // 老的虚拟DOM节点子节点结束索引
let oldEndVnode = oldChildren[oldEndIndex];// 老的虚拟DOM节点结束子节点(最后一个子节点)
let newStartIndex = 0; // 新的虚拟DOM节点子节点开始索引
let newStartVnode = newChildren[0]; // 新的虚拟DOM节点开始子节点(第一个子节点)
let newEndIndex = newChildren.length - 1; // 新的虚拟DOM节点子节点结束索引
let newEndVnode = newChildren[newEndIndex];// 新的虚拟DOM节点结束子节点(最后一个子节点)
// 每次比较新旧虚拟DOM节点的开始索引或者结束索引都会进行向前或向后移动,每比较一次,新旧节点都会少一个,直到有一个队列比较完成才停止比较
while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
if(isSameNode(oldStartVnode, newStartVnode)) { // 旧节点的第一个子节点和新节点的第一个子节点相同,即头部相同,可以复用
patch(oldStartVnode, newStartVnode); // 更新可复用的两个队列的头部节点的属性及其子节点
// 第一次新旧节点头部比较完成后,头部索引需要往后移,更新新旧节点的头部节点位置
oldStartVnode = oldChildren[++oldStartIndex];
newStartVnode = newChildren[++newStartIndex];
}
}
// 由于子节点数量不一样,所以循环结束后,可能有一个队列会多出一些还未比较的节点
// 如果旧节点的子节点比新节点的子节点数量少,那么新节点则会有剩余节点未比较完成
if (newStartIndex <= newEndIndex) { // 老的队列处理完了,新的队列没有处理完
for (let i = newStartIndex; i <= newEndIndex; i++) { // 遍历新队列中多出的未比较的节点,这些节点肯定无法复用,必须创建真实的DOM并插入到队列后面
// newEndIndex是会发生变化移动的,根据此时newEndIndex的值,将多出的节点插入到newEndIndex的后面或者说是newEndIndex + 1的前面
const beforeDOMNode = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].domNode;
parentDomNode.insertBefore(createDOMElementByVnode(newChildren[i]), beforeDOMNode);
// 为了通用可以用insertBefore代替appendChild,insertBefore第二参数为null就是在末尾插入,不为null则是在当前元素前插入
// parentDomNode.appendChild(createDOMElementByVnode(newChildren[i]));
}
}
// 如果旧节点的子节点比新节点的子节点数量多,那么旧节点则会有剩余节点未比较完成
if (oldStartIndex <= oldEndIndex) { // 新的队列处理完了,旧的队列还没有处理完
for (let i = oldStartIndex; i <= oldEndIndex; i++) { // 遍历旧队列中多出的未比较的节点,并移除
parentDomNode.removeChild(oldChildren[i].domNode);
}
}
}
const oldVnode = h("ul", {id: "container"},
h("li", {style: {background: "red"}, key: "A"}, "A")
);
const newVnode = h("ul", {id: "container"},
h("li", {style: {background: "red"}, key: "A"}, "A1"), // 参数中的函数会先执行
h("li", {style: {background: "green"}, key: "B"}, "B")
);
其比较过程就是: ① 旧节点与新节点的第一个子节点进行比较,由于key都为A,所以是相同的节点,直接调用patch()方法进行属性更新,即将A更新为A1 ② 新旧节点的头部索引都加1,向后移,此时旧节点的所有子节点都比较完成了,所以退出while循环 ③ 但是新节点中还有一个B节点未比较,所以遍历多出的未比较的子节点,转换成真实的DOM节点并追加到队列末尾,即可完成A 到 A B的更新,此时A被复用了。
- 从新旧节点的尾部开始比较,并且尾部节点相同,这里以特殊情况为例: 比如ul节点中原先是A一个节点,前面增加了一个B节点变成了B、A,这样新旧节点尾部的A是相同节点,可以复用,直接在前面添加一个B节点即可,如:
// 更新while循环,添加一个else if即可
while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
if(isSameNode(oldStartVnode, newStartVnode)) { // 旧节点的第一个子节点和新节点的第一个子节点相同,即头部相同,可以复用
console.log("头部相同");
} else if (isSameNode(oldEndVnode, newEndVnode)) { // 旧节点的最后一个子节点和新节点的最后一个子节点相同,即尾部相同,可以复用
patch(oldEndVnode, newEndVnode); // 更新可复用的两个队列的尾部节点的属性及其子节点
// 第一次新旧节点尾部比较完成后,尾部索引需要往前移,更新新旧节点的尾部节点位置
oldEndVnode = oldChildren[--oldEndIndex];
newEndVnode = newChildren[--newEndIndex];
}
}
const oldVnode = h("ul", {id: "container"},
h("li", {style: {background: "red"}, key: "A"}, "A")
);
const newVnode = h("ul", {id: "container"},
h("li", {style: {background: "green"}, key: "B"}, "B"),
h("li", {style: {background: "red"}, key: "A"}, "A1"), // 参数中的函数会先执行
);
其比较过程就是: ① 旧节点与新节点的最后一个子节点进行比较,由于key都为A,所以是相同的节点,直接调用patch()方法进行属性更新,即将A更新为A1 ② 新旧节点的尾部索引都减1,向前移,此时旧节点的所有子节点都比较完成了,所以退出while循环 ③ 但是新节点中还有一个B节点未比较,所以遍历多出的未比较的子节点,转换成真实的DOM节点并追加到队列末尾,即可完成A 到 B A的更新,此时A被复用了。
- 让旧节点的尾部与新节点的头部进行交叉比较,并且尾头节点相同,这里以特殊情况为例: 比如ul节点中原先是A、B、C三个节点,新节点变成了C 、A 、B,这样旧节点尾部与新节点的头部都是C,是相同节点,可以复用,如:
while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
if(isSameNode(oldStartVnode, newStartVnode)) { // 旧节点的第一个子节点和新节点的第一个子节点相同,即头部相同,可以复用
console.log("头部相同");
} else if (isSameNode(oldEndVnode, newEndVnode)) { // 旧节点的最后一个子节点和新节点的最后一个子节点相同,即尾部相同,可以复用
console.log("尾部相同");
} else if (isSameNode(oldEndVnode, newStartVnode)) { // 旧节点的最后一个子节点和新节点的第一个子节点相同,即尾头相同,尾部节点可以复用
console.log("尾头相同");
patch(oldEndVnode, newStartVnode); // 更新可复用的两个队列的尾头部节点的属性及其子节点
// 尾部节点可以复用,所以需要将旧节点的尾部移动到头部
parentDomNode.insertBefore(oldEndVnode.domNode, oldStartVnode.domNode);
// 旧节点的尾部移动到头部后,相当于旧节点的尾部已经比较过了,旧节点的尾部节点位置需要更新,旧节点的尾部索引向前移
oldEndVnode = oldChildren[--oldEndIndex];
// 旧节点的尾部移动到头部后,相当于新节点的头部已经比较过了,新节点的头部节点位置需要更新,下一次比较的是新节点原来头部的下一个位置
newStartVnode = newChildren[++newStartIndex];
}
}
const oldVnode = h("ul", {id: "container"},
h("li", {style: {background: "red"}, key: "A"}, "A"),
h("li", {style: {background: "green"}, key: "B"}, "B"),
h("li", {style: {background: "blue"}, key: "C"}, "C")
);
const newVnode = h("ul", {id: "container"},
h("li", {style: {background: "blue"}, key: "C"}, "C1"),
h("li", {style: {background: "red"}, key: "A"}, "A1"), // 参数中的函数会先执行
h("li", {style: {background: "green"}, key: "B"}, "B1")
);
其比较过程就是: ① 旧节点的最后一个子节点与新节点的第一个子节点进行比较,由于key都为C,所以是相同的节点,直接调用patch()方法进行属性更新,即将C更新为C1,并且将C移动到头部 ② 旧节点的尾部索引减1,向前移,新节点的头部索引加1往后移,继续while循环,此时新旧节点都剩下 A、B,又开始检测头部是否相同,头部都为A,故相同,此时将 A更新为A1 ③ 此时新旧节点都剩下B,又开始检测头部是否相同,头部都为B,故相同,此时将B更新为B1④此时新旧队列都已经比较完成,退出while循环,即可完成A B C 到 C A B的更新,此时A、B、C都被复用了。
- 让旧节点的头部与新节点的尾部进行交叉比较,并且头尾节点相同,这里以特殊情况为例: 比如ul节点中原先是A、B、C三个节点,新节点变成了B 、C 、A,这样旧节点头部与新节点的尾部都是A,是相同节点,可以复用,如:
while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
if(isSameNode(oldStartVnode, newStartVnode)) { // 旧节点的第一个子节点和新节点的第一个子节点相同,即头部相同,可以复用
console.log("头部相同");
} else if (isSameNode(oldEndVnode, newEndVnode)) { // 旧节点的最后一个子节点和新节点的最后一个子节点相同,即尾部相同,可以复用
console.log("尾部相同");
} else if (isSameNode(oldEndVnode, newStartVnode)) { // 旧节点的最后一个子节点和新节点的第一个子节点相同,即尾头相同,尾部节点可以复用
console.log("尾头相同");
} else if (isSameNode(oldStartVnode, newEndVnode)) { // 旧节点的第一个子节点和新节点的最后一个子节点相同,即头尾相同,头部节点可以复用
console.log("头尾相同");
patch(oldStartVnode, newEndVnode); // 更新可复用的两个队列的头尾部节点的属性及其子节点
// 头部节点可以复用,所以需要将旧节点的头部移动到尾部
parentDomNode.insertBefore(oldStartVnode.domNode, oldEndVnode.domNode.nextSibling);
// 旧节点的头部移动到尾部后,相当于旧节点的头部已经比较过了,旧节点的头部节点位置需要更新,旧节点的头部索引向后移
oldStartVnode = oldChildren[++oldStartIndex];
// 旧节点的头部移动到尾部后,相当于新节点的尾部已经比较过了,新节点的尾部节点位置需要更新,新节点的尾部索引向前移
newEndVnode = newChildren[--newEndIndex];
}
}
const oldVnode = h("ul", {id: "container"},
h("li", {style: {background: "red"}, key: "A"}, "A"),
h("li", {style: {background: "green"}, key: "B"}, "B"),
h("li", {style: {background: "blue"}, key: "C"}, "C")
);
const newVnode = h("ul", {id: "container"},
h("li", {style: {background: "green"}, key: "B"}, "B1"),
h("li", {style: {background: "blue"}, key: "C"}, "C1"),
h("li", {style: {background: "red"}, key: "A"}, "A1"), // 参数中的函数会先执行
);
其比较过程就是: ① 旧节点的第一个子节点与旧节点的最后一个子节点进行比较,由于key都为A,所以是相同的节点,直接调用patch()方法进行属性更新,即将A更新为A1,并且将A移动到尾部 ② 旧节点的头部索引加1,向后移,新节点的尾部索引减1往前移,继续while循环,此时新旧节点都剩下 B、C,又开始检测头部是否相同,头部都为B,故相同,此时将 B更新为B1 ③ 此时新旧节点都剩下C,又开始检测头部是否相同,头部都为C,故相同,此时将C更新为C1④此时新旧队列都已经比较完成,退出while循环,即可完成A B C 到 B C A的更新,此时A、B、C都被复用了。
- 最后还有一种就是,头头、尾尾、头尾、尾头都无法找到相同的,那么就是顺序错乱的比较,此时需要先把旧节点中所有key和index的对应关系生成一个map映射关系,即通过key可以找到其位置,首先从新节点的第一个子节点开始比较,然后根据第一个节点的key值从map映射中查找对应的索引,如果找不到对应的索引,说明是新节点,无法复用,此时直接创建DOM并插入到头部即可,如果找到了对应的索引,key相同tag不一定相同,此时再比较一下对应的tag是否相同,如果tag不相同,那么也无法复用,也是直接创建DOM并插入到头部,如果tag相同,那么可以复用,更新这两个节点,同时将找到的节点清空,然后将找到的节点插入到旧节点中的头部索引节点前面,这里以特殊情况为例: 比如ul节点中原先是A、B、C三个节点,新节点变成了D、B 、A 、C、E,这样以上四种情况都无法匹配,如:
// 添加一个createKeyToIndexMap方法
// 生成key和index索引的对应关系
function createKeyToIndexMap(children) {
let map = {};
for (let i = 0; i< children.length; i++) {
let key = children[i].key;
if (key) {
map[key] = i;
}
}
return map;
}
const oldKeyToIndexMap = createKeyToIndexMap(oldChildren); // 生成对应的key和index映射关系
while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
// 进行顺序错乱比较后,会清空找到的节点,为不影响前面四种情况比较, 如果节点被清空了,需要进行相应的移动
if (!oldStartVnode) { // 如果旧的start节点被清空了,则旧的头部索引往后移,更新头部节点
oldStartVnode = oldChildren[++oldStartIndex];
} else if (!oldEndVnode) { // 如果旧的End节点被清空了,则旧的尾部索引往前移,更新尾部节点
oldEndVnode = oldChildren[--oldEndIndex];
} else if(isSameNode(oldStartVnode, newStartVnode)) { // 旧节点的第一个子节点和新节点的第一个子节点相同,即头部相同,可以复用
console.log("头部相同");
} else if (isSameNode(oldEndVnode, newEndVnode)) { // 旧节点的最后一个子节点和新节点的最后一个子节点相同,即尾部相同,可以复用
console.log("尾部相同");
} else if (isSameNode(oldEndVnode, newStartVnode)) { // 旧节点的最后一个子节点和新节点的第一个子节点相同,即尾头相同,尾部节点可以复用
console.log("尾头相同");
} else if (isSameNode(oldStartVnode, newEndVnode)) { // 旧节点的第一个子节点和新节点的最后一个子节点相同,即头尾相同,头部节点可以复用
console.log("头尾相同");
} else { // 顺序错乱比较
console.log("顺序错乱");
let oldIndexByKey = oldKeyToIndexMap[newStartVnode.key]; // 传入新节点的第一个子节点的key,获取到对应的索引
if (oldIndexByKey == null) { // 如果索引为null,那么表示这是一个新的节点,无法复用,直接创建并插入到旧节点中当前头部的前面
parentDomNode.insertBefore(createDOMElementByVnode(newStartVnode), oldStartVnode.domNode);
} else { // 如果索引不为null,则找到了相同key的节点
const oldVnodeToMove = oldChildren[oldIndexByKey]; // 获取到旧节点中具有相同key的节点
if (oldVnodeToMove.tag !== newStartVnode.tag) { // key相同但是类型不同,也要创建一个新的DOM,并插入到旧节点中当前头部的前面
parentDomNode.insertBefore(createDOMElementByVnode(newStartVnode), oldStartVnode.domNode);
} else { // 找到了相同key和tag都相同的元素,则可用复用
patch(oldVnodeToMove, newStartVnode); // 更新找到节点
oldChildren[oldIndexByKey] = undefined; // 将旧节点中找到的元素设为undefined,清除找到节点
// 将找到的元素插入到oldStartVnode前面
parentDomNode.insertBefore(oldVnodeToMove.domNode, oldStartVnode.domNode);
}
}
newStartVnode = newChildren[++newStartIndex]; // 比较新节点中的下一个子节点
}
}
const oldVnode = h("ul", {id: "container"},
h("li", {style: {background: "red"}, key: "A"}, "A"),
h("li", {style: {background: "green"}, key: "B"}, "B"),
h("li", {style: {background: "blue"}, key: "C"}, "C")
);
const newVnode = h("ul", {id: "container"},
h("li", {style: {background: "yellow"}, key: "D"}, "D"), // 参数中的函数会先执行
h("li", {style: {background: "green"}, key: "B"}, "B1"),
h("li", {style: {background: "red"}, key: "A"}, "A1"),
h("li", {style: {background: "blue"}, key: "C"}, "C1"),
h("li", {style: {background: "green"}, key: "E"}, "E"),
);
其比较过程就是: ① 由于以上四种情况都不符合,故进行顺序错乱比较,首先调用createKeyToIndexMap方法拿到key和index的对应关系
② 从新节点的第一个子节点开始比较,即D,此时传入其key为D,到oldKeyToIndexMap映射对象中进行查找,肯定找不到,为null,故不可复用,需要创建一个新节点并插入到头部 ③ 此时旧节点中剩下A、B、C,
新节点中剩下B、A、C、E,仍然不匹配以上四种情况,再次进行顺序错乱比较,比较B,此时可以在oldKeyToIndexMap映射对象中找到对应的索引为1,然后将B更新为B1,然后清空旧节点中的B,旧节点当前的头部索引为0,索引插入到A的前面 ④ 此时旧节点中剩下A undefined C,新节点中剩下A C E,此时符合头部相同的情况,直接将A更新为A1,旧节点中头部索引往后移,变为undefined,新节点头部索引也往后移,变为C ⑤此时旧节点中剩下 undefined C,新节点中剩下 C E,再次进入while循环,由于旧节点的头部节点变为了undefined,故旧节点头部索引往后移动,头部节点变为了C ⑥此时旧节点中剩下C,新节点中还是剩下C E,此时符合头部相同,将C更新为C1即可 ⑦此时旧节点已经比较完成,新节点中剩下一个E,直接遍历E并创建DOM插入到末尾即可,此时完成了 A B C 到 D B A C E的更新。
七、总结
虚拟DOM就是用JavaScript对象来描述真实的DOM节点,主要包括标签名、属性集对象、子节点数组、节点类型、节点唯一标识key、文本节点内容text、对应的真实DOM引用。而DOM-DIFF算法则是,通过新旧节点子节点的头头、尾尾、头尾、尾头、key查找五种方式进行匹配,找到key相同的虚拟DOM节点,然后再根据虚拟DOM的tag判断该节点是否可以复用,如果tag也相同,那么可以复用,则进行差异化更新DOM节点属性即可,如果tag不同,那么也不能复用,则需要创建一个新的DOM节点并挂载上去,从而实现尽可能的复用DOM。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。