详解虚拟DOM并实现DOM-DIFF算法

1、虚拟DOM简介

所谓虚拟DOM,就是 用JavaScript对象的方式去描述真实DOM。因为真实DOM的建立、修改、删除会形成页面的重排和重绘, 频繁操做真实DOM会影响页面的性能,页面中会有数据、样式的更新, 操做真实DOM是不可避免的,而虚拟DOM的产生是为了 最大限度的减小对真实DOM的操做,由于虚拟DOM能够 将真实DOM操做映射为JavaScript对象操做,尽可能复用真实的DOM

2、虚拟DOM如何描述真实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
}

3、项目初始化

本项目须要经过webpack进行打包、并经过webpack-dev-server启动项目,因此须要安装 webpackwebpack-cliwebpack-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.表示项目初始化成功。数组

4、建立虚拟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.jsvnode.jsh.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);

5、将虚拟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]); // 更新新老节点上都存在的属性
        }
    }
}

6、实现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节点并追加到队列末尾,便可完成 AB 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 CC 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 CB 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的更新。

7、总结

虚拟DOM就是 用JavaScript对象来描述真实的DOM节点,主要包括 标签名属性集对象子节点数组节点类型节点惟一标识key文本节点内容text对应的真实DOM引用。而DOM-DIFF算法则是,经过新旧节点子节点的 头头尾尾头尾尾头key查找五种方式进行匹配, 找到key相同的虚拟DOM节点,而后 再根据虚拟DOM的tag判断该节点是否能够复用,若是 tag也相同,那么能够复用,则进行 差别化更新DOM节点属性便可,若是 tag不一样,那么也不能复用,则须要 建立一个新的DOM节点并挂载上去,从而实现尽量的复用DOM。
相关文章
相关标签/搜索