虛擬DOM詳解


React為啥這么大?因為它實現了一個虛擬DOM(Virtual DOM)。虛擬DOM是干什么的?這就要從瀏覽器本身講起。

如我們所知,在瀏覽器渲染網頁的過程中,加載到HTML文檔后,會將文檔解析並構建DOM樹,然后將其與解析CSS生成的CSSOM樹一起結合產生愛的結晶——RenderObject樹,然后將RenderObject樹渲染成頁面(當然中間可能會有一些優化,比如RenderLayer樹)。這些過程都存在與渲染引擎之中,渲染引擎在瀏覽器中是於JavaScript引擎(JavaScriptCore也好V8也好)分離開的,但為了方便JS操作DOM結構,渲染引擎會暴露一些接口供JavaScript調用。

由於這兩塊相互分離,通信是需要付出代價的,因此JavaScript調用DOM提供的接口性能不咋地。各種性能優化的最佳實踐也都在盡可能的減少DOM操作次數。

而虛擬DOM干了什么?它直接用JavaScript實現了DOM樹(大致上)。組件的HTML結構並不會直接生成DOM,而是映射生成虛擬的JavaScript DOM結構,React又通過在這個虛擬DOM上實現了一個 diff 算法找出最小變更,再把這些變更寫入實際的DOM中。這個虛擬DOM以JS結構的形式存在,計算性能會比較好,而且由於減少了實際DOM操作次數,性能會有較大提升。

 

  • 為什么需要虛擬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節點:

<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元素,所以這樣做是最優的。

我們新創建一棵樹,用於和之前的樹進行比較.

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上了。深度遍歷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才能快速跟上。

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM