虛擬DOM詳解


作者:小土豆

博客園:https://www.cnblogs.com/HouJiao/

掘金:https://juejin.im/user/2436173500265335

微信公眾號:不知名寶藏程序媛(關注"不知名寶藏程序媛"免費領取前端電子書籍。文章公眾號首發,關注公眾號第一時間獲取最新文章。)

碼字不易,點贊鼓勵喲~

前言

2020年,vue3.0 betavue3 rc陸續發布,優秀的人也早已開始各種實踐新版本的新特性,而我還不懂虛擬DOM,所以趕緊跟學起來。

🐱 黑發不知勤學早,白首方悔讀書也不遲

簡單理解虛擬DOM

當我們打開一個頁面,點擊查看元素,就能在開發中工具中看到頁面對應的DOM節點

假如我們將這些DOM節點使用一個js對象去表示,那這個js對象就可以被稱之為虛擬DOM

舉個栗子

下面有這樣一段DOM節點。

<div id="app" >
    <h3>內容</h3>
    <ul class="list">
        <li>選項一</li>
        <li>選項二</li>
    </ul>
</div>

我將這段DOM節點手動轉化為一個JS對象。

vdom = {
    type: 'div',  // 節點的類型,也就是節點的標簽名
    props: {      // 節點設置的所有屬性
        'id': 'content'
    },
    children: [   // 當前節點的子節點
        {
            type: 'h3',
            props: '',
            children:['內容']
        },
        {
            type: 'ul',
            props: {
                'class': 'list'
            },
            children: {
                {
                    type: 'li',
                    props: '',
                    children: ['選項一']
                },
                {
                    type: 'li',
                    props: '',
                    children: ['選項二']
                }
            }
        }
    ]
}

手動轉化出來的vdom對象就是我們所描述的虛擬DOM

虛擬DOM的代碼實現

前面我們手動將DOM節點轉化虛擬DOM,那這一節將使用代碼實現這個轉化。

項目環境搭建

本篇文章的示例使用npm進行搭建,最終的一個目錄結構如下:

virtual-dom
    | dist                webpack打包后的文件目錄
    | node_modules   
    | src                 源代碼目錄
    | index.html          測試的html文件
    | index.js            打包的入口文件
    | package-lock.json
    | package.json
    | webpack.config.js   webpack配置文件

定義虛擬DOM的數據結構

首先我們先將虛擬DOM的三個屬性定義出來:typepropschildren

// 代碼位置:/virtual-dom/src/virtualDOM.js
/*
*   @params: {String} type      標簽元素的類型,也就是標簽名稱
*   @params: {Object} props     標簽元素設置的屬性
*   @params: {Array}  children  標簽元素的子節點
*/
function VirtualDOM(type, props, children){
    this.type = type;
    this.props = props;
    this.children = children;   
}

接着定義一個創建虛擬dom的方法。

// 代碼位置:/virtual-dom/src/virtualDOM.js
/*
*  創建虛擬DOM的方法
*  @method create
*  @return {VirtualDOM} 返回創建出來的虛擬DOM對象
*/
function create(type, props, children){
    return new VirtualDOM(type, props, children)
}

export { VirtualDOM, create } 

該方法用來創建虛擬DOM對象,這樣就不用我們每次都使用new關鍵字進行創建

最后就是調用create方法,傳入對應的參數。

// 代碼位置:/virtual-dom/index.js
import {create} from './src/virtualDOM'

let vdom = create('div', {'class': 'content'}, [
    create('h3', {}, ['內容']),
    create('ul', { 'style': 'list-style-type: none;border: 1px solid;padding: 20px;'}, [
                create('li', {}, ['選項一']),
                create('li', {}, ['選項二'])
    ])
])

console.log(vdom);

最后我們看一下代碼生成的結果:

可以看到跟我們前面手動轉化的vdom結果一致。

將虛擬DOM轉化為真實節點

虛擬DOM它實際就是存儲在內存中的一個數據,那終極目標是需要將這個數據轉化為真實的DOM節點展示到瀏覽器上,所以接下來我們再來實現一下將虛擬DOM轉化為真實的DOM節點。

將虛擬DOM轉化為真實節點的思路和步驟大致如下:

根據type屬性創建節點 
設置節點屬性  
處理子節點:根據子節點的type創建子節點、設置子節點屬性,添加子節點到父節點中

前兩個步驟很簡單也很容易理解,最后一個步驟實際上是前兩個步驟的重復執行,因此最后一個步驟我們會使用遞歸進行實現。

那么接下來就代碼實現一下。

根據type屬性創建節點

// 代碼位置:/virtual-dom/src/render.js
/*
*   將虛擬節點轉化為真實的DOM節點並返回
*   @method render
*   @params {VirtualDOM}  vdom    虛擬DOM對象
*   @return {HMTLElement} element 返回真實的DOM節點 
*/
function render(vdom){
    var type = vdom.type;
    var props = vdom.props;
    var children = vdom.children;
    // 根據type屬性創建節點
    var element = document.createElement(vdom.type);

    return element;
}
export { render };

這里我們將邏輯寫在render函數中,並且返回創建好的真實DOM節點

設置節點屬性

// 代碼位置:/virtual-dom/src/render.js
/*  
*   為DOM節點設置屬性
*   @method setProps
*   @params {HTMLElement} element  dom元素
*   @params {Object}      props    元素的屬性
*/
function setProps(element, props){
    for (var key in props) {
        element.setAttribute(key,props[key]);
    }
}

export { render };

設置節點的屬性這個功能由setProps函數實現

然后我們需要在render函數中調用setProps方法,實現節點屬性的設置。

// 代碼位置:/virtual-dom/src/render.js
/*
*   將虛擬節點轉化為真實的DOM節點並返回
*   @method render
*   @params {VirtualDOM}  vdom    虛擬DOM對象
*   @return {HMTLElement} element 返回真實的DOM節點 
*/
function render(vdom){
    var type = vdom.type;
    var props = vdom.props;
    var children = vdom.children;
    // 根據type屬性創建節點
    var element = document.createElement(vdom.type);

    // 設置屬性
    setProps(element, props);
    
    return element;
}

/*  
*   為DOM節點設置屬性
*   @method setProps
*   @params {HTMLElement} element  dom元素
*   @params {Object}      props    元素的屬性
*/
function setProps(element, props){
    for (var key in props) {
        element.setAttribute(key,props[key]);
    }
}

export { render };

處理子節點

// 代碼位置:/virtual-dom/src/render.js
import { VirtualDOM } from './virtualDOM';
/*
*   將虛擬節點轉化為真實的DOM節點並返回
*   @method render
*   @params {VirtualDOM}  vdom    虛擬DOM對象
*   @return {HMTLElement} element 返回真實的DOM節點 
*/
function render(vdom){
    let type = vdom.type;
    let props = vdom.props;
    let children = vdom.children;
    // 根據type屬性創建節點
    let element = document.createElement(vdom.type);

    // 設置屬性
    setProps(element, props);

    // 設置子節點
    children.forEach(child => {
        // 子節點是虛擬VirtualDOM的實例 遞歸創建節點、設置屬性
        if(child instanceof VirtualDOM){
            let childEle = render(child);
        }else{
            // 子節點是文本
            let childEle = document.createTextNode(child); 
        }
        // 添加子節點到父節點中
        element.appendChild(childEle);
    });
    return element;
}

/*  
*   為DOM節點設置屬性
*   @method setProps
*   @params {HTMLElement} element  dom元素
*   @params {Object}      props    元素的屬性
*/
function setProps(element, props){
    for (let key in props) {
        element.setAttribute(key,props[key]);
    }
}

export { render };

在設置子節點的時候,有一個邏輯判斷:判斷子節點是否為虛擬VirtualDOM的實例,如果是的話,則需要遞歸調用render函數處理子節點;否則的話就說明子節點是文本內容。這個判斷邏輯的處理是根據前面兩節虛擬DOM創建的結果而定的。

這塊邏輯判斷不是固定的寫法,假如前面在生成虛擬DOM時文本類型是另外一種表示方式,那這個邏輯判斷也就是另外一種寫法了。

整合邏輯

那最后一步我們把前面的virtualDOM.jsrender.js整合到一起,實現真實DOM轉化為虛擬DOM,在將虛擬DOM轉化為真實DOM,最后在將生成后的真實DOM添加到頁面的body元素中。

// 代碼位置:/virtual-dom/index.js
import { create} from './src/virtualDOM'
import { render } from './src/render'

// 創建虛擬DOM
let vdom = create('div', {'class': 'content'}, [
    create('h3', {}, ['內容']),
    create('ul', { 'style': 'list-style-type: none;border: 1px solid;padding: 20px;'}, [
                create('li', {}, ['選項一']),
                create('li', {}, ['選項二'])
    ])
])

// 將虛擬DOM轉化為真實DOM
let realdom = render(vdom);

// 將真實DOM插入body元素中
document.body.appendChild(realdom);

最后瀏覽器中打開這個index.html文件。

可以看到,由vdom轉化后的readldom插入到頁面后和原始的真實DOM是一樣的,說明我們這個轉化是成功的。

dom-diff算法

前面總結了那么多關於虛擬DOM的內容,最后就是核心的dom-diff算法了。 dom-diff算法做的事情就是比較之前舊的虛擬DOM和當前新的虛擬DOM兩者之間的差異,然后將這部分差異的內容進行更新到文檔中。

上文描述的差異稱之為補丁:patches

那差異是怎么進行比較的呢?回歸到我們的實現的虛擬DOM上。

/*
*   @params: {String} type       標簽元素的類型,也就是標簽名稱
*   @params: {Object} props     標簽元素設置的屬性
*   @params: {Array}  children  標簽元素的子節點
*/
function VirtualDOM(type, props, children){
    this.type = type;
    this.props = props;
    this.children = children;   
}

虛擬DOM對象最基本的就三個屬性:標簽類型標簽元素的屬性標簽元素的子節點,所以說當兩個虛擬DOM對象進行一個差異比較時,比較的也就是這三個屬性。

那具體怎么個比較法呢,接下來我手動比一比下面兩個虛擬DOM

手動比較出來oldDomnewDom這兩個的差異(patches):

這個是我們手動比較出來的兩個DOM的差異,這些差異基本上包含了DOM屬性的變化、文本內容的變化、DOM節點的刪除以及替換。

這樣的比較結果使用一個js數據去表示,大概是這樣的結構:

patches = {
    '0': [
        {
            type: 'props',   // 屬性發生變化
            props: {
                class: 'box',
                id: 'wapper'
            }
        }
    ],
    '1': [
    	{
          type: 'replace',   // 節點發生替換
          content: {
          	type: 'h4', 
            {}, 
            children: ['內容']
           }
    	}
    ],
    '5':[
        {
            type: 'text',  // 文本內容變化
            content: '內容一'

        }
    ],
    '6': [
        {
            type: 'remove',  // 節點被移除
        }
    ]
}

這樣的比較結果也比較清晰明了,不過這個手動的比較結果怎么用代碼去實現呢?這個就是我們大名鼎鼎的DOM-Diff算法。

DOM-diff算法發核心就是對虛擬DOM節點進行深度優先遍歷並對每一個虛擬DOM節點進行編號,在遍歷的過程中對同一個層級的節點進行比較,最終得到比較后的差異:patches

注意dom-diff在比較差異時只會對同一層級的節點進行比較,因為如果進行完全的比較,算法實際復雜度會過高,所以舍棄了這種完全的比較方式,而采用同層比較(這里參考其他文章,因為算法不精,沒有具體研究過)

那話不多說,我們這就來用代碼簡單實現一下這個比較。

// 代碼位置:/virtual-dom/src/diff.js
/**
 * @name: traversal
 * @description: 深度優先遍歷虛擬DOM,計算出patches
 * @param {type} 參數
 * @return {type} 返回值
 */
function traversal(oldNode, newNode, o, patches){
    let currentPatches = [];
    if(newNode == undefined){
        //節點被刪除
        currentPatches.push({'type': 'remove'});
        patches[o.nid] = currentPatches;
    }else if(oldNode instanceof VirtualDOM && newNode instanceof VirtualDOM){
        // 如果是VirtualDOM類型
        if(oldNode.type != newNode.type){
            // 節點發生替換
            currentPatches.push({'type': 'replace', 'content': newNode.type})
            patches[o.nid] = currentPatches;
        }else{
            let resultDiff = diffProps(oldNode, newNode);
            // 屬性存在差異
            if(Object.keys(resultDiff).length != 0){
                currentPatches.push({'type': 'props', 'props': resultDiff})
                patches[o.nid] = currentPatches;
            }
        }
        oldNode.children.forEach((element,index) => {
            o.nid++;
            traversal(element, newNode.children[index], o, patches);
        });
    }else{
        // 文本類型
        if(!diffText(oldNode, newNode)){
            currentPatches.push({'type': 'text', 'content': newNode});
            patches[o.nid] = currentPatches;
        }
    }
}
function diff(oldNode, newNode){
    let patches = {}; //舊節點和新節點之間的差異結果
    let o = {nid: 0};    // 節點的編號
    // 遞歸遍歷oldNode、newNode 將差異結果保存到patches中
    traversal(oldNode, newNode, o, patches)
    return patches;
}

export {diff};

最后在index.js中調用這個方法,看看生成的patches是否正確。

// 創建一個新的node
let newNode = create('div', {'class': 'wapper', 'id': 'box'}, [
    create('h4', {}, ['內容']),
    create('ul', { 'style': 'list-style-type: none;border: 1px solid;padding: 20px;'}, [
                create('li', {}, ['內容一'])
    ])
])

let patches = diff(vdom, newNode);
console.log("最終的patches");
console.log(patches);

最后我們將代碼生成的patches和手動生成的patches進行一個對比,看看結果是否一樣。

可以看到兩者是一樣的,所以證明我們的diff是成功實現了。

將patches應用到頁面中

到此我簡單畫個圖總結一下前面我們已經完成的功能。

那我們的最后一步就是將diff出來的patches應用到realdom上。

這里呢,我先直接將代碼貼出來。

// 代碼位置:/virtual-dom/src/patch.js
import {render} from './render'
/**
 * @name: walk
 * @description: 遍歷patches 將差異應用到真實的DOM節點上
 * @param {HTMLElement} 真實的DOM節點
 * @param {Object} 虛擬節點的編號 編號從0開始,從patches中獲取編號為o.nid的虛擬DOM的差異
 * @param {Object}  使用diff算法比較出來新的虛擬節點和舊的虛擬節點的差異
 */
function walk(realdom, o, patchs){
    // 獲取當前節點的差異
    const currentPatch = patchs[o.nid];
    // 對當前節點進行DOM操作
    if (currentPatch) {
        applyPatch(realdom, currentPatch)
    }
    for(let i=0; i < realdom.childNodes.length; i++){
        let childNode = realdom.childNodes[i];
        o.nid++;
        walk(childNode, o, patchs); 
    }
}

/**
 * @name: applyPatch
 * @description: 應用差異到真實節點上
 * @param {HTMLElement} 需要更新的真實DOM節點
 * @param {Array}       節點需要更新的內容
 */
function applyPatch(currentRealNode, currentPatch){
    currentPatch.forEach(patch => {
        const type = patch['type'];
        switch(type){
            case 'props':
                const props = patch['props'];
                for(const propKey in props){
                    currentRealNode.setAttribute(propKey, props[propKey])
                }
                break;
            case 'replace':
                let content = patch['content'];
                let newEle = null;
                if(typeof(content) == "string"){
                    newEle = document.createTextNode(content);
                }else{
                    // 調用render將替換的節點渲染成真實的dom
                    newEle = render(content);
                }
                currentRealNode.parentNode.replaceChild(newEle, currentRealNode);
                break;
            case 'text':
                currentRealNode.textContent = patch['content']
                break;
            case 'remove':
                currentRealNode.parentNode.removeChild(currentRealNode)
        }
    });
}

export {walk};

接下來我們就分析一下patch.js中的代碼。

applyPatch

applyPatch函數的功能就是將差異對象應用到真實的DOM節點上。

函數的兩個參數為:currentRealNodecurrentPatch,分別表示的是需要更新的真實DOM節點節點需要更新的內容

舉個例子,如下:

前面我們生成的patches共有四種不同的類型,分別為:節點屬性變化、節點類型被替換、節點被移除、節點文本內容變化,所以在applyPatch函數中使用switch語句分別處理這四種不同的情況。

節點屬性發生變化
我們只需要將新的屬性(patch['props'])設置到當前節點上即可。  
節點類型被替換
節點類型被替換以后,我們的patch['type']值為'replace',對應的patch['content']為替換后虛擬DOM節點。
對於我們這篇文章中的示例來說,當執行到h3節點的時候,currentPatch的值為:
 	[
    	{
          type: 'replace',   // 節點發生替換
          content: {
          	type: 'h4', 
            {}, 
            children: ['內容']
           }
    	}
    ]
所以我們需要將patch['content']這個虛擬節點轉化為真實的節點,更新到整個文檔節點中。

由於本次我們的示例將h3節點替換成了h4,實際上有可能替換成文本內容,在replace的邏輯中會patch['content']的類型做了判斷,如果替換成文本內容,則只需要創建文本節點即可。

節點文本內容變化
節點文本內容發生變化,只需要為文本節點的textContet屬性賦新值即可。
節點被移除
節點被移除,調用當前節點的父級節點的removeChild移除當前節點即可。

applyPatch方法內部都是一些操作原生DOM節點的邏輯

總結

到此本篇文章就結束了,在此我們做一個簡單的總結。

關於什么是虛擬DOM

將真實的DOM節點抽象成為一個js對象,這個js對象就稱之為是虛擬DOM

關於dom-diff算法

dom-diff算法核心的幾個點就是:

1.將真實的DOM節點使用虛擬DOM表示(create)  
2.將虛擬DOM渲染到瀏覽器頁面上(render)  
3.當用戶操作界面修改數據后,會生成一個新的虛擬DOM,將新的虛擬DOM和舊的虛擬DOM進行對比,生成差異對象patches(diff)  
4.將差異對象應用到真實的DOM節點上(patch)  

為什么需要虛擬DOM

那在了解了虛擬DOM以及和虛擬DOM相關的dom-diff算法以后,我們肯定會思考為什么需要虛擬DOM這樣的東西。

原因一

虛擬DOM基於JavaScript對象,而真實的DOM要基於瀏覽器平台,所以虛擬DOM可以跨平台使用。

原因二:提高操作DOM的性能

我們都知道瀏覽器將一個HTML文檔轉化為真實的內容呈現到瀏覽器上的整個過程是需要經歷一系列的步驟:構建DOM樹、構建CSS規則樹、基於DOM樹CSS規則樹構建呈現樹(呈現樹是文檔的可視化表示)、根據呈現樹進行布局繪制。當有用戶交互需要改變文檔結構時,很大程度上會再一次觸發這一系列的操作。

假如用戶在一次交互中修改了10次DOM結構,那么就會觸發10次上述的步驟,所以說操作DOM的代價是很大的。

所以我們使用一個js對象來表示真實的DOM,當用戶在一次交互中修改了10DOM結構時,我們就可以將這10次的修改映射到這個js對象,之后比較之前的虛擬DOM和修改后的虛擬DOM,最后在將比較的差異應用到文檔中。那這樣的操作顯然會比直接更新10次真實的DOM要節省性能。

最后

本篇文章只針對虛擬DOMdom-diff做了簡單的總結和實踐,而vue框架內部在diff的時候還有一些更細節的處理,后續在vue源碼學習時會在做總結。

示例代碼

本文的源代碼可以 戳這里 獲取

參考文章

深入剖析:Vue核心之虛擬DOM
讓虛擬DOM和DOM-diff不再成為你的絆腳石
vue核心之虛擬DOM(vdom)
詳解Vue中的虛擬DOM

作者:小土豆

博客園:https://www.cnblogs.com/HouJiao/

掘金:https://juejin.im/user/2436173500265335

微信公眾號:不知名寶藏程序媛(關注"不知名寶藏程序媛"免費領取前端電子書籍。文章公眾號首發,關注公眾號第一時間獲取最新文章。)

碼字不易,點贊鼓勵喲~


免責聲明!

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



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