所谓虚拟DOM,就是 用JavaScript对象的方式去描述真实DOM。因为真实DOM的建立、修改、删除会形成页面的重排和重绘, 频繁操做真实DOM会影响页面的性能,页面中会有数据、样式的更新, 操做真实DOM是不可避免的,而虚拟DOM的产生是为了 最大限度的减小对真实DOM的操做,由于虚拟DOM能够 将真实DOM操做映射为JavaScript对象操做,尽可能复用真实的DOM。
好比如下一段HTML代码,咱们能够看到这是一个div元素节点,这个div元素节点上有一个属性id,值为container,而且这个div元素节点有两个子节点,一个子节点是span元素节点,span元素节点有style属性,属性值为color: red,span元素节点内也有一个子节点,hello文本节点;另外一个子节点是world文本节点
<div id="container"> hello world </div>
// 对应的JavaScript对象描述为html
{ _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启动项目,如:node
// 修改package.json 文件的scripts部分webpack
{ "scripts": { "build": "webpack --mode=development", "dev": "webpack-dev-server --mode=development --contentBase=./dist" } }
③ 在项目根目录下新建一个src目录,而后在src目录下,新建一个index.js文件,webpack默认入口文件为src目录下的index.js,默认输出目录为 项目根目录下的dist目录web
// index.js文件初始化内容算法
console.log("hello virtual dom-diff.");
④ 首先执行npm run bulid打包输出,会在项目根目录下生成一个dist目录,并在dist目录下打包输出一个main.js,而后在dist目录下,新建一个index.html文件,其引入打包输出后的main.js,如:npm
// dist/index.html文件内容json
<!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本质就是一个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()方法app
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节点转换为真实的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的元素内了。其转换过程主要为:
// 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节点,和旧的虚拟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节点进行比较, 根据新旧虚拟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,其存在比较多种状况:
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;
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; }
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被复用了。
// 更新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被复用了。
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都被复用了。
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都被复用了。
// 添加一个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。