你不知道的Virtual DOM(二):Virtual Dom的更新

歡迎關注個人公衆號睿Talk,獲取我最新的文章:
clipboard.pngjavascript

1、前言

目前最流行的兩大前端框架,React 和 Vue,都不約而同的藉助 Virtual DOM 技術提升頁面的渲染效率。那麼,什麼是 Virtual DOM ?它是經過什麼方式去提高頁面渲染效率的呢?本系列文章會詳細講解 Virtual DOM 的建立過程,並實現一個簡單的 Diff 算法來更新頁面。本文的內容脫離於任何的前端框架,只講最純粹的 Virtual DOM 。敲單詞太累了,下文 Virtual DOM 一概用 VD 表示。css

這是 VD 系列文章的第二篇,如下是本系列其它文章的傳送門:
你不知道的 Virtual DOM(一):Virtual Dom 介紹
你不知道的 Virtual DOM(二):Virtual Dom 的更新
你不知道的 Virtual DOM(三):Virtual Dom 更新優化
你不知道的 Virtual DOM(四):key 的做用
你不知道的 Virtual DOM(五):自定義組件
你不知道的 Virtual DOM(六):事件處理 & 異步更新前端

本文將會實現一個簡單的 VD Diff 算法,計算出差別並反映到真實的 DOM 上去。java

2、思路

使用 VD 的框架,通常的設計思路都是頁面等於頁面狀態的映射,即UI = render(state)。當須要更新頁面的時候,無需關心 DOM 具體的變換方式,只須要改變state便可,剩下的事情(render)將由框架代勞。咱們考慮最簡單的狀況,當 state 發生變化時,咱們從新生成整個 VD ,觸發比較的操做。上述過程分爲如下四步:node

  • state 變化,生成新的 VD
  • 比較 VD 與以前 VD 的異同
  • 生成差別對象(patch
  • 遍歷差別對象並更新 DOM

差別對象的數據結構是下面這個樣子,與每個 VDOM 元素一一對應:git

{
    type,
    vdom,
    props: [{
               type,
               key,
               value 
            }]
    children
}

最外層的 type 對應的是 DOM 元素的變化類型,有 4 種:新建、刪除、替換和更新。props 變化的 type 只有2種:更新和刪除。枚舉值以下:github

const nodePatchTypes = {
    CREATE: 'create node',
    REMOVE: 'remove node',
    REPLACE: 'replace node',
    UPDATE: 'update node'
}

const propPatchTypes = {
    REMOVE: 'remove prop',
    UPDATE: 'update prop'
}

3、代碼實現

咱們作一個定時器,500 毫秒運行一次,每次對 state 加 1。頁面的li元素的數量隨着 state 而變。算法

let state = { num: 5 };
let timer;
let preVDom;

function render(element) {
    // 初始化的VD
    const vdom = view();
    preVDom = vdom;

    const dom = createElement(vdom);
    element.appendChild(dom);

    
    timer = setInterval(() => {
        state.num += 1;
        tick(element);
    }, 500);
}

function tick(element) {
    if (state.num > 20) {
        clearTimeout(timer);
        return;
    }

    const newVDom = view();
}

function view() {
    return (
        <div>
            Hello World
            <ul>
                {
                    // 生成元素爲0到n-1的數組
                    [...Array(state.num).keys()]
                        .map( i => (
                            <li id={i} class={`li-${i}`}>
                                第{i * state.num}
                            </li>
                        ))
                }
            </ul>
        </div>
    );
}

接下來,經過對比 2 個 VD,生成差別對象。segmentfault

function tick(element) {
    if (state.num > 20) {
        clearTimeout(timer);
        return;
    }

    const newVDom = view();

    // 生成差別對象
    const patchObj = diff(preVDom, newVDom);
}

function diff(oldVDom, newVDom) {
    // 新建 node
    if (oldVDom == undefined) {
        return {
            type: nodePatchTypes.CREATE,
            vdom: newVDom
        }
    }

    // 刪除 node
    if (newVDom == undefined) {
        return {
            type: nodePatchTypes.REMOVE
        }
    }

    // 替換 node
    if (
        typeof oldVDom !== typeof newVDom ||
        ((typeof oldVDom === 'string' || typeof oldVDom === 'number') && oldVDom !== newVDom) ||
        oldVDom.tag !== newVDom.tag
    ) {
       return {
           type: nodePatchTypes.REPLACE,
           vdom: newVDom
       } 
    }

    // 更新 node
    if (oldVDom.tag) {
        // 比較 props 的變化
        const propsDiff = diffProps(oldVDom, newVDom);

        // 比較 children 的變化
        const childrenDiff = diffChildren(oldVDom, newVDom);
        
        // 若是 props 或者 children 有變化,才須要更新
        if (propsDiff.length > 0 || childrenDiff.some( patchObj => (patchObj !== undefined) )) {
            return {
                type: nodePatchTypes.UPDATE,
                props: propsDiff,
                children: childrenDiff
            }   
        }
        
    }
}

// 比較 props 的變化
function diffProps(oldVDom, newVDom) {
    const patches = [];

    const allProps = {...oldVDom.props, ...newVDom.props};

    // 獲取新舊全部屬性名後,再逐一判斷新舊屬性值
    Object.keys(allProps).forEach((key) => {
            const oldValue = oldVDom.props[key];
            const newValue = newVDom.props[key];

            // 刪除屬性
            if (newValue == undefined) {
                patches.push({
                    type: propPatchTypes.REMOVE,
                    key
                });
            } 
            // 更新屬性
            else if (oldValue == undefined || oldValue !== newValue) {
                patches.push({
                    type: propPatchTypes.UPDATE,
                    key,
                    value: newValue
                });
            }
        }
    )

    return patches;
}

// 比較 children 的變化
function diffChildren(oldVDom, newVDom) {
    const patches = [];
    
    // 獲取子元素最大長度
    const childLength = Math.max(oldVDom.children.length, newVDom.children.length);

    // 遍歷並diff子元素
    for (let i = 0; i < childLength; i++) {
        patches.push(diff(oldVDom.children[i], newVDom.children[i]));
    }

    return patches;
}

計算得出的差別對象是這個樣子的:數組

{
    type: "update node",
    props: [],
    children: [
        null, 
        {
            type: "update node",
            props: [],
            children: [
                null, 
                {
                    type: "update node",
                    props: [],
                    children: [
                        null, 
                        {
                            type: "replace node",
                            vdom: 6
                        }
                    ]
                }
            ]
        },
        {
            type: "create node",
            vdom: {
                tag: "li",
                props: {
                    id: 5,
                    class: "li-5"
                },
                children: ["第", 30]
            }
        }
    ]
}

下一步就是遍歷差別對象並更新 DOM 了:

function tick(element) {
    if (state.num > 20) {
        clearTimeout(timer);
        return;
    }

    const newVDom = view();

    // 生成差別對象
    const patchObj = diff(preVDom, newVDom);

    preVDom = newVDom;

    // 給 DOM 打個補丁
    patch(element, patchObj);
}

// 給 DOM 打個補丁
function patch(parent, patchObj, index=0) {
    if (!patchObj) {
        return;
    }

    // 新建元素
    if (patchObj.type === nodePatchTypes.CREATE) {
        return parent.appendChild(createElement(patchObj.vdom));
    }

    const element = parent.childNodes[index];

    // 刪除元素
    if (patchObj.type === nodePatchTypes.REMOVE) {
        return parent.removeChild(element);
    }

    // 替換元素
    if (patchObj.type === nodePatchTypes.REPLACE) {
        return parent.replaceChild(createElement(patchObj.vdom), element);
    }

    // 更新元素
    if (patchObj.type === nodePatchTypes.UPDATE) {
        const {props, children} = patchObj;

        // 更新屬性
        patchProps(element, props);

        // 更新子元素
        children.forEach( (patchObj, i) => {
            // 更新子元素時,須要將子元素的序號傳入
            patch(element, patchObj, i)
        });
    }
}

// 更新屬性
function patchProps(element, props) {
    if (!props) {
        return;
    }

    props.forEach( patchObj => {
        // 刪除屬性
        if (patchObj.type === propPatchTypes.REMOVE) {
            element.removeAttribute(patchObj.key);
        } 
        // 更新或新建屬性
        else if (patchObj.type === propPatchTypes.UPDATE) {
            element.setAttribute(patchObj.key, patchObj.value);
        }
    })
}

到此爲止,整個更新的流程就執行完了。能夠看到頁面跟咱們預期的同樣,每 500 毫秒刷新一次,構造渲染樹和繪製頁面花的時間也很是少。
clipboard.png

做爲對比,若是咱們在生成新的 VD 後,不通過比較,而是直接從新渲染整個 DOM 的時候,會怎樣呢?咱們修改一下代碼:

function tick(element) {
    if (state.num > 20) {
        clearTimeout(timer);
        return;
    }

    const newVDom = view();
    newDom = createElement(newVDom);

    element.replaceChild(newDom, dom);

    dom = newDom;

    /*
    // 生成差別對象
    const patchObj = diff(preVDom, newVDom);

    preVDom = newVDom;

    // 給 DOM 打個補丁
    patch(element, patchObj);
    */
}

效果以下:
clipboard.png

能夠看到,構造渲染樹(Rendering)和繪製頁面(Painting)的時間要多一些。但另外一方面花在 JS 計算(Scripting)的時間要少一些,由於不須要比較節點的變化。若是算總時間的話,從新渲染整個 DOM 花費的時間反而更少,這是爲何呢?

其實緣由很簡單,由於咱們的 DOM 樹太簡單了!節點不多,使用到的 css 也不多,因此構造渲染樹和繪製頁面就花不了多少時間。VD 真正的效果仍是要在真實的項目中才體現得出來。

4、總結

本文詳細介紹如何實現一個簡單的 VD Diff 算法,再根據計算出的差別去更新真實的 DOM 。而後對性能作了一個簡單的分析,得出使用 VD 在減小渲染時間的同時增長了 JS 計算時間的結論。基於當前這個版本的代碼還能作怎樣的優化呢,請看下一篇的內容:你不知道的Virtual DOM(三):Virtual Dom更新優化

P.S.: 想看完整代碼見這裏,若是有必要建一個倉庫的話請留言給我:代碼

相關文章
相關標籤/搜索