讓虛擬DOM和DOM-diff再也不成爲你的絆腳石

Keep Moving

時至今日,前端對於知識的考量是愈來愈有水平了,逼格高大上了css

各種框架你們已經能夠說不管是工做仍是平常中都已經或多或少的使用過了前端

曾經據說不少人被問到過虛擬DOM和DOM-diff算法是如何實現的,有沒有研究過?vue

想必問出此問題的也是高手高手之高高手了,不少人都半開玩笑的說:「面試造航母,工做擰螺絲」node

那麼,話很少說了,今天就讓咱們也來一塊兒研究研究這個東東react

好飯不怕晚,沉澱下來收收心!咱們雖然走的慢,可是卻從未停下腳步git

神奇的虛擬DOM

首先神奇不神奇的咱們先不去關注,先來簡單說說何爲虛擬DOMgithub

虛擬DOM簡而言之就是,用JS去按照DOM結構來實現的樹形結構對象,你也能夠叫作DOM對象面試

好了,一句話就把這麼偉大的東西給解釋了,那麼再也不耽誤時間了,趕忙進入主環節吧算法

固然,這裏還有整個項目的地址方便查看npm

實現一下虛擬DOM

在親自上陣以前,咱們讓糧草先行,先發個圖,來看一下整個目錄結構是什麼樣子的

這個目錄結構是用 create-react-app腳手架直接生成的,也是爲了方便編譯調試

// 全局安裝
npm i create-react-app -g
// 生成項目
create-react-app dom-diff
// 進入項目目錄
cd dom-diff
// 編譯
npm run start
複製代碼

如今咱們開始正式寫吧,從建立虛擬DOM及渲染DOM起步吧

建立虛擬DOM

在element.js文件中要實現如何建立虛擬DOM以及將建立出來的虛擬DOM渲染成真實的DOM

首先實現一下如何建立虛擬DOM,看代碼:

// element.js

// 虛擬DOM元素的類,構建實例對象,用來描述DOM
class Element {
    constructor(type, props, children) {
        this.type = type;
        this.props = props;
        this.children = children;
    }
}
// 建立虛擬DOM,返回虛擬節點(object)
function createElement(type, props, children) {
    return new Element(type, props, children);
}

export {
    Element,
    createElement
}
複製代碼

寫好了方法,咱們就從index.js文件入手來看看是否成功吧

調用createElement方法

在主入口文件裏,咱們主要作的操做就是來建立一個DOM對象,渲染DOM以及經過diff後去打補丁更新DOM,不囉嗦了,直接看代碼:

// index.js

// 首先引入對應的方法來建立虛擬DOM
import { createElement } from './element';

let virtualDom = createElement('ul', {class: 'list'}, [
    createElement('li', {class: 'item'}, ['周杰倫']),
    createElement('li', {class: 'item'}, ['林俊杰']),
    createElement('li', {class: 'item'}, ['王力宏'])
]);

console.log(virtualDom);
複製代碼

createElement方法也是vue和react用來建立虛擬DOM的方法,咱們也叫這個名字,方便記憶。接收三個參數,分別是typepropschildren

  • 參數分析
    • type: 指定元素的標籤類型,如'li', 'div', 'a'等
    • props: 表示指定元素身上的屬性,如class, style, 自定義屬性等
    • children: 表示指定元素是否有子節點,參數以數組的形式傳入

下面來看一下打印出來的虛擬DOM,以下圖

到目前爲止,已經垂手可得的實現了建立虛擬DOM。那麼,接下來進行下一步,將其渲染爲真實的DOM,別猶豫,繼續回到element.js文件中

渲染虛擬DOM

// element.js

class Element {
    // 省略
}

function createElement() {
    // 省略
}

// render方法能夠將虛擬DOM轉化成真實DOM
function render(domObj) {
    // 根據type類型來建立對應的元素
    let el = document.createElement(domObj.type);
    
    // 再去遍歷props屬性對象,而後給建立的元素el設置屬性
    for (let key in domObj.props) {
        // 設置屬性的方法
        setAttr(el, key, domObj.props[key]);
    }
    
    // 遍歷子節點
    // 若是是虛擬DOM,就繼續遞歸渲染
    // 不是就表明是文本節點,直接建立
    domObj.children.forEach(child => {
        child = (child instanceof Element) ? render(child) : document.createTextNode(child);
        // 添加到對應元素內
        el.appendChild(child);
    });

    return el;
}

// 設置屬性
function setAttr(node, key, value) {
    switch(key) {
        case 'value':
            // node是一個input或者textarea就直接設置其value便可
            if (node.tagName.toLowerCase() === 'input' ||
                node.tagName.toLowerCase() === 'textarea') {
                node.value = value;
            } else {
                node.setAttribute(key, value);
            }
            break;
        case 'style':
            // 直接賦值行內樣式
            node.style.cssText = value;
            break;
        default:
            node.setAttribute(key, value);
            break;
    }
}

// 將元素插入到頁面內
function renderDom(el, target) {
    target.appendChild(el);
}

export {
    Element,
    createElement,
    render,
    setAttr,
    renderDom
};
複製代碼

既然寫完了,那就趕快來看當作果吧

調用render方法

再次回到index.js文件中,修改成以下代碼

// index.js

// 引入createElement、render和renderDom方法
import { createElement, render, renderDom } from './element';

let virtualDom = createElement('ul', {class: 'list'}, [
    createElement('li', {class: 'item'}, ['周杰倫']),
    createElement('li', {class: 'item'}, ['林俊杰']),
    createElement('li', {class: 'item'}, ['王力宏'])
]);

console.log(virtualDom);

// +++
let el = render(virtualDom); // 渲染虛擬DOM獲得真實的DOM結構
console.log(el);
// 直接將DOM添加到頁面內
renderDom(el, document.getElementById('root'));
// +++
複製代碼

經過調用render方法轉爲真實DOM,並調用renderDom方法直接將DOM添加到了頁面內

下圖爲打印後的結果:

截止目前,咱們已經實現了虛擬DOM並進行了渲染真實DOM到頁面中。那麼接下來咱們就有請DOM-diff隆重登場,來看一下這大有來頭的diff算法是如何發光發熱的吧!

DOM-diff閃亮登場

說到DOM-diff那必定要清楚其存在的意義,給定任意兩棵樹,採用先序深度優先遍歷的算法找到最少的轉換步驟

DOM-diff比較兩個虛擬DOM的區別,也就是在比較兩個對象的區別。

做用: 根據兩個虛擬對象建立出補丁,描述改變的內容,將這個補丁用來更新DOM

已經瞭解到DOM-diff是幹嗎的了,那就沒什麼好說的了,繼續往下寫吧

// diff.js

function diff(oldTree, newTree) {
    // 聲明變量patches用來存放補丁的對象
    let patches = {};
    // 第一次比較應該是樹的第0個索引
    let index = 0;
    // 遞歸樹 比較後的結果放到補丁裏
    walk(oldTree, newTree, index, patches);

    return patches;
}

function walk(oldNode, newNode, index, patches) {
    // 每一個元素都有一個補丁
    let current = [];

    if (!newNode) { // rule1
        current.push({ type: 'REMOVE', index });
    } else if (isString(oldNode) && isString(newNode)) {
        // 判斷文本是否一致
        if (oldNode !== newNode) {
            current.push({ type: 'TEXT', text: newNode });
        }

    } else if (oldNode.type === newNode.type) {
        // 比較屬性是否有更改
        let attr = diffAttr(oldNode.props, newNode.props);
        if (Object.keys(attr).length > 0) {
            current.push({ type: 'ATTR', attr });
        }
        // 若是有子節點,遍歷子節點
        diffChildren(oldNode.children, newNode.children, patches);
    } else {    // 說明節點被替換了
        current.push({ type: 'REPLACE', newNode});
    }
    
    // 當前元素確實有補丁存在
    if (current.length) {
        // 將元素和補丁對應起來,放到大補丁包中
        patches[index] = current;
    }
}

function isString(obj) {
    return typeof obj === 'string';
}

function diffAttr(oldAttrs, newAttrs) {
    let patch = {};
    // 判斷老的屬性中和新的屬性的關係
    for (let key in oldAttrs) {
        if (oldAttrs[key] !== newAttrs[key]) {
            patch[key] = newAttrs[key]; // 有可能仍是undefined
        }
    }

    for (let key in newAttrs) {
        // 老節點沒有新節點的屬性
        if (!oldAttrs.hasOwnProperty(key)) {
            patch[key] = newAttrs[key];
        }
    }
    return patch;
}

// 全部都基於一個序號來實現
let num = 0;

function diffChildren(oldChildren, newChildren, patches) {
    // 比較老的第一個和新的第一個
    oldChildren.forEach((child, index) => {
        walk(child, newChildren[index], ++num, patches);
    });
}

// 默認導出
export default diff;
複製代碼

代碼雖然又臭又長,可是這些代碼就讓咱們實現了diff算法了,因此你們先不要盲動,不要盲動,且聽風吟,讓我一一道來

比較規則

  • 新的DOM節點不存在{type: 'REMOVE', index}
  • 文本的變化{type: 'TEXT', text: 1}
  • 當節點類型相同時,去看一下屬性是否相同,產生一個屬性的補丁包{type: 'ATTR', attr: {class: 'list-group'}}
  • 節點類型不相同,直接採用替換模式{type: 'REPLACE', newNode}

根據這些規則,咱們再來看一下diff代碼中的walk方法這位關鍵先生

walk方法都作了什麼?

  • 每一個元素都有一個補丁,因此須要建立一個放當前補丁的數組
  • 若是沒有new節點的話,就直接將type爲REMOVE的類型放到當前補丁裏
if (!newNode) {
        current.push({ type: 'REMOVE', index });
    }
複製代碼
  • 若是新老節點是文本的話,判斷一下文本是否一致,再指定類型TEXT並把新節點放到當前補丁
else if (isString(oldNode) && isString(newNode)) {
        if (oldNode !== newNode) {
            current.push({ type: 'TEXT', text: newNode });
        }
    }
複製代碼
  • 若是新老節點的類型相同,那麼就來比較一下他們的屬性props
    • 屬性比較
      • diffAttr
        • 去比較新老Attr是否相同
        • 把newAttr的鍵值對賦給patch對象上並返回此對象
    • 而後若是有子節點的話就再比較一會兒節點的不一樣,再調一次walk
      • diffChildren
        • 遍歷oldChildren,而後遞歸調用walk再經過child和newChildren[index]去diff
else if (oldNode.type === newNode.type) {
        // 比較屬性是否有更改
        let attr = diffAttr(oldNode.props, newNode.props);
        if (Object.keys(attr).length > 0) {
            current.push({ type: 'ATTR', attr });
        }
        
        // 若是有子節點,遍歷子節點
        diffChildren(oldNode.children, newNode.children, patches);
    }
複製代碼
  • 上面三個若是都沒有發生的話,那就表示節點單純的被替換了,type爲REPLACE,直接用newNode替換便可
else {
        current.push({ type: 'REPLACE', newNode});
    }
複製代碼
  • 當前補丁裏確實有值的狀況,就將對應的補丁放進大補丁包裏
if (current.length > 0) {
        // 將元素和補丁對應起來,放到大補丁包中
        patches[index] = current;
    }
複製代碼

以上就是關於diff算法的分析過程了,沒太明白的話不要緊,再反覆看幾遍試試,意外老是不期而遇的

diff已經完事了,那麼最後一步就是你們所熟知的打補丁

補丁要怎麼打?那麼讓久違的patch出來吧

patch補丁更新

打補丁須要傳入兩個參數,一個是要打補丁的元素,另外一個就是所要打的補丁了,那麼直接看代碼

import { Element, render, setAttr } from './element';

let allPatches;
let index = 0;  // 默認哪一個須要打補丁

function patch(node, patches) {
    allPatches = patches;
    
    // 給某個元素打補丁
    walk(node);
}

function walk(node) {
    let current = allPatches[index++];
    let childNodes = node.childNodes;

    // 先序深度,繼續遍歷遞歸子節點
    childNodes.forEach(child => walk(child));

    if (current) {
        doPatch(node, current); // 打上補丁
    }
}

function doPatch(node, patches) {
    // 遍歷全部打過的補丁
    patches.forEach(patch => {
        switch (patch.type) {
            case 'ATTR':
                for (let key in patch.attr) {
                    let value = patch.attr[key];
                    if (value) {
                        setAttr(node, key, value);
                    } else {
                        node.removeAttribute(key);
                    }
                }
                break;
            case 'TEXT':
                node.textContent = patch.text;
                break;
            case 'REPLACE':
                let newNode = patch.newNode;
                newNode = (newNode instanceof Element) ? render(newNode) : document.createTextNode(newNode);
                node.parentNode.replaceChild(newNode, node);
                break;
            case 'REMOVE':
                node.parentNode.removeChild(node);
                break;
            default:
                break;
        }
    });
}

export default patch;
複製代碼

看完代碼還須要再來簡單的分析一下

patch作了什麼?

  • 用一個變量來獲得傳遞過來的全部補丁allPatches
  • patch方法接收兩個參數(node, patches)
    • 在方法內部調用walk方法,給某個元素打上補丁
  • walk方法裏獲取全部的子節點
    • 給子節點也進行先序深度優先遍歷,遞歸walk
    • 若是當前的補丁是存在的,那麼就對其打補丁(doPatch)
  • doPatch打補丁方法會根據傳遞的patches進行遍歷
    • 判斷補丁的類型來進行不一樣的操做
      1. 屬性ATTR for in去遍歷attrs對象,當前的key值若是存在,就直接設置屬性setAttr; 若是不存在對應的key值那就直接刪除這個key鍵的屬性

      2. 文字TEXT 直接將補丁的text賦值給node節點的textContent便可

      3. 替換REPLACE 新節點替換老節點,須要先判斷新節點是否是Element的實例,是的話調用render方法渲染新節點;

        不是的話就代表新節點是個文本節點,直接建立一個文本節點就OK了。

        以後再經過調用父級parentNode的replaceChild方法替換爲新的節點

      4. 刪除REMOVE 直接調用父級的removeChild方法刪除該節點

  • 將patch方法默認導出方便調用

好了,一切都安靜下來了。讓咱們迴歸index.js文件中,去調用一下diff和patch這兩個重要方法,看看奇蹟會不會發生吧

迴歸

// index.js

import { createElement, render, renderDom } from './element';
// +++ 引入diff和patch方法
import diff from './diff';
import patch from './patch';
// +++

let virtualDom = createElement('ul', {class: 'list'}, [
    createElement('li', {class: 'item'}, ['周杰倫']),
    createElement('li', {class: 'item'}, ['林俊杰']),
    createElement('li', {class: 'item'}, ['王力宏'])    
]);

let el = render(virtualDom);
renderDom(el, window.root);

// +++
// 建立另外一個新的虛擬DOM
let virtualDom2 = createElement('ul', {class: 'list-group'}, [
    createElement('li', {class: 'item active'}, ['七里香']),
    createElement('li', {class: 'item'}, ['一千年之後']),
    createElement('li', {class: 'item'}, ['須要人陪'])    
]);
// diff一下兩個不一樣的虛擬DOM
let patches = diff(virtualDom, virtualDom2);
console.log(patches);
// 將變化打補丁,更新到el
patch(el, patches);
// +++
複製代碼

將修改後的代碼保存,會在瀏覽器裏看到DOM被更新了,以下圖

到這裏就finish了,內容有些多,可能不是很好的消耗,不過不要緊,就讓我用最後幾句話來總結一下實現的整個過程吧

四句話

咱們來梳理一下整個DOM-diff的過程:

  1. 用JS對象模擬DOM(虛擬DOM)
  2. 把此虛擬DOM轉成真實DOM並插入頁面中(render)
  3. 若是有事件發生修改了虛擬DOM,比較兩棵虛擬DOM樹的差別,獲得差別對象(diff)
  4. 把差別對象應用到真正的DOM樹上(patch)

行了,就這四句話吧,說多了就有點多此一舉了。很久沒有寫文章了,很感謝小夥伴們的觀看,辛苦各位了,886

參考:

虛擬DOM和原生誰更快?

珠峯 DOM-Diff算法學習-提取碼:rtcu

相關文章
相關標籤/搜索