了不得的Virtual DOM(二): 使用TypeScript開發簡易Virtual DOM庫

前言

  首先歡迎你們關注、點贊、收藏個人掘金帳號和Github博客,也算是對個人一點鼓勵,畢竟寫東西無法得到變現,能堅持下去也是靠的是本身的熱情和你們的鼓勵。以前的文章咱們介紹了MV*框架的歷史以及React引入Virtual DOM所帶來的新的解決思路,俗話說,百聞不如一見,百見不如一干。這篇文章咱們將嘗試使用去實現一個Virtual DOM的最小化實現方案,由於最近剛學了TypeScript,正好拿來練手。源碼地址將在文章最後附錄。
  javascript

回顧

  不管是MVC模式仍是後來改進的MVP模式以及目前更爲常見的MVVM模式,其目的都是爲了解決Model層和View如何鏈接,經過採用各類中間層(Controller、Presenter、View of Model)協調View與Model的關係。可是React所倡導的Virtual DOM方案卻劍走偏鋒,即每次Model層的變化都會從新渲染View層,那麼做爲開發者而言,只須要處理好數據和視圖映射,從而將咱們的關注重點集中於數據和數據流的變化,從而極大的下降開發關注度。css

  實際上咱們都知道瀏覽器對DOM的操做所帶來的渲染重繪相比於JavaScript計算速度確定是慢上好幾個數量級的。假設僅僅只是頁面中一個數據的變化就重繪整個頁面,那確定是咱們所不能接受的。借鑑計算機學科中最經常使用的Cache思想,咱們在低速的DOM操做和高速的JavaScript執行之間引入了Virtual DOM,經過對比兩個Virtual DOM節點的變化,找出其中的變化,從而精準地修改DOM節點,在實現思路的同時儘量地下降操做代價,達到良好的性能體驗。html

  衆所周知,把大象裝到冰箱須要三步,那麼實現一個Virtual DOM庫須要幾步呢?前端

  上圖就是咱們要實現Virtual DOM的基本流程:java

  • 建立Virtual DOM節點
  • 渲染Virtual DOM樹
  • Diff算法比較兩個Virtual DOM樹,獲得結果Patch
  • 應用Patch,更新DOM樹

  上面的四個步驟也就基本對應着咱們所要實現Virtual DOM的四個函數:node

  • createElement
  • render
  • diff
  • applyDiff

  乍一看想要實現Virtual DOM庫可能感受很有難度,可是通過仔細的分析,其實將問題轉化成實現四個特定功能的函數。其實這種思惟方式在咱們軟件開發中仍是很是的實用的,當目標過大而無從下手時,要學會將目標合理拆分。React所倡導的前端組件化其實就包含這個思想,組件化最重要的兩個特色就是:複用和分治,咱們每每過於強調複用的特性。其實相比複用,分治纔是組件化的精髓,咱們經過劃分組件,每每使得特定組件僅具備相對較爲簡單的職責功能,而後經過組合簡單的組件成爲複雜的功能。相比而言,維護功能職責簡單的組件更爲容易,也不容易出錯。接下來咱們要作的就是一步步實現各個函數功能,最終實現一個簡單的Virtural DOM庫。react

建立Virtual DOM節點

  在此以前,咱們首先簡要介紹JSX的做用,由React發揚光大的JSX語法使得咱們更爲方便的在JavaScript中建立HTML,描述UI界面。JSX語法並非某個庫所獨有的,而是一種JavaScript函數調用的語法糖,JSX其實至關於JavaScript + HTML(也被稱爲hyperscript,即hyper + script,hyper是HyperText超文本的簡寫,而script是JavaScript的簡寫)。在React中,JSX語法都會轉化成React.createElement調用,而在Preact中,JSX語法則會被轉成preact.h函數調用。git

例如在React中:github

<ul>
    <li>列表1</li>
    <li>列表2</li>
    <li>列表3</li>
</ul>

則會被轉化爲:算法

React.createElement(
    'ul',
    null,
    React.createElement('li', null, '列表1'),
    React.createElement('li', null, '列表2'),
    React.createElement('li', null, '列表3')
);

  其中createElement的參數依次是元素類型、屬性、以及子元素。類型元素能夠分爲三種,依次是:字符串、函數、類,依次對應於HTML固有元素、無狀態函數組件 (SFC)、類組件。本篇文章重點只在於闡釋Virtual DOM基本原理,所以簡單起見,咱們僅支持HTML固有元素,暫不支持無狀態函數組件 (SFC)和類組件。

  JSX能夠根據使用的框架編譯成不一樣的函數調用,例如React的React.createElement或者Preact的h,咱們能夠經過在JSX上添加編譯註釋(Pragma)來局部改變,例如:

/** @jsx h */
let dom = <div id="foo">Hello!</div>;

  經過爲JSX添加註釋@jsx(這也被成爲Pragma,即編譯註釋),可使得Babel在轉化JSX代碼時,將其裝換成函數h的調用。固然,也能夠在工程全局進行配置,好比咱們能夠在Babel6中的.babelrc文件中設置:

{
  "plugins": [
    ["transform-react-jsx", { "pragma":"h" }]
  ]
}

  這樣工程中全部用到JSX的地方都是被Babel轉化成使用h函數的調用。在TypeScript中咱們能夠經過更改配置文件tsconfig.json中的jsxFactory來控制JSX的編譯,具體可參照TypeScript中關於JSX的文檔,再也不此處贅述。

  根據Virtual DOM節點的特色,咱們給出Virtual DOM節點類描述:

// 類型別名
type TagNameType = string;

type KeyType = string | number | null;

interface PropsType {
    key?: string | number;
    [prop: string]: any;
}

// 類
class VNode {
    // 節點類型
    public tagName: TagNameType;
    // 屬性
    public props: PropsType;
    // key
    public key? : KeyType;
    // 子元素
    public children: (VNode | string)[];

    public constructor(tagName: TagNameType) {
        this.tagName = tagName;
        this.key = null;
        this.children = [];
        this.props = {};
    }
}

  其中tagName爲元素類型,例如:divp。由於咱們暫時僅支持HTML固有元素,所以TagNameType是字符串類型。props爲元素屬性,在接口PropsType咱們規定屬性中的key值必須爲number或者string或者null(null不傳key屬性),若是對key有不明白的同窗,歡迎你們閱讀我以前的文章:React技術內幕:key帶來了什麼

  接下來讓咱們看一下createElement函數的定義:

function createElement(tagName: TagNameType, props: PropsType, ...children: any[]) {

    let key: KeyType = null;

    if (isNotNull(props)) {

        if (isKey(props.key)) {
            key = props.key!;
            delete props.key;
        }

        if (isNotNull(props.children)) {
            children.push(props.children);
            delete props.children;
        }
    }

    const node = new VNode(tagName);
    node.children = flatten(children);
    node.key = key;
    node.props = isNotNull(props) ? props : {};
    return node;
}

  若是props中含有key屬性,則會將其從props中刪除,單獨賦值給VNode的key屬性,而處理props中的children屬性主要目的是爲了處理如下狀況經過props中的children屬性直接傳遞子元素。而對children調用flatten主要是爲了處理:

const dom = (
    <ul>
    {
        Array.from({length: 3}).map((val, index)=>{
            return (<li key={index}>列表</li>)
        })
    }
    </ul>
);

  在這種狀況下createElement中的chilren[0]是子元素數組,所以咱們使用flatten函數將其處理普通的子元素數組。

  經過createElement函數咱們就能夠將JSX轉化成Virtual DOM節點,寫個單元測試驗證一下是否正確:

describe('createElement', () => {
    test('多個子元素-數組形式', () => {
        const dom = (
            <ul>
                {
                    Array.from({ length: 2 }).map((val, index) => {
                        return <li key={index}></li>;
                    })
                }
            </ul>
        );
        const ul = new VNode('ul');
        ul.children = Array.from({ length: 2 }).map((val, index) => {
            const li = new VNode('li');
            li.key = index;
            return li;
        });
        expect(dom).toEqual(ul);
    });
});

  運行一下,Bingo,測試經過。

渲染Virtual DOM樹

  將Virtual DOM樹渲染成真實DOM函數也並不複雜:

const renderDOM = function (vnode: VNodeChildType) {

    if (isVNode(vnode)) {
        let node = document.createElement(vnode.tagName);
        // 設置元素屬性
        for (const prop of Object.keys(vnode.props)) {
            let value = vnode.props[prop];
            if (prop === 'style') {
                value = transformStyleToString(value);
            }
            node.setAttribute(prop, value);
        }

        for (const child of vnode.children) {
            node.appendChild(renderDOM(child));
        }

        return node;
    }

    if (typeof vnode === 'number') {
        return document.createTextNode(String(vnode));
    }
    return document.createTextNode(vnode);

};

const render = function (vnode: VNode, root: HTMLElement) {
    const dom = renderDOM(vnode);
    root.appendChild(dom);
    return dom;
}

  其中邏輯並不複雜,只須要特殊說起一點,元素中style屬性較爲特殊,style屬性用來經過CSS爲元素指定樣式。在經過getAttribute()訪問時,返回的style特性值中包含的是CSS文本,而經過屬性來訪問它則會返回一個對象。所以在這裏咱們經過setAttribute函數設置元素樣式前,經過transformStyleToString函數將樣式從對象改變爲字符串類型,而且將駝峯式的樣式屬性轉化爲普通的CSS樣式屬性,具體可見函數定義:

const transformStyleToString = function (style) {
    // 如果文本類型則直接返回
    if (isString(style)) {
        return style;
    }
    // 如果對象類型則轉爲字符串
    if (isObject(style)) {
        return Object.keys(style).reduce((acc, key) => {
            let cssKey = key.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`);
            return acc + `${cssKey}: ${style[key]};`;
        }, '');
    }
    return '';
};

Diff算法

  Diff算法多是Virtual DOM中相對較爲複雜的部分,固然咱們只是爲了實現一個簡易的Virtual DOM系統,並不須要過於複雜的實現,下面只是我本身的一種實現策略,並不具備廣泛性。Diff算法目的是爲了比較兩棵Virtual DOM樹的差別,傳統diff算法的複雜度爲 O(n^3),實際上前端DOM樹結構具備其自身的特定,所以衍生了各類各樣的啓發式算法,並將Diff算法的時間複雜度下降到O(n)。

  所謂的啓發式算法是指:在解決問題時所採起的一種根據經驗規則進行發現的方法。其特色是在解決問題時,利用過去的經驗,選擇已經行之有效的方法,而不是系統地、以肯定的步驟去尋求答案。而在Diff中啓發式算法主要是依賴於下列條件:

  • 同級比較
  • 同元素比較
  • 子元素比較

同級比較

  同級比較是指,咱們僅會對比同一層次的節點,而忽略跨層級的DOM移動操做。對於同一層次節點的增長和刪除,咱們則不會進一步比較其子節點,這樣只須要對樹遍歷一遍便可。

  以上圖爲例,父節點從ul變爲p節點,即便ul大部分節點也能夠重用,但咱們並不會跨層級比較,所以咱們會從新渲染div及其子節點。

同元素比較

  同元素比較是指,當遇到元素類型變化時,不會比較兩個組件的不一樣,直接建立新的元素。

  以上圖爲例,父節點從ul變爲ol節點,即便ul子節點並未發生改變,但咱們認爲元素類型從ul改變爲ol,雖然子節點未發生改變,咱們並不會比較子節點,直接建立新的節點。

子元素比較

  子元素比較是指,當節點處於同一層級時,咱們認爲存在如下的節點操做:

  • 插入節點(INSERT_MARKUP)
  • 刪除節點(REMOVE_NODE)
  • 移動節點(MOVE_EXISTING)

  以上圖爲例,假設以前的Virtual DOM樹爲Old Tree。

  當比較第一個子元素div時,由於New Tree中的div與同位置Old Tree中的div節點類型一致,則咱們認爲先後變化中對應位置的節點還是同一個,則咱們會繼續比較節點屬性及其及其子節點。

  當比較第二個子元素時,由於p節點含有key屬性,且key = 2的節點也存在於Old Tree,而且先後兩個key = 2的節點類型是一致的,所以咱們認爲New Tree中key = 2p元素是由Old Tree中第三個子元素移動(MOVE_EXISTING)而來。

  當比較第三個子元素時,由於p節點含有key = 3且Old Tree中並不含有key = 3的同類型節點,則咱們認爲改節點屬於插入節點(INSERT_MARKUP)。

  當咱們比較a元素的子節點時,由於New Tree中已經不存在該位置的節點,所以咱們認爲改節點屬於刪除節點(REMOVE_NODE)。

Patch

  當比較兩棵Virtual DOM樹時,咱們須要記錄兩棵Virtual DOM樹的區別,咱們將其稱爲Patch,由於咱們須要記錄的是樹節點的差別,所以咱們也能夠將Patch同類化一個樹結構。根據Patch類特色,咱們給Patch類的定義:

class Patch {
    // 節點變化類型
    public types: OPERATOR_TYPE[];
    // 子元素Patch集合
    public children: Patch[];
    // 存儲帶渲染的新節點
    public node: VNode | string | null;
    // 存儲屬性改變
    public modifyProps: ModifyProps[];
    // 文本改變
    public modifyString: string;
    // 節點移動,搭配`MOVE_EXISTING`使用
    public removeIndex: number;

    public constructor(types?: (OPERATOR_TYPE | OPERATOR_TYPE[])) {
        this.types = [];
        this.children = [];
        this.node = null;
        this.modifyProps = [];
        this.modifyString = '';
        this.removeIndex = 0;

        if (types) {
            types instanceof Array ? this.types.push(...types) : this.types.push(types);
        }
    }
    
    // 省略類方法實現
    // addType
    // addModifyProps
    // addChildPatch
    // setNode
    // setModifyString
    // setRemoveIndex
}

  其中types屬性用於存儲變化類型,注意同一個Patch可能存在多種變化類型,所以咱們使用數組存儲。Patch存在如下幾種類型:

export const enum OPERATOR_TYPE {
    INSERT_MARKUP,
    MOVE_EXISTING,
    REMOVE_NODE,
    PROPS_CHANG,
    TEXT_CHANGE
}

  其中INSERT_MARKUPMOVE_EXISTINGREMOVE_NODE咱們都已經介紹過,而PROPS_CHANG表示節點屬性發生改變,例如id屬性變化。而TEXT_CHANGE適用於文本節點,表示文本節點內容發生改變。例如文本節點內容從Hello!改變爲Hello World

  node屬性用於存儲待渲染的節點類型類型,搭配INSERT_MARKUP使用。removeIndex屬性表示當前節點是從同層序號節點位置移動而來,搭配MOVE_EXISTING使用。modifyString表示文本節點變化後的內容,搭配TEXT_CHANGE使用。modifyProps表示屬性改變的數組,搭配PROPS_CHANGE使用。其中ModifyProps接口描述爲:

const enum PROP_TYPE {
    ADD, // 新增屬性
    DELETE, // 刪除屬性
    MODIFY // 屬性改變
}

interface ModifyProps {
    type: PROP_TYPE;
    key?: string;
    value?: any;
}

  完事具有,讓咱們開始實現diff函數

Diff函數簡要實現

// 用於返回子元素數組NodeList中key-node集合(Map)
function getChildrenKeyMap(children: VNodeChildType[]) {
    let map = new Map();
    each(children, child => {
        if (isVNode(child) && isKey(child.key)) {
            map.set(child.key, child);
        }
    });
    return map;
}

// 返回給定key值對應節點所在位置
function getChildIndexByKey(children: VNodeChildType[], key) {
    return findIndex(children, child => (isVNode(child) && child.key === key));
}

function diffProps(preProps: PropsType, nextProps: PropsType) {
    // return [...addPropResult, ...deletePropResult, ...modifyPropResult];
    // 返回Props比較數組結果,若是不存在Props變化則返回空數組。
}
function diff(preNode: VNode | null, nextNode: VNode) {

    const patch = new Patch();

    // 若節點類型不一致或者以前的元素爲空,則直接從新建立該節點及其子節點
    if (preNode === null || preNode.tagName !== nextNode.tagName) {
        return patch.addType(OPERATOR_TYPE.INSERT_MARKUP).setNode(nextNode);
    }

    // 先後兩個虛擬節點類型一致,則須要比較屬性是否一致
    const propsCompareResult = diffProps(preNode.props, nextNode.props);

    if (isNotEmptyArray(propsCompareResult)) {
        patch.addType(OPERATOR_TYPE.PROPS_CHANGE).addModifyProps(propsCompareResult);
    }

    // 若是上一個子元素不爲空,且下一個子元素全爲空,則須要清除全部子元素
    if (isEmptyArray(nextNode.children) && isNotEmptyArray(preNode.children)) {
        return patch.addChildPatch(preNode.children.map(() => new Patch(OPERATOR_TYPE.REMOVE_NODE)));
    }

    const preChildrenKeyMap = getChildrenKeyMap(preNode.children);

    // 遍歷處理子元素
    each(nextNode.children, (child, index) => {
        const nextChild = child;
        const preChild = isNotNull(preNode.children[index]) ? preNode.children[index] : null;

        // 若是當前子節點是字符串類型
        if (isString(nextChild)) {
            // 以前對應節點也是字符串
            if (isString(preChild)) {
                if (nextChild === preChild) {
                    return patch.addChildPatch(new Patch());
                } else {
                    return patch.addChildPatch((new Patch(OPERATOR_TYPE.TEXT_CHANGE).setModifyString(nextChild)));
                }
            } else {
                // 以前對應節點不是字符串,則須要建立新的節點
                return patch.addChildPatch((new Patch(OPERATOR_TYPE.INSERT_MARKUP)).setNode(nextChild));
            }
        }

        // 若當前的子節點中存在key屬性
        if (isVNode(nextChild) && isKey(nextChild.key)) {
            // 若是上一個同層虛擬DOM節點中存在相同key且元素類型相同的節點
            if (preChildrenKeyMap.has(nextChild.key) && preChildrenKeyMap.get(nextChild.key).tagName === nextChild.tagName) {
                // 若是先後兩個元素的key值和元素類型相等
                const preSameKeyChild = preChildrenKeyMap.get(nextChild.key);
                const sameKeyIndex = getChildIndexByKey(preNode.children, nextChild.key);
                const childPatch = diff(preSameKeyChild, nextChild);
                if (sameKeyIndex !== index) {
                    childPatch.addType(OPERATOR_TYPE.MOVE_EXISTING).setRemoveIndex(sameKeyIndex);
                }
                return patch.addChildPatch(childPatch);
            } else {
                // 直接建立新的元素
                return patch.addChildPatch((new Patch(OPERATOR_TYPE.INSERT_MARKUP).setNode(nextChild)));
            }
        }

        // 子節點中不存在key屬性
        // 若先後相同位置的節點是 非VNode(字符串) 或者 存在key值( nextChild不含有key) 或者是 節點類型不一樣,則直接建立新節點
        if (!isVNode(preChild) || isKey(preChild.key) || preChild.tagName !== nextChild.tagName) {
            return patch.addChildPatch((new Patch(OPERATOR_TYPE.INSERT_MARKUP)).setNode(nextChild));
        }

        return patch.addChildPatch(diff(preChild, nextChild));
    });

    // 若是存在nextChildren個數少於preChildren,則須要補充刪除節點
    if (preNode.children.length > nextNode.children.length) {
        patch.addChildPatch(Array.from({ length: preNode.children.length - nextNode.children.length }, () => new Patch(OPERATOR_TYPE.REMOVE_NODE)));
    }
    return patch;
}

  咱們簡單舉例下圖場景,分別給new Treeold Tree調用diff算法,則會生成圖中所示的Patch Tree:

應用Patch更新DOM樹

  applyDiff函數的實現則相對要簡單的多,咱們只要對照Patch Tree,對以前渲染的DOM樹進行修改便可。

function applyChildDiff(actualDOM: HTMLElement, patch: Patch) {
    // 由於removeIndex是基於old Tree中的序號位置,所以咱們須要提早備份節點節點順序關係
    const childrenDOM = map(actualDOM.childNodes, child => child);
    const childrenPatch = patch.children;

    for (let index = 0; index < actualDOM.childNodes.length; index++) {
        const childPatch = childrenPatch[index];
        let childDOM = childrenDOM[index];
        if (contains(childPatch.types, OPERATOR_TYPE.MOVE_EXISTING)) {
            const insertDOM = childrenDOM[childPatch.removeIndex];
            actualDOM.insertBefore(insertDOM, childDOM);
            childDOM = insertDOM;
        }
        innerApplyDiff(childDOM, childPatch, actualDOM);
    }
}

function innerApplyDiff(actualDOM: HTMLElement | Text, patch: Patch, parentDOM: HTMLElement) {
    // 處理INSERT_MARKUP,直接建立新節點替換以前節點
    if (contains(patch.types, OPERATOR_TYPE.INSERT_MARKUP)) {
        const replaceDOM = renderDOM(patch.node!);
        parentDOM.replaceChild(replaceDOM, actualDOM);
        return replaceDOM;
    }
    // 處理REMOVE_NODE,直接刪除當前節點
    if (contains(patch.types, OPERATOR_TYPE.REMOVE_NODE)) {
        parentDOM.removeChild(actualDOM);
        return null;
    }
    // 處理TEXT_CHANGE
    if (contains(patch.types, OPERATOR_TYPE.TEXT_CHANGE)) {
        actualDOM.nodeValue = patch.modifyString;
        return actualDOM;
    }
    // 處理PROPS_CHANGE
    if (contains(patch.types, OPERATOR_TYPE.PROPS_CHANGE)) {
        each(patch.modifyProps, function (modifyProp) {
            let key = modifyProp.key;
            let value = modifyProp.value;
            switch (modifyProp.type) {
                case PROP_TYPE.ADD:
                case PROP_TYPE.MODIFY:
                    if (key === 'style') {
                        value = transformStyleToString(value);
                    }
                    actualDOM.setAttribute(key, value);
                    break;
                case PROP_TYPE.DELETE:
                    actualDOM.removeAttribute(key);
                    break;
            }
        });
    }

    if (isHTMLElement(actualDOM)) {
        applyChildDiff(actualDOM, patch);
    }

    return actualDOM;
}

function applyDiff (actualDOM: HTMLElement | Text, patch: Patch) {
    if (!(actualDOM.parentNode instanceof HTMLElement)) {
        throw Error('DOM元素未渲染');
    }
    return applyDiff(actualDOM, patch, actualDOM.parentNode);
}

Virtual DOM 使用演示

  如今咱們的Virtual DOM庫已經基本完成,咱們起個名字就叫Vom,讓咱們嘗試使用一下:

import Vom from '../index';

function getRandomArray(length) {
    return Array.from(new Array(length).keys()).sort(() => Math.random() - 0.5);
}

function getRandomColor() {
    const colors = ['blue', 'red', 'green'];
    return colors[(new Date().getSeconds()) % colors.length];
}

function getJSX() {
    return (
        <div>
            <p>這是一個由Vom渲染的界面</p>
            <p>
                <span style={{ color: getRandomColor() }}>如今時間: { Date().toString() }</span>
            </p>
            <p>下面是一個順序動態變化的有序列表:</p>
            <ul>
                {
                    getRandomArray(10).map((key) => {
                        return <li key={key}>列表序號: {key} </li>;
                    })
                }
            </ul>
        </div>
    );
}

let preNode = getJSX();
let actualDom = Vom.render(preNode, document.body);

setInterval(() => {
    const nextNode = getJSX();
    const patch = Vom.diff(preNode, nextNode);
    actualDom = Vom.applyDiff(actualDom, patch)!;
    preNode = nextNode;
}, 1000);

結尾

  到目前爲止咱們已經實現一個Virtual DOM的基本功能,本篇文章重點仍是在講述Virtual DOM基本原理,實現方面相對比較簡陋,若有不正確之處,望各位見諒。代碼已經上傳Github:Vom。寫做不易,願你們能多多支持,關注個人Github博客,關注、點贊、收藏 素質三連哦!但願這篇文章能對你有些許幫助,願共同窗習!

相關文章
相關標籤/搜索