詳解虛擬DOM並實現DOM-DIFF算法

一、虛擬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啓動項目,所以需要安裝webpackwebpack-cliwebpack-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.jsvnode.jsh.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[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節點並追加到隊列末尾,即可完成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的更新。

至此已經完成了DOM-DIFF算法。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章