從零開始,手寫一個簡易的Virtual DOM

衆所周知,對前端而言,直接操做 DOM 是一件及其耗費性能的事情,以 React 和 Vue 爲表明的衆多框架廣泛採用 Virtual DOM 來解決現在愈發複雜 Web 應用中狀態頻繁發生變化致使的頻繁更新 DOM 的性能問題。本文爲筆者經過實際操做,實現了一個很是簡單的 Virtual DOM ,加深對現今主流前端框架中 Virtual DOM 的理解。javascript

關於 Virtual DOM ,社區已經有許多優秀的文章,而本文是筆者採用本身的方式,並有所借鑑前輩們的實現,以淺顯易懂的方式,對 Virtual DOM 進行簡單實現,但不包含snabbdom的源碼分析,在筆者的最終實現裏,參考了snabbdom的原理,將本文的Virtual DOM實現進行了改進,感興趣的讀者能夠閱讀上面幾篇文章,並參考筆者本文的最終代碼進行閱讀。html

本文閱讀時間約15~20分鐘。前端

概述

本文分爲如下幾個方面來說述極簡版本的 Virtual DOM 核心實現:java

  • Virtual DOM 主要思想
  • 用 JavaScript 對象表示 DOM 樹
  • 將 Virtual DOM 轉換爲真實 DOM
    • 設置節點的類型
    • 設置節點的屬性
    • 對子節點的處理
  • 處理變化
    • 新增與刪除節點
    • 更新節點
    • 更新子節點

Virtual DOM 主要思想

要理解 Virtual DOM 的含義,首先須要理解 DOM ,DOM 是針對 HTML 文檔和 XML 文檔的一個 API , DOM 描繪了一個層次化的節點樹,經過調用 DOM API,開發人員能夠任意添加,移除和修改頁面的某一部分。而 Virtual DOM 則是用 JavaScript 對象來對 Virtual DOM 進行抽象化的描述。Virtual DOM 的本質是JavaScript對象,經過 Render函數,能夠將 Virtual DOM 樹 映射爲 真實 DOM 樹。node

一旦 Virtual DOM 發生改變,會生成新的 Virtual DOM ,相關算法會對比新舊兩顆 Virtual DOM 樹,並找到他們之間的不一樣,儘量地經過最少的 DOM 操做來更新真實 DOM 樹。react

咱們能夠這麼表示 Virtual DOM 與 DOM 的關係:DOM = Render(Virtual DOM)webpack

用 JavaScript 對象表示 DOM 樹

Virtual DOM 是用 JavaScript 對象表示,並存儲在內存中的。主流的框架均支持使用 JSX 的寫法, JSX 最終會被 babel 編譯爲JavaScript 對象,用於來表示Virtual DOM,思考下列的 JSX:git

<div>
    <span className="item">item</span>
    <input disabled={true} />
</div>
複製代碼

最終會被babel編譯爲以下的 JavaScript對象:github

{
    type: 'div',
    props: null,
    children: [{
        type: 'span',
        props: {
            class: 'item',
        },
        children: ['item'],
    }, {
        type: 'input',
        props: {
            disabled: true,
        },
        children: [],
    }],
}
複製代碼

咱們能夠注意到如下兩點:web

  • 全部的 DOM 節點都是一個相似於這樣的對象:
{ type: '...', props: { ... }, children: { ... }, on: { ... } }
複製代碼
  • 本文節點是用 JavaScript 字符串來表示

那麼 JSX 又是如何轉化爲 JavaScript 對象的呢。幸運的是,社區有許許多多優秀的工具幫助咱們完成了這件事,因爲篇幅有限,本文對這個問題暫時不作探討。爲了方便你們更快速地理解 Virtual DOM ,對於這一個步驟,筆者使用了開源工具來完成。著名的 babel 插件babel-plugin-transform-react-jsx幫助咱們完成這項工做。

爲了更好地使用babel-plugin-transform-react-jsx,咱們須要搭建一下webpack開發環境。具體過程這裏不作闡述,有興趣本身實現的同窗能夠到simple-virtual-dom查看代碼。

對於不使用 JSX 語法的同窗,能夠不配置babel-plugin-transform-react-jsx,經過咱們的vdom函數建立 Virtual DOM:

function vdom(type, props, ...children) {
    return {
        type,
        props,
        children,
    };
}
複製代碼

而後咱們能夠經過以下代碼建立咱們的 Virtual DOM 樹:

const vNode = vdom('div', null,
    vdom('span', { class: 'item' }, 'item'),
    vdom('input', { disabled: true })
);

複製代碼

在控制檯輸入上述代碼,能夠看到,已經建立好了用 JavaScript對象表示的 Virtual DOM 樹:

將 Virtual DOM 轉換爲真實 DOM

如今咱們知道了如何用 JavaScript對象 來表明咱們的真實 DOM 樹,那麼, Virtual DOM 又是怎麼轉換爲真實 DOM 給咱們呈現的呢?

在這以前,咱們要先知道幾項注意事項:

  • 在代碼中,筆者將以$開頭的變量來表示真實 DOM 對象;
  • toRealDom函數接受一個 Virtual DOM 對象爲參數,將返回一個真實 DOM 對象;
  • mount函數接受兩個參數:將掛載 Virtual DOM 對象的父節點,這是一個真實 DOM 對象,命名爲$parent;以及被掛載的 Virtual DOM 對象vNode

下面是toRealDom的函數原型:

function toRealDom(vNode) {
    let $dom;
    // do something with vNode
    return $dom;
}
複製代碼

經過toRealDom方法,咱們能夠將一個vNode對象轉化爲一個真實 DOM 對象,而mount函數經過appendChild,將真實 DOM 掛載:

function mount($parent, vNode) {
    return $parent.appendChild(toRealDom(vNode));
}
複製代碼

下面,讓咱們來分別處理vNodetypepropschildren

設置節點的類型

首先,由於咱們同時具備字符類型的文本節點和對象類型的element節點,須要對type作單獨的處理:

if (typeof vNode === 'string') {
    $dom = document.createTextNode(vNode);
} else {
    $dom = document.createElement(vNode.type);
}
複製代碼

在這樣一個簡單的toRealDom函數中,對type的處理就完成了,接下來讓咱們看看對props的處理。

設置節點的屬性

咱們知道,若是節點有props,那麼props是一個對象。經過遍歷props,調用setProp方法,對每一類props單獨處理。

if (vNode.props) {
    Object.keys(vNode.props).forEach(key => {
        setProp($dom, key, vNode.props[key]);
    });
}
複製代碼

setProp接受三個參數:

  • $target,這是一個真實 DOM 對象,setProp將對這個節點進行 DOM 操做;
  • name,表示屬性名;
  • value,表示屬性的值;

讀到這裏,相信你已經大概清楚setProp須要作什麼了,通常狀況下,對於普通的props,咱們會經過setAttribute給 DOM 對象附加屬性。

function setProp($target, name, value) {
    return $target.setAttribute(name, value);
}
複製代碼

但這遠遠不夠,思考下列的 JSX 結構:

<div>
    <span className="item" data-node="item" onClick={() => console.log('item')}>item</span>
    <input disabled={true} />
</div>
複製代碼

從上面的 JSX 結構中,咱們發現如下幾點:

  • 因爲class是 JavaScript 的保留字, JSX 通常使用className來表示 DOM 節點所屬的class
  • 通常以on開頭的屬性來表示事件;
  • 除字符類型外,屬性還多是布爾值,如disabled,當該值爲true時,則添加這一屬性;

因此,setProp也一樣須要考慮上述狀況:

function isEventProp(name) {
    return /^on/.test(name);
}

function extractEventName(name) {
    return name.slice(2).toLowerCase();
}

function setProp($target, name, value) {
    if (name === 'className') { // 由於class是保留字,JSX使用className來表示節點的class
        return $target.setAttribute('class', value);
    } else if (isEventProp(name)) { // 針對 on 開頭的屬性,爲事件
        return $target.addEventListener(extractEventName(name), value);
    } else if (typeof value === 'boolean') { // 兼容屬性爲布爾值的狀況
        if (value) {
            $target.setAttribute(name, value);
        }
        return $target[name] = value;
    } else {
        return $target.setAttribute(name, value);
    }
}
複製代碼

最後,還有一類屬性是咱們的自定義屬性,例如主流框架中的組件間的狀態傳遞,即經過props來進行傳遞的,咱們並不但願這一類屬性顯示在 DOM 中,所以須要編寫一個函數isCustomProp來檢查這個屬性是不是自定義屬性,由於本文只是爲了實現 Virtual DOM 的核心思想,爲了方便,在本文中,這個函數直接返回false

function isCustomProp(name) {
    return false;
}
複製代碼

最終的setProp函數:

function setProp($target, name, value) {
    if (isCustomProp(name)) {
        return;
    } else if (name === 'className') { // fix react className
        return $target.setAttribute('class', value);
    } else if (isEventProp(name)) {
        return $target.addEventListener(extractEventName(name), value);
    } else if (typeof value === 'boolean') {
        if (value) {
            $target.setAttribute(name, value);
        }
        return $target[name] = value;
    } else {
        return $target.setAttribute(name, value);
    }
}
複製代碼

對子節點的處理

對於children裏的每一項,都是一個vNode對象,在進行 Virtual DOM 轉化爲真實 DOM 時,子節點也須要被遞歸轉化,能夠想到,針對有子節點的狀況,須要對子節點以此遞歸調用toRealDom,以下代碼所示:

if (vNode.children && vNode.children.length) {
    vNode.children.forEach(childVdom => {
        const realChildDom = toRealDom(childVdom);
        $dom.appendChild(realChildDom);
    });
}
複製代碼

最終完成的toRealDom以下:

function toRealDom(vNode) {
    let $dom;
    if (typeof vNode === 'string') {
        $dom = document.createTextNode(vNode);
    } else {
        $dom = document.createElement(vNode.type);
    }

    if (vNode.props) {
        Object.keys(vNode.props).forEach(key => {
            setProp($dom, key, vNode.props[key]);
        });
    }

    if (vNode.children && vNode.children.length) {
        vNode.children.forEach(childVdom => {
            const realChildDom = toRealDom(childVdom);
            $dom.appendChild(realChildDom);
        });
    }

    return $dom;
}
複製代碼

處理變化

Virtual DOM 之因此被創造出來,最根本的緣由是性能提高,經過 Virtual DOM ,開發者能夠減小許多沒必要要的 DOM 操做,以達到最優性能,那麼下面咱們來看看 Virtual DOM 算法 是如何經過對比更新前的 Virtual DOM 樹和更新後的 Virtual DOM 樹來實現性能優化的。

注:本文是筆者的最簡單實現,目前社區廣泛通用的算法是snabbdom,如 Vue 則是借鑑該算法實現的 Virtual DOM ,有興趣的讀者能夠查看這個庫的源代碼,基於本文的 Virtual DOM 的小示例,筆者最終也參考了該算法實現,本文demo傳送門,因爲篇幅有限,感興趣的讀者能夠自行研究。

爲了處理變化,首先聲明一個updateDom函數,這個函數接受如下四個參數:

  • $parent,表示將被掛載的父節點;
  • oldVNode,舊的VNode對象;
  • newVNode,新的VNode對象;
  • index,在更新子節點時使用,表示當前更新第幾個子節點,默認爲0;

函數原型以下:

function updateDom($parent, oldVNode, newVNode, index = 0) {

}
複製代碼

新增與刪除節點

首先咱們來看新增一個節點的狀況,對於本來沒有該節點,須要添加新的一個節點到 DOM 樹中,咱們須要經過appendChild來實現:

轉化爲代碼表述爲:

// 沒有舊的節點,添加新的節點
if (!oldVNode) {
    return $parent.appendChild(toRealDom(newVNode));
}
複製代碼

同理,對於刪除一箇舊節點的狀況,咱們經過removeChild來實現,在這裏,咱們應該從真實 DOM 中將舊的節點刪掉,但問題是在這個函數中是直接取不到這一個節點的,咱們須要知道這個節點在父節點中的位置,事實上,能夠經過$parent.childNodes[index]來取到,這即是上面提到的爲什麼須要傳入index,它表示當前更新的節點在父節點中的索引:

轉化爲代碼表述爲:

const $currentDom = $parent.childNodes[index];

// 沒有新的節點,刪除舊的節點
if (!newVNode) {
    return $parent.removeChild($currentDom);
}
複製代碼

更新節點

Virtual DOM 的核心在於如何高效更新節點,下面咱們來看看更新節點的狀況。

首先,針對文本節點,咱們能夠簡單處理,對於文本節點是否發生改變,只須要經過比較其新舊字符串是否相等便可,若是是相同的文本節點,是不須要咱們更新 DOM 的,在updateDom函數中,直接return便可:

// 都是文本節點,都沒有發生變化
if (typeof oldVNode === 'string' && typeof newVNode === 'string' && oldVNode === newVNode) {
    return;
}
複製代碼

接下來,考慮節點是否真的須要更新,如圖所示,一個節點的類型從span換成了div,顯而易見,這是必定須要咱們去更新DOM的:

咱們須要編寫一個函數isNodeChanged來幫助咱們判斷舊節點和新節點是否真的一致,若是不一致,須要咱們把節點進行替換:

function isNodeChanged(oldVNode, newVNode) {
    // 一個是textNode,一個是element,必定改變
    if (typeof oldVNode !== typeof newVNode) {
        return true;
    }

    // 都是textNode,比較文本是否改變
    if (typeof oldVNode === 'string' && typeof newVNode === 'string') {
        return oldVNode !== newVNode;
    }

    // 都是element節點,比較節點類型是否改變
    if (typeof oldVNode === 'object' && typeof newVNode === 'object') {
        return oldVNode.type !== newVNode.type;
    }
}
複製代碼

updateDom中,發現節點類型發生變化,則將該節點直接替換,以下代碼所示,經過調用replaceChild,將舊的 DOM 節點移除,並將新的 DOM 節點加入:

if (isNodeChanged(oldVNode, newVNode)) {
    return $parent.replaceChild(toRealDom(newVNode), $currentDom);
}
複製代碼

但這遠遠尚未結束,考慮下面這種狀況:

<!-- old -->
<div class="item" data-item="old-item"></div>
複製代碼
<!-- new -->
<div id="item" data-item="new-item"></div>
複製代碼

對比上面的新舊兩個節點,發現節點類型並無發生改變,即VNode.type都是'div',可是節點的屬性卻發生了改變,除了針對節點類型的變化更新 DOM 外,針對節點的屬性的改變,也須要對應把 DOM 更新。

與上述方法相似,咱們編寫一個isPropsChanged函數,來判斷新舊兩個節點的屬性是否有發生變化:

function isPropsChanged(oldProps, newProps) {
    // 類型都不一致,props確定發生變化了
    if (typeof oldProps !== typeof newProps) {
        return true;
    }

    // props爲對象
    if (typeof oldProps === 'object' && typeof newProps === 'object') {
        const oldKeys = Object.keys(oldProps);
        const newkeys = Object.keys(newProps);
        // props的個數都不同,必定發生了變化
        if (oldKeys.length !== newkeys.length) {
            return true;
        }
        // props的個數相同的狀況,遍歷props,看是否有不一致的props
        for (let i = 0; i < oldKeys.length; i++) {
            const key = oldKeys[i]
            if (oldProps[key] !== newProps[key]) {
                return true;
            }
        }
        // 默認未改變
        return false;
    }

    return false;
}
複製代碼

由於當節點沒有任何屬性時,propsnullisPropsChanged首先判斷新舊兩個節點的props是不是同一類型,便是否存在舊節點的propsnull,新節點有新的屬性,或者反之:新節點的propsnull,舊節點的屬性被刪除了。若是類型不一致,那麼屬性必定是被更新的。

接下來,考慮到節點在更新先後都有props的狀況,咱們須要判斷更新先後的props是否一致,即兩個對象是否全等,遍歷便可。若是有不相等的屬性,則認爲props發生改變,須要處理props的變化。

如今,讓咱們回到咱們的updateDom函數,看看是把Virtual DOM 節點props的更新應用到真實 DOM 上的。

// 虛擬DOM的type未改變,對比節點的props是否改變
const oldProps = oldVNode.props || {};
const newProps = newVNode.props || {};
if (isPropsChanged(oldProps, newProps)) {
    const oldPropsKeys = Object.keys(oldProps);
    const newPropsKeys = Object.keys(newProps);

    // 若是新節點沒有屬性,把舊的節點的屬性清除掉
    if (newPropsKeys.length === 0) {
        oldPropsKeys.forEach(propKey => {
            removeProp($currentDom, propKey, oldProps[propKey]);
        });
    } else {
        // 拿到全部的props,以此遍歷,增長/刪除/修改對應屬性
        const allPropsKeys = new Set([...oldPropsKeys, ... newPropsKeys]);
        allPropsKeys.forEach(propKey => {
            // 屬性被去除了
            if (!newProps[propKey]) {
                return removeProp($currentDom, propKey, oldProps[propKey]);
            }
            // 屬性改變了/增長了
            if (newProps[propKey] !== oldProps[propKey]) {
                return setProp($currentDom, propKey, newProps[propKey]);
            }
        });
    }
}
複製代碼

上面的代碼也很是好理解,若是發現props改變了,那麼對舊的props的每項去作遍歷。把不存在的屬性清除,再把新增長的屬性加入到更新後的 DOM 樹中:

  • 首先,若是新的節點沒有屬性,遍歷刪除全部舊的節點的屬性,在這裏,咱們經過調用removeProp刪除。removePropsetProp相對應,因爲本文篇幅有限,筆者在這裏就不作過多闡述;
function removeProp($target, name, value) {
    if (isCustomProp(name)) {
        return;
    } else if (name === 'className') { // fix react className
        return $target.removeAttribute('class');
    } else if (isEventProp(name)) {
        return $target.removeEventListener(extractEventName(name), value);
    } else if (typeof value === 'boolean') {
        $target.removeAttribute(name);
        $target[name] = false;
    } else {
        $target.removeAttribute(name);
    }
}
複製代碼
  • 若是新節點有屬性,那麼拿到舊節點和新節點全部屬性,遍歷新舊節點的全部屬性,若是屬性在新節點中沒有,那麼說明該屬性被刪除了。若是新的節點與舊的節點屬性不一致/或者是新增的屬性,則調用setProp給真實 DOM 節點添加新的屬性。

更新子節點

在最後,與toRealDom相似的是,在updateDom中,咱們也應當處理全部子節點,對子節點進行遞歸調用updateDom,一個一個對比全部子節點的VNode是否有更新,一旦VNode有更新,則真實 DOM 也須要從新渲染:

// 根節點相同,但子節點不一樣,要遞歸對比子節點
if (
    (oldNode.children && oldNode.children.length) ||
    (newNode.children && newNode.children.length)
) {
    for (let i = 0; i < oldNode.children.length || i < newNode.children.length; i++) {
        updateDom($currentDom, oldNode.children[i], newNode.children[i], i);
    }
}
複製代碼

遠遠沒有結束

以上是筆者實現的最簡單的 Virtual DOM 代碼,但這與社區咱們所用到 Virtual DOM 算法是有天壤之別的,筆者在這裏舉個最簡單的例子:

<!-- old -->
<ul>
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
</ul>
複製代碼
<!-- new -->
<ul>
    <li>5</li>
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
</ul>
複製代碼

對於上述代碼中實現的updateDom函數而言,更新先後的 DOM 結構如上所示,則會觸發五個li節點所有從新渲染,這顯然是一種性能的浪費。而snabbdom則經過移動節點的方式較好地解決了上述問題,因爲本文篇幅有限,而且社區也有許多對該 Virtual DOM 算法的分析文章,筆者就不在本文作過多闡述了,有興趣的讀者能夠到自行研究。筆者也基於本文實例,參考snabbdom算法實現了最終的版本,有興趣的讀者能夠查看本文示例最終版

相關文章
相關標籤/搜索