從零開始實現一個React(三):diff算法

前言

上一篇文章,咱們已經實現了React的組件功能,從功能的角度來講已經實現了React的核心功能了。前端

可是咱們的實現方式有很大的問題:每次更新都從新渲染整個應用或者整個組件,DOM操做十分昂貴,這樣性能損耗很是大。node

爲了減小DOM更新,咱們須要找渲染先後真正變化的部分,只更新這一部分DOM。而對比變化,找出須要更新部分的算法咱們稱之爲diff算法react

對比策略

在前面兩篇文章後,咱們實現了一個render方法,它能將虛擬DOM渲染成真正的DOM,咱們如今就須要改進它,讓它不要再傻乎乎地從新渲染整個DOM樹,而是找出真正變化的部分。git

這部分不少類React框架實現方式都不太同樣,有的框架會選擇保存上次渲染的虛擬DOM,而後對比虛擬DOM先後的變化,獲得一系列更新的數據,而後再將這些更新應用到真正的DOM上。github

但也有一些框架會選擇直接對比虛擬DOM和真實DOM,這樣就不須要額外保存上一次渲染的虛擬DOM,而且可以一邊對比一邊更新,這也是咱們選擇的方式。算法

不論是DOM仍是虛擬DOM,它們的結構都是一棵樹,徹底對比兩棵樹變化的算法時間複雜度是O(n^3),可是考慮到咱們不多會跨層級移動DOM,因此咱們只須要對比同一層級的變化。chrome

圖片描述
只須要對比同一顏色框內的節點數組

總而言之,咱們的diff算法有兩個原則:架構

  • 對比當前真實的DOM和虛擬DOM,在對比過程當中直接更新真實DOM
  • 只對比同一層級的變化

實現

咱們須要實現一個diff方法,它的做用是對比真實DOM和虛擬DOM,最後返回更新後的DOMapp

/**
 * @param {HTMLElement} dom 真實DOM
 * @param {vnode} vnode 虛擬DOM
 * @returns {HTMLElement} 更新後的DOM
 */
function diff( dom, vnode ) {
    // ...
}

接下來就要實現這個方法。
在這以前先來回憶一下咱們虛擬DOM的結構:
虛擬DOM的結構能夠分爲三種,分別表示文本、原生DOM節點以及組件。

// 原生DOM節點的vnode
{
    tag: 'div',
    attrs: {
        className: 'container'
    },
    children: []
}

// 文本節點的vnode
"hello,world"

// 組件的vnode
{
    tag: ComponentConstrucotr,
    attrs: {
        className: 'container'
    },
    children: []
}

對比文本節點

首先考慮最簡單的文本節點,若是當前的DOM就是文本節點,則直接更新內容,不然就新建一個文本節點,並移除掉原來的DOM。

// diff text node
if ( typeof vnode === 'string' ) {

    // 若是當前的DOM就是文本節點,則直接更新內容
    if ( dom && dom.nodeType === 3 ) {    // nodeType: https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType
        if ( dom.textContent !== vnode ) {
            dom.textContent = vnode;
        }
    // 若是DOM不是文本節點,則新建一個文本節點DOM,並移除掉原來的
    } else {
        out = document.createTextNode( vnode );
        if ( dom && dom.parentNode ) {
            dom.parentNode.replaceChild( out, dom );
        }
    }

    return out;
}

文本節點十分簡單,它沒有屬性,也沒有子元素,因此這一步結束後就能夠直接返回結果了。

對比非文本DOM節點

若是vnode表示的是一個非文本的DOM節點,那就要分幾種狀況了:
若是真實DOM和虛擬DOM的類型不一樣,例如當前真實DOM是一個div,而vnode的tag的值是'button',那麼原來的div就沒有利用價值了,直接新建一個button元素,並將div的全部子節點移到button下,而後用replaceChild方法將div替換成button。

if ( !dom || dom.nodeName.toLowerCase() !== vnode.tag.toLowerCase() ) {
    out = document.createElement( vnode.tag );

    if ( dom ) {
        [ ...dom.childNodes ].map( out.appendChild );    // 將原來的子節點移到新節點下

        if ( dom.parentNode ) {
            dom.parentNode.replaceChild( out, dom );    // 移除掉原來的DOM對象
        }
    }
}

若是真實DOM和虛擬DOM是同一類型的,那咱們暫時不須要作別的,只須要等待後面對比屬性和對比子節點。

對比屬性

實際上diff算法不只僅是找出節點類型的變化,它還要找出來節點的屬性以及事件監聽的變化。咱們將對比屬性單獨拿出來做爲一個方法:

function diffAttributes( dom, vnode ) {

    const old = dom.attributes;    // 當前DOM的屬性
    const attrs = vnode.attrs;     // 虛擬DOM的屬性

    // 若是原來的屬性不在新的屬性當中,則將其移除掉(屬性值設爲undefined)
    for ( let name in old ) {

        if ( !( name in attrs ) ) {
            setAttribute( dom, name, undefined );
        }

    }

    // 更新新的屬性值
    for ( let name in attrs ) {

        if ( old[ name ] !== attrs[ name ] ) {
            setAttribute( dom, name, attrs[ name ] );
        }

    }

}

setAttribute方法的實現參見第一篇文章

對比子節點

節點自己對比完成了,接下來就是對比它的子節點。
這裏會面臨一個問題,前面咱們實現的不一樣diff方法,都是明確知道哪個真實DOM和虛擬DOM對比,可是子節點是一個數組,它們可能改變了順序,或者數量有所變化,咱們很難肯定要和虛擬DOM對比的是哪個。
爲了簡化邏輯,咱們可讓用戶提供一些線索:給節點設一個key值,從新渲染時對比key值相同的節點。

// diff方法
if ( vnode.children && vnode.children.length > 0 || ( out.childNodes && out.childNodes.length > 0 ) ) {
    diffChildren( out, vnode.children );
}
function diffChildren( dom, vchildren ) {

    const domChildren = dom.childNodes;
    const children = [];

    const keyed = {};

    // 將有key的節點和沒有key的節點分開
    if ( domChildren.length > 0 ) {
        for ( let i = 0; i < domChildren.length; i++ ) {
            const child = domChildren[ i ];
            const key = child.key;
            if ( key ) {
                keyedLen++;
                keyed[ key ] = child;
            } else {
                children.push( child );
            }
        }
    }

    if ( vchildren && vchildren.length > 0 ) {

        let min = 0;
        let childrenLen = children.length;

        for ( let i = 0; i < vchildren.length; i++ ) {

            const vchild = vchildren[ i ];
            const key = vchild.key;
            let child;

            // 若是有key,找到對應key值的節點
            if ( key ) {

                if ( keyed[ key ] ) {
                    child = keyed[ key ];
                    keyed[ key ] = undefined;
                }

            // 若是沒有key,則優先找類型相同的節點
            } else if ( min < childrenLen ) {

                for ( let j = min; j < childrenLen; j++ ) {

                    let c = children[ j ];

                    if ( c && isSameNodeType( c, vchild ) ) {

                        child = c;
                        children[ j ] = undefined;

                        if ( j === childrenLen - 1 ) childrenLen--;
                        if ( j === min ) min++;
                        break;

                    }

                }

            }

            // 對比
            child = diff( child, vchild );

            // 更新DOM
            const f = domChildren[ i ];
            if ( child && child !== dom && child !== f ) {
                if ( !f ) {
                    dom.appendChild(child);
                } else if ( child === f.nextSibling ) {
                    removeNode( f );
                } else {
                    dom.insertBefore( child, f );
                }
            }

        }
    }

}

對比組件

若是vnode是一個組件,咱們也單獨拿出來做爲一個方法:

function diffComponent( dom, vnode ) {

    let c = dom && dom._component;
    let oldDom = dom;

    // 若是組件類型沒有變化,則從新set props
    if ( c && c.constructor === vnode.tag ) {
        setComponentProps( c, vnode.attrs );
        dom = c.base;
    // 若是組件類型變化,則移除掉原來組件,並渲染新的組件
    } else {

        if ( c ) {
            unmountComponent( c );
            oldDom = null;
        }

        c = createComponent( vnode.tag, vnode.attrs );

        setComponentProps( c, vnode.attrs );
        dom = c.base;

        if ( oldDom && dom !== oldDom ) {
            oldDom._component = null;
            removeNode( oldDom );
        }

    }

    return dom;

}

下面是相關的工具方法的實現,和上一篇文章的實現相比,只須要修改renderComponent方法其中的一行。

function renderComponent( component ) {
    
    // ...

    // base = base = _render( renderer );          // 將_render改爲diff
    base = diff( component.base, renderer );

    // ...
}

完整diff實現看這個文件

渲染

如今咱們實現了diff方法,咱們嘗試渲染上一篇文章中定義的Counter組件,來感覺一下有無diff方法的不一樣。

class Counter extends React.Component {
    constructor( props ) {
        super( props );
        this.state = {
            num: 1
        }
    }

    onClick() {
        this.setState( { num: this.state.num + 1 } );
    }

    render() {
        return (
            <div>
                <h1>count: { this.state.num }</h1>
                <button onClick={ () => this.onClick()}>add</button>
            </div>
        );
    }
}

不使用diff

使用上一篇文章的實現,從chrome的調試工具中能夠看到,閃爍的部分是每次更新的部分,每次點擊按鈕,都會從新渲染整個組件。
圖片描述

使用diff

而實現了diff方法後,每次點擊按鈕,都只會從新渲染變化的部分。
圖片描述

後話

在這篇文章中咱們實現了diff算法,經過它作到了每次只更新須要更新的部分,極大地減小了DOM操做。React實現遠比這個要複雜,特別是在React 16以後還引入了Fiber架構,可是主要的思想是一致的。

實現diff算法能夠說性能有了很大的提高,可是在別的地方仍而後不少改進的空間:每次調用setState後會當即調用renderComponent從新渲染組件,但現實狀況是,咱們可能會在極短的時間內屢次調用setState。
假設咱們在上文的Counter組件中寫出了這種代碼

onClick() {
    for ( let i = 0; i < 100; i++ ) {
        this.setState( { num: this.state.num + 1 } );
    }
}

那以目前的實現,每次點擊都會渲染100次組件,對性能確定有很大的影響。
下一篇文章咱們就要來改進setState方法

這篇文章的代碼:https://github.com/hujiulong/...

從零開始實現React系列

React是前端最受歡迎的框架之一,解讀其源碼的文章很是多,可是我想從另外一個角度去解讀React:從零開始實現一個React,從API層面實現React的大部分功能,在這個過程當中去探索爲何有虛擬DOM、diff、爲何setState這樣設計等問題。

整個系列大概會有四篇,我每週會更新一到兩篇,我會第一時間在github上更新,有問題須要探討也請在github上回復我~

博客地址: https://github.com/hujiulong/...
關注點star,訂閱點watch

上一篇文章

從零開始實現一個React(二):組件和生命週期

相關文章
相關標籤/搜索