虛擬Dom與Diff的簡單實現

宇宙南海發表於2019-07-26

都9102年了,或許這類的文章已經出現了很多,但依舊自己做一個記錄吧。如若您願意閱讀更多我的個人筆記,可以訪問 我的部落格我的部落格倉庫.

什麼是虛擬Dom

虛擬 Dom(virtual Dom)正如其名,它並不是真正的 Dom 物件,但可以根據虛擬 Dom 來轉換為真正的 Dom 物件。

虛擬 Dom 其實是一個 JavaScript 物件,對於下面所示的 Dom 結構:

<ul class="lists">
    <li>1</li>
    <li class="item">2</li>
    <li>3</li>
</ul>
複製程式碼

該 Dom 結構所對應的 JavaScript 物件可以是這樣的:

const virtualDom = {
    type: 'ul'
    props: {
        class: 'lists'
    },
    children: [
        {
            type: 'li'
            props: {},
            children: ['1']
        },
        {
            type: 'li'
            props: { class: 'item' },
            children: ['2']
        },
        {
            type: 'li'
            props: {},
            children: ['3']
        }
    ]
}
複製程式碼

這種能夠表示 Dom 的 JavaScript 物件,就是虛擬 Dom。

虛擬 Dom -> 真實 Dom

在建立新元素時,React 會首先建立出虛擬 Dom,然後根據虛擬 Dom 的表示,經過 render 方法轉換為真實的 Dom。

而後續有關介面上的互動,也是作用在虛擬 Dom 上,觸發虛擬 Dom 的更新,從而引起真實 Dom 的更新。

虛擬Dom和真實Dom的互動

虛擬 Dom 的實現

我們在書寫 React 元件時可以使用兩種語法:

  • JSX

  • React.createElement

JSX 是 React 提供的一個語法糖,藉助 Babel 工具,使開發者可以使用更方便的語法形式來書寫,實際上這兩種方式是等價的。換句話說,JSX 在經過 Babel 的轉換後,會使用 React.createElement() 這一方法。

簡單實現 React.createElement 方法

  • createElement(type, config, children): Element;

該方法主要做的就是建立一個物件,來描述 Dom 資訊,可以建立一個建構函式來儲存,並通過 new 關鍵字去例項化。

function Element(type, config, children) {
    this.type = type;
    this.props = config;
    this.children = children;
}

function createElement(type, config, children) {
    return new Element(type, config, children);
}
複製程式碼

使用時需要呼叫 createElement 方法:

let virtualDom1 = createElement("ul", { class: "lists" }, [
    createElement("li", {}, ["1"]),
    createElement("li", { class: "item" }, ["2"]),
    createElement("li", {}, ["3"]),
]);

console.log(virtualDom1);
複製程式碼

實現render方法

虛擬 Dom 需要通過一個 render 方法,將虛擬 Dom 物件轉換為真實 Dom。

  • render(eleObj);
function setAttr(node, key, value) {
    switch(key) {
        case "value":
            if (node.tagName.toUpperCase === 'INPUT' || node.tagName.toUpperCase === "TEXTAREA") {
                node.value = value;
            } else {
                node.setAttribute(key, value);
            }
            break;
        case "style":
            node.style.cssText = value;
            break;
        default:
            node.setAttribute(key, value);
            break;
    }
}
function render(eleObj) {
    // 建立Element
    let el = document.createElement(eleObj.type);

    // 遍歷屬性並設定
    for (let key in eleObj.props) {
        setAttr(el, key, eleObj.props[key]);
    }

    // 遍歷孩子節點,並建立(如果是Element建構函式,則遞迴呼叫render方法,否則建立一個文字節點)
    eleObj.children.forEach(child => {
        if (child instanceof Element) {
            child = render(child);
        } else {
            child = document.createTextNode(child);
        }
        el.appendChild(child);
    });

    return el;
}
複製程式碼

呼叫

let virtualDom1 = createElement("ul", { class: "lists" }, [
    createElement("li", {}, ["1"]),
    createElement("li", { class: "item" }, ["2"]),
    createElement("li", { style: "color: red;" }, ["3"]),
]);

let dom = render(virtualDom1);

console.log(dom);
複製程式碼

列印虛擬Dom和真實Dom

要令 dom 顯示在頁面上,那麼還需要最後做一次append操作:

// 這裡只是最簡單的插入到了body中,實際上還存在通過id選擇root節點,再將dom插入到root節點中
document.body.appendChild(dom);
複製程式碼

以上我們就簡單的實現了一個虛擬Dom。

簡單實現中並沒有包括 ref、key 等內容,如果你想了解更多,推薦閱讀原始碼解析相關文章,這邊推薦一篇文章:

【React深入】深入分析虛擬DOM的渲染原理和特性

patch 補丁

React 通過 patch 補丁的形式來更新現有的 Dom,所謂的 patch 補丁,其實也是一個物件,這個物件描述了虛擬 Dom 樹需要做出怎麼樣的修改。它的形式類似於:{ type: 'REPLACE', node: newNode }

上面那種形式的補丁,告訴我們此處需要替換內容。那麼根據這個補丁,所對應的依舊是Dom操作。

patch 從何而來?

patch 補丁來源於 Dom Diff,Diff 則發生在新舊的虛擬 Dom 樹上。

通過對比新舊虛擬 Dom 樹,計算出差異,產生 patch 補丁,這些補丁也就是如果將舊的 Dom 樹更新為新的 Dom 樹的所需要做出的 Dom 操作。

使用虛擬 Dom 會更快嗎?

使用虛擬 Dom 不一定會變得更快。虛擬 Dom 是 Dom 的 JavaScript 表示,在事件發生時,通過對比新舊虛擬 Dom 得出更新(通過 Diff 演算法獲得 patch 補丁),這是一系列轉換、分析、計算的過程。

對於一個很簡單的場景(點選按鈕,頁面顯示的數字增加),直接操作 dom 將會是更快的,因為在一系列的分析計算後,所產生的 patch 補丁也將是這樣的 dom 操作。儘管這個過程可能並不久,但依舊經歷了額外的分析計算過程。

對於複雜場景,虛擬 Dom 會是更快的,頁面效能所最重要的地方也就是重排、重繪,頻繁的 Dom 操作所帶來的頁面開銷將是巨大的。在經過 Diff 的分析計算後,產生 patch 補丁,將會簡化 Dom 操作(可能並不是最優的),極大的減少不必要的、重複的 Dom 操作。

Diff

先序深度優先遍歷

Diff 採用先序深度優先遍歷來觀察差異,所謂先序深度優先,也就是先遍歷根節點,其次是子節點(對於二叉樹是根、左、右)。

先序深度優先遍歷示意圖
;

const diffHelper = {
    Index: 0
}
function dfs(tree) {
    console.log(tree.type, diffHelper.Index);
    dfsChildren(tree.children);
}
function dfsChildren(nodeArray) {
    nodeArray.forEach(node => {
        // 每個節點都佔用一個編號
        ++diffHelper.Index;
        if (node.type) {
            // 是節點,遞迴呼叫
            dfs(node);
        } else {
            // 文字節點
            console.log(node, diffHelper.Index);
        }
    })
}
複製程式碼

先序深度優先遍歷結果

O(n^3) -> O(n)

對比兩棵樹的差異是 Diff 演算法最核心的部分。

兩棵樹完全 Diff(對比父節點、自身、子節點是否完全一致)的時間複雜度是 O(n^3),由於前端中跨層級移動節點的場景較少,因此 React 的 Diff 演算法中利用同級比較(只比較同級元素)巧妙的將時間複雜度降低至 O(n)。

Diff 演算法使用同級比較來降低時間複雜度

同層級比較規則:

  • 如果新節點不存在,產生一個移除節點的 patch 補丁
  • 如果節點型別相同,比較屬性差異,如若屬性不同,產生一個關於屬性的 patch 補丁
  • 如果節點型別不同,將舊節點替換成新節點,產生一個有關替換的 patch 補丁
  • 如果有新增節點,產生一個有關新增的 patch 補丁
const diffHelper = {
    Index: 0,
    isTextNode: (eleObj) => {
        return !(eleObj instanceof Element);
    },
    diffAttr: (oldAttr, newAttr) => {
        let patches = {}
        for (let key in oldAttr) {
            if (oldAttr[key] !== newAttr[key]) {
                // 可能產生了更改 或者 新屬性為undefined,也就是該屬性被刪除
                patches[key] = newAttr[key];
            }
        }

        for (let key in newAttr) {
            // 新增屬性
            if (!oldAttr.hasOwnProperty(key)) {
                patches[key] = newAttr[key];
            }
        }

        return patches;
    },
    diffChildren: (oldChild, newChild, patches) => {
        if (newChild.length > oldChild.length) {
            // 有新節點產生
            patches[diffHelper.Index] = patches[diffHelper.Index] || [];
            patches[diffHelper.Index].push({
                type: PATCHES_TYPE.ADD,
                nodeList: newChild.slice(oldChild.length)
            });
        }
        oldChild.forEach((children, index) => {
            dfsWalk(children, newChild[index], ++diffHelper.Index, patches);
        });
    },
    dfsChildren: (oldChild) => {
        if (!diffHelper.isTextNode(oldChild)) {
            oldChild.children.forEach(children => {
                ++diffHelper.Index;
                diffHelper.dfsChildren(children);
            });
        }
    }
}

const PATCHES_TYPE = {
    ATTRS: 'ATTRS',
    REPLACE: 'REPLACE',
    TEXT: 'TEXT',
    REMOVE: 'REMOVE',
    ADD: 'ADD'
}

function diff(oldTree, newTree) {
    // 當前節點的標誌 每次呼叫Diff,從0重新計數
    diffHelper.Index = 0;
    let patches = {};

    // 進行深度優先遍歷
    dfsWalk(oldTree, newTree, diffHelper.Index, patches);

    // 返回補丁物件
    return patches;
}

function dfsWalk(oldNode, newNode, index, patches) {
    let currentPatches = [];
    if (!newNode) {
        // 如果不存在新節點,發生了移除,產生一個關於 Remove 的 patch 補丁
        currentPatches.push({
            type: PATCHES_TYPE.REMOVE
        });

        // 刪除了但依舊要遍歷舊樹的節點確保 Index 正確
        diffHelper.dfsChildren(oldNode);
    } else if (diffHelper.isTextNode(oldNode) && diffHelper.isTextNode(newNode)) {
        // 都是純文字節點 如果內容不同,產生一個關於 textContent 的 patch
        if (oldNode !== newNode) {
            currentPatches.push({
                type: PATCHES_TYPE.TEXT,
                text: newNode
            });
        }
    } else if (oldNode.type === newNode.type) {
        // 如果節點型別相同,比較屬性差異,如若屬性不同,產生一個關於屬性的 patch 補丁
        let attrs = diffHelper.diffAttr(oldNode.props, newNode.props);

        // 有attr差異
        if(Object.keys(attrs).length > 0) {
            currentPatches.push({
                type: PATCHES_TYPE.ATTRS,
                attrs: attrs
            });
        }

        // 如果存在孩子節點,處理孩子節點
        diffHelper.diffChildren(oldNode.children, newNode.children, patches);
    } else {
        // 如果節點型別不同,說明發生了替換
        currentPatches.push({
            type: PATCHES_TYPE.REPLACE,
            node: newNode
        });
        // 替換了但依舊要遍歷舊樹的節點確保 Index 正確
        diffHelper.dfsChildren(oldNode);
    }

    // 如果當前節點存在補丁,則將該補丁資訊填入傳入的patches物件中
    if(currentPatches.length) {
        patches[index] = patches[index] ? patches[index].concat(currentPatches) : currentPatches;
    }
}
複製程式碼

呼叫

let virtualDom1 = createElement("ul", { class: "lists" }, [
    createElement("li", {}, ["1"]),
    createElement("li", { class: "item" }, ["2"]),
    createElement("li", { style: "color: red;" }, ["3"])
]);

let virtualDom2 = createElement("ul", {}, [
    createElement("div", {}, ["1"]),
    createElement("li", { class: "item" }, ["這裡變了"]),
    createElement("li", { style: "color: blue;" }, [
        createElement("li", {}, ["3-1"]),
    ]),
    createElement("li", {}, ["1"]),
]);

console.log(diff(virtualDom1, virtualDom2));
複製程式碼

執行結果如下圖所示:

執行 diff 後的 patch 補丁物件
;

同層級比較的缺陷

上面的形式對於列表存在比較大的缺陷:改變順序的列表,所產生的開銷將是巨大的。

舉例來說,對於下面的兩個 dom,其實發生的是一個順序的變化,但是在同級比較中,會產生2個替換的 patch 補丁(將3替換為4,將4替換為3),實際上最優的 dom 操作,是進行移動,將3移動到4的位置。

<ul>
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
</ul>
<ul>
    <li>1</li>
    <li>2</li>
    <li>4</li>
    <li>3</li>
</ul>
複製程式碼

列表Diff

React 引入 key 屬性來進行列表層面的 diff 判斷。

如果在書寫 React 列表時,你沒有給列表的每一項設定一個 key 值,那麼在控制檯上將會列印出一則警告,這是 React 在告訴你它無法高效的進行列表層面的 Diff 判斷。

未引入 key 時,React 將採用我們剛才介紹的方式進行 Diff。

未使用 Key 屬性時列表的 Diff

如上圖所示,C 將會被替換成 F,D 將會被替換成 C,E 將會別替換成 D,同時新增了一個 E。

使用 key 屬性後,React Diff 演算法將可以複用元素(key 一致時且標籤型別一致時,認為是同一元素)

使用 Key 屬性後列表的 Diff

通過演算法分析將可以知道 A、B、C、D、E 均未發生改變,因此會獲得一個有關插入的 patch 補丁。它的形式可能類似於:

{
  type: 'REORDER',
  moves: [{remove or insert}, {remove or insert}, ...]
}
複製程式碼

這個 patch 補丁所對應的 dom 操作可以是:

  • 刪除元素 element.removeChild()

  • 在某一元素前面增加元素 element.insertBefore()

這一部分的程式碼將不會在本篇進行講述。

修補補丁

通過 diff 演算法可以得到 patch 補丁物件,現在我們就可以根據 patch 補丁物件進行修補補丁。

let patches = diff(virtualDom1, virtualDom2);

patch(dom, patches);
複製程式碼

補丁物件的形式如下,我們可以從中得知第 n 個節點需要打的補丁。

patches = {
    0: [{
        type: 'ATTR',
        attrs: {
            class: undefined
        }
    }],
    3: [{
        type: 'TEXT',
        text: "這裡變了"
    }]
}
複製程式碼

我們要執行更新,也要做一遍先序深度優先遍歷,並執行相關的補丁操作。

const patchHelper = {
    Index: 0
}

function patch(node, patches) {
    dfsPatch(node, patches);
}

function dfsPatch(node, patches) {
    let currentPatch = patches[patchHelper.Index++];
    node.childNodes.forEach(child => {
        dfsPatch(child, patches);
    });
    if (currentPatch) {
        doPatch(node, currentPatch);
    }
}

function doPatch(node, patches) {
    patches.forEach(patch => {
        switch (patch.type) {
            case PATCHES_TYPE.ATTRS:
                for (let key in patch.attrs) {
                    if (patch.attrs[key] !== undefined) {
                        setAttr(node, key, patch.attrs[key]);
                    } else {
                        node.removeAttribute(key);
                    }
                }
                break;
            case PATCHES_TYPE.TEXT:
                node.textContent = patch.text;
                break;
            case PATCHES_TYPE.REPLACE:
                let newNode = patch.node instanceof Element ? render(patch.node) : document.createTextNode(patch.node);
                node.parentNode.replaceChild(newNode, node);
                break;
            case PATCHES_TYPE.REMOVE:
                node.parentNode.removeChild(node);
                break;
            case PATCHES_TYPE.ADD:
                patch.nodeList.forEach(newNode => {
                    let n = newNode instanceof Element ? render(newNode) : document.createTextNode(newNode);
                    node.appendChild(n);
                });
                break;
            default:
                break;
        }
    })
}
複製程式碼

React Fiber

這一部分大量引用了 Deep In React 之淺談 React Fiber 架構(一) 的文章內容,您也可以直接閱讀這一篇內容來了解 Fiber 的相關內容。

React 主要有兩個階段:

  • 調和階段(Reconciler):React 通過先序深度優先遍歷生成 Virtual DOM,然後通過 Diff 演算法,獲得變更補丁(Patch),放到更新佇列裡面去。

  • 渲染階段(Renderer):遍歷更新佇列,通過呼叫宿主環境的API,實際更新渲染對應元素。宿主環境,比如 DOM、Native、WebGL 等。

更多關於調和階段的解釋可以點選 這裡

從剛才我們的實現來看,代表了調和階段一旦開始,就無法 中斷。該功能將一直佔用主執行緒, 一直要等到整棵 Virtual DOM 樹計算完成之後,才能把執行權交給渲染引擎。

這樣的情況導致一些使用者互動、動畫等任務無法立即得到處理,容易造成卡頓、失幀等現象,影響使用者體驗。

Fiber 的誕生正是為了解決這個問題。

什麼是Fiber

為了解決這個問題,有以下幾個可供改進的地方:

  • 暫停工作,稍後再回來。
  • 為不同型別的工作分配優先權。
  • 重用以前完成的工作。
  • 如果不再需要,則中止工作。

為了做到這些,我們首先需要一種方法將任務分解為單元。從某種意義上說,這就是 Fiber,Fiber 代表一種工作單元。

Fiber 就是重新實現的堆疊幀,本質上 Fiber 也可以理解為是一個 虛擬的堆疊幀,將可中斷的任務拆分成多個子任務,通過按照優先順序來自由排程子任務,分段更新,從而將之前的同步渲染改為非同步渲染。

所以我們可以說 Fiber 是一種資料結構(堆疊幀),也可以說是一種解決可中斷的呼叫任務的一種解決方案,它的特性就是 時間分片(time slicing)和暫停(supense)

關於 Fiber,本篇不再展開講述,這裡提及只是為了說明在 Fiber 架構引入後,React的 diff 將會在瀏覽器有“空閒”的時候進行可中斷的執行。

程式碼

本文程式碼你可以在 我的Github倉庫 中找到。

參考資料

相關文章