實現一個簡單的DOM diff演算法

codeXiu發表於2019-06-05

上一篇文章,我說到了如何實現一個簡單的虛擬DOM,這一篇文章是接著上一篇文章的知識點的。

我們都知道虛擬DOM其實就是JS物件,我們用JS來操作物件比操作DOM效能要好得多

我們為什麼還需要diff演算法?

  • 因為如果我們有一個很龐大的DOM Tree,我們要對它進行更新操作,如果我們只是更新了它很小的一部分,我們就需要更新整個DOM Tree。這也是很浪費效能和資源的。因為有好多無用的更新。所以我們才需要diff演算法來剔除掉無用的更新

我先來簡單的概括一下diff演算法。

  1. 它遵循先序深度優先遍歷的規則。
  2. 同級比較。
  3. diff只是找到差異,找到了差異我們還有修補差異,就是打補丁(patch)。
  4. 我們diff的過程其實就是對比兩個虛擬DOM的過程。通過對比找到patch(差異)。
  5. 然後再把patch打到真實的DOM上(打補丁)。
  • 我們先來說一下先序深度優先遍歷,我覺得學過資料結構的人對它都不是很陌生,這是一種對樹的遍歷方法。(先序、中序、後序,廣度優先、深度優先)。 先序遍歷就是先遍歷根節點在遍歷左邊的孩子節點然後是右邊的孩子節點(根→左→右)。
    實現一個簡單的DOM diff演算法

diff的實現過程

  • 首先準備好我們需要的變數: 我們需要一個補丁物件和一個全域性的位置索引(遍歷的順序)
let patches = {};
let index = 0;
複製程式碼
  • 兩個虛擬DOM的不同主要有
    • 文字的不同("a" → "bb")
    • 屬性的不同(class: .a → .b)
    • 刪除
    • 替換

所以我們需要建立四個識別符號。

const ATTR = 0; // 屬性
const TEXT = 1; // 文字
const REMOVE = 2; // 刪除
const REPLACE = 3; // 替換
複製程式碼
  1. 先建立兩個不同的虛擬DOM。
let vDom1 = createElement("div", {class: "div"}, [
            	createElement("div", {class: "div"}, ["a"])
            ]);
let vDom2 = createElement("div", {class: "div1"}, [
                createElement("div", {class: "div2"}, ["b"])
            ]);

複製程式碼

實現一個簡單的DOM diff演算法
實現一個簡單的DOM diff演算法
2. 然後比較兩個虛擬DOM的差異(diff過程) diff(vDom1, vDom2)

function diff(oldTree, newTree){
    walk(oldTree, newTree, index); // 遍歷兩個虛擬DOM樹
}
複製程式碼
  1. 遍歷的過程
  • 文字的不同
// 我這裡使用了不是很準確的比較(可以使用toString.call)
// 如果都是文字
// patch是補丁物件
// 資料描述: {type: 不同的型別,不同的地方}
if((typeof oldNode === "string") && (typeof newNode === "string")){
	// 如果文字內容不一樣
	if(newNode !== oldNode){
		patch.push({type: TEXT, text: newNode});
	}
}
複製程式碼
  • 屬性的不同
// 如果型別相同就比較屬性, 型別不相同預設換掉了整個元素
if(oldNode.type === newNode.type){
    // 遍歷新老節點屬性的不同
    let attr = diffAttr(oldNode.props, newNode.props);
    // 如果有不同, 就加入patch中
    if (Object.keys(attr).length > 0) {
        patch.push({ type: ATTR, attr });
    }
    // 遍歷子節點
    diffChildren(oldNode.children, newNode.children);
}
複製程式碼

遍歷屬性

function diffAttr(oldAttr, newAttr){
    let attr = {};
    // 看兩個屬性是否不同(修改)
    for (key in oldAttr) {
        	if(oldAttr[key] != newAttr[key]){
        	    attr[key] = newAttr[key];
        	}
	}
	// 是否新增
	for (key in newAttr) {
        	if(!oldAttr.hasOwnProperty(key)){
        	    attr[key] = newAttr[key];
        	}
	}
    return attr;
}
複製程式碼

遍歷子節點的屬性

function diffChildren(oldChildren, newChildren){
    oldChildren.forEach(function(child, i){
        // 子節點遞迴遍歷屬性
    	walk(child, newChildren[i], ++ index);
    });
}
複製程式碼
  • 刪除
// 如果沒有新節點,說明刪除了,標記處刪除的索引
if(!newNode){
    patch.push({type: REMOVE, index});
}
複製程式碼
  • 替換
// 其餘情況為替換
patch.push({type: REPLACE, newNode});
複製程式碼
  • 整體程式碼
let patches = {};
let index = 0;

const ATTR = 0;
const TEXT = 1;
const REMOVE = 2;
const REPLACE = 3;

function diff(oldTree, newTree){
    walk(oldTree, newTree, index);
}

function walk(oldNode, newNode, index){
    let patch = [];
    // 刪除
    if(!newNode){
    	patch.push({type: REMOVE, index});
    // 文字
    }else if((typeof oldNode === "string") && (typeof newNode === "string")){
    	if(newNode !== oldNode){
    	    patch.push({type: TEXT, text: newNode});
    	}
    }else if(oldNode.type === newNode.type){
        // 屬性
    	let attr = diffAttr(oldNode.props, newNode.props);
    	 if (Object.keys(attr).length > 0) {
            patch.push({ type: ATTR, attr });
        }
    	diffChildren(oldNode.children, newNode.children);
    }else {
        // 替換
    	patch.push({type: REPLACE, newNode});
    }
    if(patch.length > 0){
    	patches[index] = patch;
    }
}
// 比較屬性的不同
function diffAttr(oldAttr, newAttr){
    let attr = {};
    // 看兩個屬性是否不同(修改)
    for (key in oldAttr) {
        	if(oldAttr[key] != newAttr[key]){
        	    attr[key] = newAttr[key];
        	}
	}
	// 是否新增
	for (key in newAttr) {
        	if(!oldAttr.hasOwnProperty(key)){
        	    attr[key] = newAttr[key];
        	}
	}
    return attr;
}
// 比較子節點的屬性
function diffChildren(oldChildren, newChildren){
    oldChildren.forEach(function(child, i){
        walk(child, newChildren[i], ++ index);
    });
}
複製程式碼

我們檢視一下補丁物件

實現一個簡單的DOM diff演算法

打補丁(patch)

首先建立一個索引物件let patchIndex = 0;

  1. 將補丁物件和真實的DOM作比較patch(dom, patches)
function patch(dom, patches){
    walkPatch(dom);
}
複製程式碼

遍歷補丁的實現

function walkPatch(dom){
    // 獲取當前節點的補丁
    let patch = patches[patchIndex ++];
    // 獲取子節點
    let children = dom.childNodes;
    // 遍歷子節點
    // 遍歷到最後一個元素,從後往前打補丁
    children.forEach((child)=>walkPatch(child));
    // 如果有補丁,就打補丁
    if(patch){
    	doPatch(dom, patch);
    }
}
複製程式碼
  • 打補丁的實現過程
    • 屬性
    // 遍歷屬性
    // key 就是 class或者value(這個value是屬性)
    // value 就是 類名或者是值
    for (key in p.attr) {
        let value = p.attr[key];
        // 如果有值(其實就是上一篇虛擬DOM中的設定屬性)
        if(value){
            if(key === "value"){
    	    if(node.type.toUpperCase() === "INPUT" || node.type.toUpperCase() === "TEXTAREA"){
    	        node.value = value;
    	    }
    	}else {
    		node.setAttribute(key, value);
    	}
    	// 沒有值,就是刪除屬性
        }else {
            node.removeAttribute(key);
        }
    }
    複製程式碼
    • 文字
    // 替換文字節點
    node.textContent = p.text;
    複製程式碼
    • 刪除
    // 刪除自己
    node.parentNode.removeChild(node);
    複製程式碼
    • 替換
    let { newNode } = p;
    // 如果是元素就建立元素否則就是文字
    newNode = (newNode instanceof Element) ?  createDom(newNode): document.createTextNode(newNode);
    // 用新節點替換舊結點
    newNode.parentNode.replaceChild(newNode, node);
    複製程式碼

整體程式碼

function doPatch(node, patch){
    patch.forEach((p)=>{
        switch (p.type) {
            case ATTR:
                // 遍歷屬性
            	for (key in p.attr) {
                    let value = p.attr[key];
                    // 如果有值(其實就是上一篇虛擬DOM中的設定屬性)
                    if(value){
                        if(key === "value"){
                	    if(node.type.toUpperCase() === "INPUT" || node.type.toUpperCase() === "TEXTAREA"){
                	        node.value = value;
                	    }
                	}else {
                		node.setAttribute(key, value);
                	}
                	// 沒有值,就是刪除屬性
                    }else {
                        node.removeAttribute(key);
                    }
            	}
            	break;
        	case TEXT:
        	    // 替換文字節點
        	    node.textContent = p.text;
        	    break;
        	case REMOVE:
        	    // 刪除自己
        	    node.parentNode.removeChild(node);
        	    break;
        	case REPLACE:
        	    let { newNode } = p;
        	    // 如果是元素就建立元素否則就是文字
        	    newNode = (newNode instanceof Element) ?  createDom(newNode): document.createTextNode(newNode);
        	    // 用新節點替換舊結點
        	    newNode.parentNode.replaceChild(newNode, node);
        	    break;
        	default:
        	    break;
        }
    })
}
複製程式碼

未打補丁的DOM樹

實現一個簡單的DOM diff演算法
打完補丁的DOM樹
實現一個簡單的DOM diff演算法
我們用一個相對複雜一點的例子來驗證

let vDom1 = createElement("div", {class: "div"}, [
            	createElement("div", {class: "div"}, ["a"]),
            	createElement("div", {}, ["b"]),
            	createElement("div", {class: "div"}, [
        		    createElement("div", {class: "div"}, ["c"]),
        		    createElement("div", {class: "div"}, ["d"])
            	])
            ]);
let vDom2 = createElement("div", {class: "div1"}, [
    	        createElement("div", {class: "div2"}, ["1"]),
    	        createElement("div", {class: "div3"}, ["2"]),
    	        createElement("div", {}, [
    	            createElement("div", {class: "div5"}, ["3"]),
    		        createElement("div", {class: "div6"}, ["4"])
    	        ])
            ]);
複製程式碼

打補丁以前的DOM樹

實現一個簡單的DOM diff演算法
打補丁之後的DOM樹
實現一個簡單的DOM diff演算法

  • 總結
    • 其實我只是實現了一個很簡單的diff演算法,還有好多情況沒有考慮和實現。比如新增還有兩個節點交換了位置,以及不是同級的比較。
    • 其實我覺得這就是一種思想,重點在於我們不僅會使用它還有學會了解他並慢慢的掌握它。
    • 上邊的屬性,其實沒有包含style的實現。

相關文章