虛擬DOM介紹

近一年業務項目中都在使用React框架,也出現了一些以前沒怎么關注過的新概念,例如虛擬DOM。虛擬DOM本身不是什么新鮮事物,網上隨便一搜,早在2015年就有人詳細介紹過了,但我只知道它速度快,效率高,對原理一無所知,最近抽空學習了一下。

參考資料如下:

深度剖析:如何實現一個 Virtual DOM 算法(作者:戴嘉華)

Why Virtual DOM(作者:Sai Kishore Komanduri)

虛擬DOM Diff算法解析(作者:王沛)

深入淺出React和Redux(作者:程墨)

  • 為什么需要虛擬DOM
  • 實現虛擬DOM
  • Diff算法
  • 映射成真實DOM

為什么需要虛擬DOM

先介紹瀏覽器加載一個HTML文件需要做哪些事,幫助我們理解為什么我們需要虛擬DOM。webkit引擎的處理流程,一圖勝千言:

所有瀏覽器的引擎工作流程都差不多,如上圖大致分5步:創建DOM tree –> 創建Style Rules -> 構建Render tree -> 布局Layout –> 繪制Painting

第一步,用HTML分析器,分析HTML元素,構建一顆DOM樹。

第二步:用CSS分析器,分析CSS文件和元素上的inline樣式,生成頁面的樣式表。

第三步:將上面的DOM樹和樣式表,關聯起來,構建一顆Render樹。這一過程又稱為Attachment。每個DOM節點都有attach方法,接受樣式信息,返回一個render對象(又名renderer)。這些render對象最終會被構建成一顆Render樹。

第四步:有了Render樹后,瀏覽器開始布局,會為每個Render樹上的節點確定一個在顯示屏上出現的精確坐標值。

第五步:Render數有了,節點顯示的位置坐標也有了,最后就是調用每個節點的paint方法,讓它們顯示出來。

當你用傳統的源生api或jQuery去操作DOM時,瀏覽器會從構建DOM樹開始從頭到尾執行一遍流程。比如當你在一次操作時,需要更新10個DOM節點,理想狀態是一次性構建完DOM樹,再執行后續操作。但瀏覽器沒這么智能,收到第一個更新DOM請求后,并不知道后續還有9次更新操作,因此會馬上執行流程,最終執行10次流程。顯然例如計算DOM節點的坐標值等都是白白浪費性能,可能這次計算完,緊接著的下一個DOM更新請求,這個節點的坐標值就變了,前面的一次計算是無用功。

即使計算機硬件一直在更新迭代,操作DOM的代價仍舊是昂貴的,頻繁操作還是會出現頁面卡頓,影響用戶的體驗。真實的DOM節點,哪怕一個最簡單的div也包含著很多屬性,可以打印出來直觀感受一下:

虛擬DOM就是為了解決這個瀏覽器性能問題而被設計出來的。例如前面的例子,假如一次操作中有10次更新DOM的動作,虛擬DOM不會立即操作DOM,而是將這10次更新的diff內容保存到本地的一個js對象中,最終將這個js對象一次性attach到DOM樹上,通知瀏覽器去執行繪制工作,這樣可以避免大量的無謂的計算量。

實現虛擬DOM

我們來實現一個虛擬DOM。例如一個真實的DOM節點:代碼見倉庫里的src/firstStep

<div id="real-container">
    <p>Real DOM</p>
    <div>cannot update</div>
    <ul>
        <li className="item">Item 1</li>
        <li className="item">Item 2</li>
        <li className="item">Item 3</li>
    </ul>
</div>

用js對象來模擬DOM節點如下:

const tree = Element('div', { id: 'virtual-container' }, [
    Element('p', {}, ['Virtual DOM']),
    Element('div', {}, ['before update']),
    Element('ul', {}, [
        Element('li', { class: 'item' }, ['Item 1']),
        Element('li', { class: 'item' }, ['Item 2']),
        Element('li', { class: 'item' }, ['Item 3']),
    ]),
]);

const root = tree.render();
document.getElementById('virtualDom').appendChild(root);

用js對象模擬DOM節點的好處是,頁面的更新可以先全部反映在js對象上,操作內存中的js對象的速度顯然要快多了。等更新完后,再將最終的js對象映射成真實的DOM,交由瀏覽器去繪制。

那具體怎么實現呢?看一下Element方法的具體實現:

function Element(tagName, props, children) {
    if (!(this instanceof Element)) {
        return new Element(tagName, props, children);
    }

    this.tagName = tagName;
    this.props = props || {};
    this.children = children || [];
    this.key = props ? props.key : undefined;

    let count = 0;
    this.children.forEach((child) => {
        if (child instanceof Element) {
            count += child.count;
        }
        count++;
    });
    this.count = count;
}

第一個參數是節點名(如div),第二個參數是節點的屬性(如class),第三個參數是子節點(如ul的li)。除了這三個參數會被保存在對象上外,還保存了key和count。


有了js對象后,最終還需要將其映射成真實的DOM:

Element.prototype.render = function() {
    const el = document.createElement(this.tagName);
    const props = this.props;

    for (const propName in props) {
        setAttr(el, propName, props[propName]);
    }

    this.children.forEach((child) => {
        const childEl = (child instanceof Element) ? child.render() : document.createTextNode(child);
        el.appendChild(childEl);
    });

    return el;
};

上面都是自解釋代碼,根據DOM名調用源生的createElement創建真實DOM,將DOM的屬性全都加到這個DOM元素上,如果有子元素繼續遞歸調用創建子元素,并appendChild掛到該DOM元素上。這樣就完成了從創建虛擬DOM到將其映射成真實DOM的全部工作。

Diff算法

我們已經完成了創建虛擬DOM并將其映射成真實DOM的工作,這樣所有的更新都可以先反映到虛擬DOM上,如何反映呢?需要明確一下Diff算法。

兩棵樹如果完全比較時間復雜度是O(n^3),但參照《深入淺出React和Redux》一書中的介紹,React的Diff算法的時間復雜度是O(n)。要實現這么低的時間復雜度,意味著只能平層地比較兩棵樹的節點,放棄了深度遍歷。這樣做,似乎犧牲了一定的精確性來換取速度,但考慮到現實中前端頁面通常也不會跨層級移動DOM元素,所以這樣做是最優的。

我們新創建一棵樹,用于和之前的樹進行比較,代碼見倉庫里的src/secondStep

const newTree = Element('div', { id: 'virtual-container' }, [
    Element('h3', {}, ['Virtual DOM']),                     // REPLACE
    Element('div', {}, ['after update']),                   // TEXT
    Element('ul', { class: 'marginLeft10' }, [              // PROPS
        Element('li', { class: 'item' }, ['Item 1']),
        // Element('li', { class: 'item' }, ['Item 2']),    // REORDER remove
        Element('li', { class: 'item' }, ['Item 3']),
    ]),
]);

只考慮平層地Diff的話,就簡單多了,只需要考慮以下4種情況:

第一種是最簡單的,節點類型變了,例如下圖中的P變成了h3。我們將這個過程稱之為REPLACE。直接將舊節點卸載(componentWillUnmount)并裝載新節點(componentWillMount)就行了。

(為簡單起見上圖隱藏了文本節點)

舊節點包括下面的子節點都將被卸載,如果新節點和舊節點僅僅是類型不同,但下面的所有子節點都一樣時,這樣做顯得效率不高。但為了避免O(n^3)的時間復雜度,這樣做是值得的。這也提醒了React開發者,應該避免無謂的節點類型的變化,例如運行時將div變成p就沒什么太大意義。

第二種也比較簡單,節點類型一樣,僅僅屬性或屬性值變了。

renderA: <ul>
renderB: <ul class: 'marginLeft10'>
=> [addAttribute class "marginLeft10"]

我們將這個過程稱之為PROPS。此時不會觸發節點的卸載(componentWillUnmount)和裝載(componentWillMount)動作。而是執行節點更新(shouldComponentUpdate到componentDidUpdate的一系列方法)。

function diffProps(oldNode, newNode) {
    const oldProps = oldNode.props;
    const newProps = newNode.props;

    let key;
    const propsPatches = {};
    let isSame = true;

    // find out different props
    for (key in oldProps) {
        if (newProps[key] !== oldProps[key]) {
            isSame = false;
            propsPatches[key] = newProps[key];
        }
    }

    // find out new props
    for (key in newProps) {
        if (!oldProps.hasOwnProperty(key)) {
            isSame = false;
            propsPatches[key] = newProps[key];
        }
    }

    return isSame ? null : propsPatches;
}

第三種是文本變了,文本對也是一個Text Node,也比較簡單,直接修改文字內容就行了,我們將這個過程稱之為TEXT。

第四種是移動,增加,刪除子節點,我們將這個過程稱之為REORDER。具體可以看這篇虛擬DOM Diff算法解析。例如:

在中間插入一個節點,程序員寫代碼很簡單:$(B).after(F)。但如何高效地插入呢?簡單粗暴的做法是:卸載C,裝載F,卸載D,裝載C,卸載E,裝載D,裝載E。如下圖:
我們寫JSX代碼時,如果沒有給數組或枚舉類型定義一個key,就會看到下面這樣的warning。React提醒我們,沒有key的話,涉及到移動,增加,刪除子節點的操作時,就會用上面那種簡單粗暴的做法來更新。雖然程序運行不會有錯,但效率太低,因此React會給我們一個warning。

如果我們在JSX里為數組或枚舉型元素增加上key后,React就能根據key,直接找到具體的位置進行操作,效率比較高。如下圖:

常見的最小編輯距離問題,可以用Levenshtein Distance算法來實現,時間復雜度是O(M*N),但通常我們只要一些簡單的移動就能滿足需要,降低點精確性,將時間復雜度降低到O(max(M, N)即可。具體可參照采用深度剖析:如何實現一個 Virtual DOM 算法里的一個算法一文。或自行閱讀例子中的源代碼

最終Diff出來的結果如下:

{
    1: [ {type: REPLACE, node: Element} ],
    4: [ {type: TEXT, content: "after update"} ],
    5: [ {type: PROPS, props: {class: "marginLeft10"}}, {type: REORDER, moves: [{index: 2, type: 0}]} ],
    6: [ {type: REORDER, moves: [{index: 2, type: 0}]} ],
    8: [ {type: REORDER, moves: [{index: 2, type: 0}]} ],
    9: [ {type: TEXT, content: "Item 3"} ],
}

映射成真實DOM

虛擬DOM有了,Diff也有了,現在就可以將Diff應用到真實DOM上了。代碼見倉庫里的src/thirdStep

深度遍歷DOM將Diff的內容更新進去:

function dfsWalk(node, walker, patches) {
    const currentPatches = patches[walker.index];

    const len = node.childNodes ? node.childNodes.length : 0;
    for (let i = 0; i < len; i++) {
        walker.index++;
        dfsWalk(node.childNodes[i], walker, patches);
    }

    if (currentPatches) {
        applyPatches(node, currentPatches);
    }
}

具體更新的代碼如下,其實就是根據Diff信息調用源生API操作DOM:

function applyPatches(node, currentPatches) {
    currentPatches.forEach((currentPatch) => {
        switch (currentPatch.type) {
            case REPLACE: {
                const newNode = (typeof currentPatch.node === 'string')
                    ? document.createTextNode(currentPatch.node)
                    : currentPatch.node.render();
                node.parentNode.replaceChild(newNode, node);
                break;
            }
            case REORDER:
                reorderChildren(node, currentPatch.moves);
                break;
            case PROPS:
                setProps(node, currentPatch.props);
                break;
            case TEXT:
                if (node.textContent) {
                    node.textContent = currentPatch.content;
                } else {
                    // ie
                    node.nodeValue = currentPatch.content;
                }
                break;
            default:
                throw new Error(`Unknown patch type ${currentPatch.type}`);
        }
    });
}

虛擬DOM的目的是將所有操作累加起來,統計計算出所有的變化后,統一更新一次DOM。其實即使不懂原理,業務代碼照樣寫,但理解原理后,出了什么新東東如React Fiber才能快速跟上。前端就是這樣痛并快樂著。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容