虛擬Dom與Diff的簡單實現

都9102年了,或許這類的文章已經出現了不少,但依舊本身作一個記錄吧。如若您願意閱讀更多個人我的筆記,能夠訪問 個人博客個人博客倉庫.javascript

什麼是虛擬Dom

虛擬 Dom(virtual Dom)正如其名,它並非真正的 Dom 對象,但能夠根據虛擬 Dom 來轉換爲真正的 Dom 對象。css

虛擬 Dom 實際上是一個 JavaScript 對象,對於下面所示的 Dom 結構:html

<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。java

虛擬 Dom -> 真實 Dom

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

然後續有關界面上的交互,也是做用在虛擬 Dom 上,觸發虛擬 Dom 的更新,從而引發真實 Dom 的更新。react

虛擬Dom和真實Dom的交互

虛擬 Dom 的實現

咱們在書寫 React 組件時可使用兩種語法:git

  • JSXgithub

  • 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倉庫 中找到。

參考資料

相關文章
相關標籤/搜索