從Preact瞭解一個類React的框架是怎麼實現的(二): 元素diff

請叫我王磊同學發表於2017-09-24

前言

  首先歡迎大家關注我的掘金賬號和Github部落格,也算是對我的一點鼓勵,畢竟寫東西沒法獲得變現,能堅持下去也是靠的是自己的熱情和大家的鼓勵。
  之前分享過幾篇關於React的文章:

  其實我在閱讀React原始碼的時候,真的非常痛苦。React的程式碼及其複雜、龐大,閱讀起來挑戰非常大,但是這卻又擋不住我們的React的原理的好奇。前段時間有人就安利過Preact,千行程式碼就基本實現了React的絕大部分功能,相比於React動輒幾萬行的程式碼,Preact顯得別樣的簡潔,這也就為了我們學習React開闢了另一條路。本系列文章將重點分析類似於React的這類框架是如何實現的,歡迎大家關注和討論。如有不準確的地方,歡迎大家指正。
  
  在上篇文章從preact瞭解一個類React的框架是怎麼實現的(一): 元素建立我們瞭解了我們平時所書寫的JSX是怎樣轉化成Preact中的虛擬DOM結構的,接下來我們就要了解一下這些虛擬DOM節點是如何渲染成真實的DOM節點的以及虛擬DOM節點的改變如何對映到真實DOM節點的改變(也就是diff演算法的過程)。這篇文章相比第一篇會比較冗長和枯燥,為了能集中分析diff過程,我們只關注dom元素,暫時不去考慮元件。   

渲染與diff

render函式

  我們知道在React中渲染是並不是由React完成的,而是由ReactDOM中的render函式去實現的。其實在最早的版本中,render函式也是屬於React的,只不過後來React的開發者想實現一個於平臺無關的庫(其目的也是為了React Native服務的),因此將Web中渲染的部分獨立成ReactDOM庫。Preact作為一個極度精簡的庫,render函式是屬於Preact本身的。Preact的render函式與ReactDOM的render函式也是有有所區別的:

ReactDOM.render(
  element,
  container,
  [callback]
)複製程式碼

  ReactDOM.render接受三個引數,element是需要渲染的React元素,而container掛載點,即React元素將被渲染進container中,第三個引數callback是可選的,當元件被渲染或者更新的時候會被呼叫。ReactDOM.render會返回渲染組元素的真實DOM節點。如果之前container中含有dom節點,則渲染時會將之前的所有節點清除。例如:

html:

<div id="root">
  <div>Hello React!</div>
</div>複製程式碼

javascript:

ReactDOM.render(
  <h1>Hello, world!</h1>,
  document.getElementById('root')
);複製程式碼

  最終的顯示效果為:

Hello, world!

  而Preact的render函式為:   

Preact.render(
  vnode, 
  parent, 
  [merge]
)複製程式碼

  Preact.renderReactDOM.render的前兩個引數代表的意義相同,區域在於最後一個,Preact.render可選的第三個引數merge,要求必須是第二個引數的子元素,是指會被替換的根節點,否則,如果沒有這個引數,Preact 預設追加,而不是像React進行替換。
  
  例如不存在第三個引數的情況下:

html:

<div id="root">
  <div id='container'>Hello Preact!</div>
</div>複製程式碼

javascript:

preact.render(
  <h1>Hello, world!</h1>,
  document.getElementById('root')
);複製程式碼

  最終的顯示效果為:

Hello Preact
Hello, world!

  如果呼叫函式有第三個引數:

javascript:

preact.render(
  <h1>Hello, world!</h1>,
  document.getElementById('root'),
  document.getElementById('container')
);複製程式碼

  顯示效果是:

Hello, world!   

實現

  其實在Preact中無論是初次渲染還是之後虛擬DOM改變導致的UI更新最終呼叫的都是diff函式,這也是非常合理的,畢竟我們可以將首次渲染當做是diff過程中用現有的虛擬dom去與空的真實dom基礎上進行更新的過程。下面我們首先給出整個diff過程的大致流程圖,我們可以對照流程圖對程式碼進行分析:
  

diff流程圖
diff流程圖

  
  首先從render函式入手,render函式呼叫的就是diff函式:

function render(vnode, parent, merge) {
    return diff(merge, vnode, {}, false, parent, false);
}複製程式碼

  我們可以看到Preact中的render呼叫了diff函式,而diff定義在vdom/diff中:

function diff(dom, vnode, context, mountAll, parent, componentRoot) {

    // diffLevel為 0 時表示第一次進入diff函式
    if (!diffLevel++) {
        // 第一次執行diff,檢視我們是否在diff SVG元素或者是元素在SVG內部
        isSvgMode = parent!=null && parent.ownerSVGElement!==undefined;

        // hydration 指示的是被diff的現存元素是否含有屬性props的快取
        // 屬性props的快取被存在dom節點的__preactattr_屬性中
        hydrating = dom!=null && !(ATTR_KEY in dom);
    }

    let ret = idiff(dom, vnode, context, mountAll, componentRoot);

    // 如果父節點之前沒有建立的這個子節點,則將子節點新增到父節點之後
    if (parent && ret.parentNode!==parent) parent.appendChild(ret);

    // diffLevel回減到0說明已經要結束diff的呼叫
    if (!--diffLevel) {
        hydrating = false;
        // 負責觸發元件的componentDidMount生命週期函式
        if (!componentRoot) flushMounts();
    }

    return ret;
}複製程式碼

  這部分的函式內容比較龐雜,很難做到面面俱到,我會在程式碼中做相關的註釋。diff函式主要負責就是將當前的虛擬node節點對映到真實的DOM節點中。引數如下:

  • vnode: 不用說,就是我們需要渲染的虛擬dom節點
  • parent: 就是你要將虛擬dom掛載的父節點
  • dom: 這裡的dom其實就是當前的vnode所對應的之前未更新的真實dom。那麼就有兩種可能: 第一就是null或者是上面例子的contaienr(就是render函式對應的第三個引數),其本質都是首次渲染,第二種就是vnode的對應的未更新的真實dom,那麼對應的就是渲染重新整理介面
  • context: 元件相關,暫時可以不考慮,對應React中的context
  • mountAll: 元件相關,暫時可以不考慮
  • componentRoot: 元件相關,暫時可以不考慮

  vnode對應的就是一個遞迴的結構,那麼不用想diff函式肯定也是遞迴的。我們首先看一下函式初始的幾個變數:

  • diffLevel:用來記錄當前渲染的層數(遞迴的深度),其實在程式碼中並沒有在進入每層遞迴的時候都增加並且退出遞迴的時候減小。只是記錄了是不是渲染的第一層,所以對應的值只有01
  • isSvgMode:用來指代當前的渲染是否內SVG元素的內部或者我們是否在diff一個SVG元素(SVG元素需要特殊處理)。
  • hydrating: 這個變數是我一直所困惑的,我還專門查了一下,hydrating指的是保溼、吸水 的意思。hydrating = dom != null && !(ATTR_KEY in dom);(ATTR_KEY對應常量__preactattr_,preact會將props等快取資訊儲存在dom的__preactattr_屬性中),作者給的是下面的註釋:

hydration is indicated by the existing element to be diffed not having a prop cache

也就是說hydrating是指當前的diff的元素沒有快取但是對應的dom元素必須存在。那麼什麼時候才會出現dom節點中沒有儲存快取?只有當前的dom節點並不是由Preact所建立並渲染的才會使得hydrating為true。

  idiff函式就是diff演算法的內部實現,相對來說程式碼會比較複雜,idiff會返回虛擬dom對應建立的真實dom節點。下面的程式碼是是向父級元素有選擇性新增建立的dom節點,之所以這麼做,主要是有可能之前該節點就沒有渲染過,所以需要將新建立的dom節點新增到父級dom。但是如果僅僅只是修改了之前dom中的某一個屬性(比如樣式),那麼其實是不需要新增的,因為該dom節點已經存在於父級dom。
  
  後面的內容,一方面結束遞迴之後,回置diffLevel(diffLevel此時應該為0,表明此時要退出diff函式),退出diff前,將hydrating置為false,相當於一個復位的功能。下面的flushMounts函式是元件相關,在這裡我們只需要知道它要做的就是去執行所有剛才安裝元件的componentDidMount生命週期函式。
  
  下面讓我們看看idiff的實現(程式碼已經分塊,具體見註釋),程式碼比較長,可以先大致瀏覽一下,做到心裡有數,下面會逐塊分析,可以對照流程圖看:

/** 內部的diff函式 */
function idiff(dom, vnode, context, mountAll, componentRoot) {
    // block-1
    let out = dom, prevSvgMode = isSvgMode;

    // 空的node 渲染空的文字節點
    if (vnode==null || typeof vnode==='boolean') vnode = '';

    // String & Number 型別的節點 建立/更新 文字節點
    if (typeof vnode==='string' || typeof vnode==='number') {

        // 更新如果存在的原有文字節點
        // 這裡如果節點值是文字型別,其父節點又是文字型別的節點,則直接更新
        if (dom && dom.splitText!==undefined && dom.parentNode && (!dom._component || componentRoot)) {
            if (dom.nodeValue!=vnode) {
                dom.nodeValue = vnode;
            }
        }
        else {
            // 不是文字節點,替換之前的節點,回收之前的節點
            out = document.createTextNode(vnode);
            if (dom) {
                if (dom.parentNode) dom.parentNode.replaceChild(out, dom);
                recollectNodeTree(dom, true);
            }
        }

        out[ATTR_KEY] = true;
        return out;
    }

    // block-2
    // 如果是VNode代表的是一個元件,使用元件的diff
    let vnodeName = vnode.nodeName;
    if (typeof vnodeName==='function') {
        return buildComponentFromVNode(dom, vnode, context, mountAll);
    }

    // block-3    
    // 沿著樹向下時記錄記錄存在的SVG名稱空間
    isSvgMode = vnodeName==='svg' ? true : vnodeName==='foreignObject' ? false : isSvgMode;

    // 如果不是一個已經存在的元素或者型別有問題,則重新建立一個
    vnodeName = String(vnodeName);
    if (!dom || !isNamedNode(dom, vnodeName)) {
        out = createNode(vnodeName, isSvgMode);

        if (dom) {
            // 移動dom中的子元素到out中
            while (dom.firstChild) out.appendChild(dom.firstChild);

            // 如果之前的元素已經屬於某一個DOM節點,則將其替換
            if (dom.parentNode) dom.parentNode.replaceChild(out, dom);

            // 回收之前的dom元素(跳過非元素型別)
            recollectNodeTree(dom, true);
        }
    }

    // block-4
    let fc = out.firstChild,
        props = out[ATTR_KEY],
        vchildren = vnode.children;

    if (props==null) {
        props = out[ATTR_KEY] = {};
        for (let a=out.attributes, i=a.length; i--; ) props[a[i].name] = a[i].value;
    }

    // 優化: 對於元素只包含一個單一文字節點的優化路徑
    if (!hydrating && vchildren && vchildren.length===1 && typeof vchildren[0]==='string' && fc!=null && fc.splitText!==undefined && fc.nextSibling==null) {
        if (fc.nodeValue!=vchildren[0]) {
            fc.nodeValue = vchildren[0];
        }
    }
    // 否則,如果有存在的子節點或者新的孩子節點,執行diff
    else if (vchildren && vchildren.length || fc!=null) {
        innerDiffNode(out, vchildren, context, mountAll, hydrating || props.dangerouslySetInnerHTML!=null);
    }

    // 將props和atrributes從VNode中應用到DOM元素
    diffAttributes(out, vnode.attributes, props);

    // 恢復之前的SVG模式
    isSvgMode = prevSvgMode;

    return out;
}複製程式碼

  idiff函式所接受的引數與diff是完全相同的,但是二者也是有所區別的。diff在渲染過程(或者更新過程)中僅僅會呼叫一次,所以說diff函式接受的vnode就是整個應用的虛擬dom,而dom也就是當前整個虛擬dom所對應的節點。但是idiff的呼叫是遞迴的,因此domvnode開始時diff函式相等,但是在之後遞迴的過程中,就對應的是整個應用的部分

  • 首先來看第一塊(block-1)的程式碼:

  變數prevSvgMode用來儲存之前的isSvgMode,目的就是在退出這一次遞迴呼叫時恢復到呼叫前的值。然後如果vnode是null或者布林型別,都按照空字元去處理。接下的渲染是整對於字串(sting或者number型別),主要分為兩部分: 更新或者建立元素。如果dom本身存在並且就是一個文字節點,那就只需要將其中的值更新為當前的值即可。否則建立一個新的文字節點,並且將其替換到父元素上,並回收之前的節點值。因為文字節點是沒有什麼需要快取的屬性值(文字的顏色等屬性實際是儲存的父級的元素中),所以直接將其ATTR_KEY(實際值為__preactattr_)賦值為true,並返回新建立的元素。這段程式碼有兩個需要注意的地方:

if (dom.nodeValue!=vnode) {
    dom.nodeValue = vnode;
}複製程式碼

  為什麼在賦值文字節點值時,需要首先進行一個判斷?根據程式碼註釋得知Firfox瀏覽器不會預設做等值比較(其他的瀏覽器例如Chrome即使直接賦值,如果相等也不會修改dom元素),所以人為的增加了比較的過程,目的就是為了防止文字節點每次都會被更新,這算是一個瀏覽器怪癖(quirk)。

  回收dom節點的recollectNodeTree函式做了什麼?看程式碼:

/**
 * 遞迴地回收(或者解除安裝)節點及其後代節點
 * @param node
 * @param unmountOnly 如果為`true`,僅僅觸發解除安裝的生命週期,跳過刪除
 */
function recollectNodeTree(node, unmountOnly) {
    let component = node._component;
    if (component) {
        // 如果該節點屬於某個元件,解除安裝該元件(最終在這裡遞迴),主要包括元件的回收和相依解除安裝生命週期的呼叫
        unmountComponent(component);
    }
    else {
        // 如果節點含有ref函式,則執行ref函式,引數為null(這裡是React的規範,用於取消設定引用)
        // 確實在React如果設定了ref的話,在解除安裝的時候,也會被回撥,得到的引數是null
        if (node[ATTR_KEY]!=null && node[ATTR_KEY].ref) node[ATTR_KEY].ref(null);

        if (unmountOnly===false || node[ATTR_KEY]==null) {
            //要做的無非是從父節點將該子節點刪除
            removeNode(node);
        }

        //遞迴刪除子節點
        removeChildren(node);
    }
}
/**
 * 回收/解除安裝所有的子元素
 * 我們在這裡使用了.lastChild而不是使用.firstChild,是因為訪問節點的代價更低。
 */
export function removeChildren(node) {
    node = node.lastChild;
    while (node) {
        let next = node.previousSibling;
        recollectNodeTree(node, true);
        node = next;
    }
}
/** 從父節點刪除該節點
 *    @param {Element} node        待刪除的節點
 */
function removeNode(node) {
    let parentNode = node.parentNode;
    if (parentNode) parentNode.removeChild(node);
}複製程式碼

  我們看到在函式recollectNodeTree中,如果dom元素屬於某個元件,首先遞迴解除安裝元件(不是本次講述的重點,主要包括元件的回收和相依解除安裝生命週期的呼叫)。否則,只需要先判別該dom節點中是否被在jsx中存在ref函式(也是快取在__preactattr_屬性中),因為存在ref函式時,我們在元件解除安裝時以null引數作為回撥(React文件做了相應的規定,詳情見Refs and the DOM)。recollectNodeTree中第二個引數unmountOnly,表示僅僅觸發解除安裝的生命週期,跳過刪除的過程,如果unmountOnlyfalse或者dom中的ATTR_KEY屬性不存在(說明這個屬性不是preact所渲染的,否則肯定會存在該屬性),則直接將其從父節點刪除。最後遞迴刪除子節點,我們可以看到遞迴刪除子元素的過程是從右到左刪除的(首先刪除的lastChild元素),主要考慮到的是從後訪問會有效能的優勢。我們在這裡(block-1)呼叫函式recollectNodeTree的第二個引數是true,原因是在呼叫之前我們已經將其在父元素中進行替換,所以是不需要進行呼叫的函式removeNode再進行刪除該節點的。  

  • 第二塊程式碼,主要是針對的元件的渲染,如果vnode.nodeName對應的是函式型別,表明要渲染的是一個元件,直接呼叫了函式buildComponentFromVNode(元件不是本次敘述內容)。

  • 第三塊程式碼,首先:

    isSvgMode = vnodeName==='svg' ? true : vnodeName==='foreignObject' ? false : isSvgMode;複製程式碼

      變數isSvgMode還是用來標記當前建立的元素是否是SVG元素。foreignObject元素允許包含外來的XML名稱空間,一個foreignObject內部的任何SVG元素都不會被繪製,所以如果是vnodeNameforeignObject話,isSvgMode會被置為false(其實Svg對我來說也是比較生疏的內容,但是不影響我們分析整個渲染過程)。

    // 如果不是一個已經存在的元素或者型別有問題,則重新建立一個
    vnodeName = String(vnodeName);
    if (!dom || !isNamedNode(dom, vnodeName)) {
        out = createNode(vnodeName, isSvgMode);

        if (dom) {
            // 移動dom中的子元素到out中
            while (dom.firstChild) out.appendChild(dom.firstChild);

            // 如果之前的元素已經屬於某一個DOM節點,則將其替換
            if (dom.parentNode) dom.parentNode.replaceChild(out, dom);

            // 回收之前的dom元素(跳過非元素型別)
            recollectNodeTree(dom, true);
        }
    }複製程式碼

  然後開始嘗試建立dom元素,如果之前的dom為空(說明之前沒有渲染)或者dom的名稱與vnode.nodename不一致時,說明我們要建立新的元素,然後如果之前的dom節點中存在子元素,則將其全部移入新建立的元素中。如果之前的dom已經有父元素了,則將其替換成新的元素,最後回收該元素。
  在判斷節點dom型別與虛擬dom的vnodeName型別是否相同時使用了函式isNamedNode:   

function isNamedNode(node, nodeName) {
    return node.normalizedNodeName===nodeName || node.nodeName.toLowerCase()===nodeName.toLowerCase();
}複製程式碼

  如果節點是由Preact建立的(即由函式createNode建立的),其中dom節點中含有屬性normalizedNodeName(node.normalizedNodeName = nodeName),則使用normalizedNodeName去判斷節點型別是否相等,否則直接採用dom節點中的nodeName屬性去判斷。
 
  到此為止渲染的當前虛擬dom的過程已經結束,接下來就是處理子元素的過程。

  • 第四塊程式碼:
    let fc = out.firstChild,
        props = out[ATTR_KEY],
        vchildren = vnode.children;

    if (props==null) {
        props = out[ATTR_KEY] = {};
        for (let a=out.attributes, i=a.length; i--; ) props[a[i].name] = a[i].value;
    }

    // 優化: 對於元素只包含一個單一文字節點的優化路徑
    if (!hydrating && vchildren && vchildren.length===1 && typeof vchildren[0]==='string' && fc!=null && fc.splitText!==undefined && fc.nextSibling==null) {
        if (fc.nodeValue!=vchildren[0]) {
            fc.nodeValue = vchildren[0];
        }
    }
    // 否則,如果有存在的子節點或者新的孩子節點,執行diff
    else if (vchildren && vchildren.length || fc!=null) {
        innerDiffNode(out, vchildren, context, mountAll, hydrating || props.dangerouslySetInnerHTML!=null);
    }複製程式碼

  然後我們看到,如果out是新建立的元素或者該元素不是由Preact建立的(即不存在屬性__preactattr_),我們會初始化out中的__preactattr_屬性中並將out元素(剛建立的dom元素)中屬性attributes快取在out元素的ATTR_KEY(__preactattr_)屬性上。但是需要注意的是,比如某個節點的屬性發生改變,比如name1變成了2,那麼out屬性中的快取(__preactattr_)也需要得到更新,但是更新的操作並不發生在這裡,而是下面的diffAttributes函式中。
  
  接下來就是處理子元素只有一個文字節點的情況(其實這部分也可以沒有,通過下一層的遞迴也能解決,這樣做只不過是為了優化效能),比如處理下面的情形:

<l1>1</li>複製程式碼

  進入單個節點的判斷條件也是比較明確的,唯一需要注意的一點是,必須滿足hydrating不為true,因為我們知道當hydratingtrue是說明當前的節點並不是由Preact渲染的,因此不能進行直接的優化,需要由下一層遞迴中建立新的文字元素。   

    //將props和atrributes從VNode中應用到DOM元素
    diffAttributes(out, vnode.attributes, props);
    // 恢復之前的SVG模式
    isSvgMode = prevSvgMode;
    return out;複製程式碼

  函式diffAttributes的主要作用就是將虛擬dom中attributes更新到真實的dom中(後面詳細講)。最後重置變數isSvgMode,並返回vnode所渲染的真實dom節點。
  
  看完了函式idiff,接下來要關心的就是,在idiff中對虛擬dom的子元素呼叫的innerDiffNode函式(程式碼依然很長,我們依然做分塊,對照流程圖看):

function innerDiffNode(dom, vchildren, context, mountAll, isHydrating) {
    let originalChildren = dom.childNodes,
        children = [],
        keyed = {},
        keyedLen = 0,
        min = 0,
        len = originalChildren.length,
        childrenLen = 0,
        vlen = vchildren ? vchildren.length : 0,
        j, c, f, vchild, child;

    // block-1
    // 建立一個包含key的子元素和一個不包含有子元素的Map
    if (len!==0) {
        for (let i=0; i<len; i++) {
            let child = originalChildren[i],
                props = child[ATTR_KEY],
                key = vlen && props ? child._component ? child._component.__key : props.key : null;
            if (key!=null) {
                keyedLen++;
                keyed[key] = child;
            }
            else if (props || (child.splitText!==undefined ? (isHydrating ? child.nodeValue.trim() : true) : isHydrating)) {
                children[childrenLen++] = child;
            }
        }
    }
    // block-2
    if (vlen!==0) {
        for (let i=0; i<vlen; i++) {
            vchild = vchildren[i];
            child = null;

            // 嘗試通過鍵值匹配去尋找節點
            let key = vchild.key;
            if (key!=null) {
                if (keyedLen && keyed[key]!==undefined) {
                    child = keyed[key];
                    keyed[key] = undefined;
                    keyedLen--;
                }
            }
            // 嘗試從現有的孩子節點中找出型別相同的節點
            else if (!child && min<childrenLen) {
                for (j=min; j<childrenLen; j++) {
                    if (children[j]!==undefined && isSameNodeType(c = children[j], vchild, isHydrating)) {
                        child = c;
                        children[j] = undefined;
                        if (j===childrenLen-1) childrenLen--;
                        if (j===min) min++;
                        break;
                    }
                }
            }

            // 變形匹配/尋找到/建立的DOM子元素來匹配vchild(深度匹配)
            child = idiff(child, vchild, context, mountAll);

            f = originalChildren[i];
            if (child && child!==dom && child!==f) {
                if (f==null) {
                    dom.appendChild(child);
                }
                else if (child===f.nextSibling) {
                    removeNode(f);
                }
                else {
                    dom.insertBefore(child, f);
                }
            }
        }
    }
    // block-3
    // 移除未使用的帶有keyed的子元素
    if (keyedLen) {
        for (let i in keyed) if (keyed[i]!==undefined) recollectNodeTree(keyed[i], false);
    }
    // 移除沒有父節點的不帶有key值的子元素
    while (min<=childrenLen) {
        if ((child = children[childrenLen--])!==undefined) recollectNodeTree(child, false);
    }
}複製程式碼

  首先看innerDiffNode函式的引數:

  • dom: diff的虛擬子元素的父元素對應的真實dom節點
  • vchildren: diff的虛擬子元素
  • context: 類似於React中的context,元件使用
  • mountAll: 元件相關,暫時可以不考慮
  • componentRoot: 元件相關,暫時可以不考慮

  函式程式碼將近百行,為了方便閱讀,我們將其分為四個部分(看程式碼註釋):

  • 第一部分程式碼:
// 建立一個包含key的子元素和一個不包含有子元素的Map
if (len!==0) {
    //len === dom.childNodes.length
    for (let i=0; i<len; i++) {
        let child = originalChildren[i],
            props = child[ATTR_KEY],
            key = vlen && props ? child._component ? child._component.__key : props.key : null;
        if (key!=null) {
            keyedLen++;
            keyed[key] = child;
        }
        else if (props || (child.splitText!==undefined ? (isHydrating ? child.nodeValue.trim() : true) : isHydrating)) {
            children[childrenLen++] = child;
        }
    }
}複製程式碼

  我們所希望的diff的過程肯定是以最少的dom操作使得更改後的dom與虛擬dom相匹配,所以之前父節點的dom重用也是非常必要。len是父級dom的子元素個數,首先對所有的子元素進行遍歷,如果該元素是由Preact所渲染(也就是有props的快取)並且含有key值(不考慮元件的情況下,我們暫時只看該元素props中是否有key值),我們將其儲存在keyed中,否則如果該元素也是Preact所渲染(有props的快取)或者滿足條件(child.splitText!==undefined ? (isHydrating ? child.nodeValue.trim() : true) : isHydrating)時,我們將其分配到children中。這樣我們其實就將子元素劃分為兩類,一類是帶有key值的子元素,一類是沒有key的子元素。

  關於條件(child.splitText!==undefined ? (isHydrating ? child.nodeValue.trim() : true) : isHydrating)我們分析一下,我們知道hydratingtrue時表示的是dom元素不是Preact建立的,我們知道呼叫函式innerDiffNode時,isHydrating的值是hydrating || props.dangerouslySetInnerHTML!=null,那麼isHydratingtrue表示的就是子dom節點不是由Preact所建立的,那麼現在看起來上面的判斷條件也非常容易理解了。如果節點child不是文字節點,根據該節點是否是由Preact所建立的做決定,如果是不是由Preact建立的,則新增到children,否則不新增。如果是文字節點的話,如果是由Preact建立的話則新增,否則執行child.nodeValue.trim(),我們知道函式trim返回的是去掉字串前後空格的新字串,如果該節點有非空字元,則會被新增到children中,否則不新增。這樣做的目的也無非是最大程度利用之前的文字節點,減少建立不必要的文字節點。

  • 第二部分程式碼:
if (vlen!==0) {

    for (let i=0; i<vlen; i++) {
        vchild = vchildren[i];
        child = null;

        // 嘗試通過鍵值匹配去尋找節點
        let key = vchild.key;
        if (key!=null) {
            if (keyedLen && keyed[key]!==undefined) {
                child = keyed[key];
                keyed[key] = undefined;
                keyedLen--;
            }
        }
        // 嘗試從現有的孩子節點中找出型別相同的節點
        else if (!child && min<childrenLen) {
            for (j=min; j<childrenLen; j++) {
                if (children[j]!==undefined && isSameNodeType(c = children[j], vchild, isHydrating)) {
                    child = c;
                    children[j] = undefined;
                    if (j===childrenLen-1) childrenLen--;
                    if (j===min) min++;
                    break;
                }
            }
        }
        // 變形匹配/尋找到/建立的DOM子元素來匹配vchild(深度匹配)
        child = idiff(child, vchild, context, mountAll);

        f = originalChildren[i];
        if (child && child!==dom && child!==f) {
            if (f==null) {
                dom.appendChild(child);
            }
            else if (child===f.nextSibling) {
                removeNode(f);
            }
            else {
                dom.insertBefore(child, f);
            }
        }
    }
}複製程式碼

  該部分程式碼首先對虛擬dom中的子元素進行遍歷,對每一個子元素,首先判斷該子元素是否含有屬性key,如果含有則在keyed中查詢對應keyed的dom元素,並在keyed將該元素刪除。否則在children查詢是否含有和該元素相同型別的節點(利用函式isSameNodeType),如果查詢到相同型別的節點,則在children中刪除並根據對應的情況(即查到的元素在children查詢範圍的首尾)縮小排查範圍。然後遞迴執行函式idiff,如果之前child沒有查詢到的話,會在idiff中建立對應型別的節點。然後根據之前的所分析的,idiff會返回新的dom節點。
  
  如果idiff返回dom不為空並且該dom與原始dom中對應位置的dom不相同時,將其新增到父節點。如果不存在對應位置的真實節點,則直接新增到父節點。如果child已經新增到對應位置的真實dom後,則直接將其移除當前位置的真實dom,否則都將其新增到對應位置之前。

  • 第三塊程式碼:
    if (keyedLen) {
        for (let i in keyed) if (keyed[i]!==undefined) recollectNodeTree(keyed[i], false);
    }
    // 移除沒有父節點的不帶有key值的子元素
    while (min<=childrenLen) {
        if ((child = children[childrenLen--])!==undefined) recollectNodeTree(child, false);
    }複製程式碼

  這段程式碼所作的工作就是將keyed中與children中沒有用到的原始dom節點回收。到此我們已經基本講完了整個diff的所有大致流程,還剩idiff中的diffAttributes函式沒有講,因為裡面涉及到dom中的事件觸發,所以還是有必要講一下:   

function diffAttributes(dom, attrs, old) {
    let name;

    // 通過將其設定為undefined,移除不在vnode中的屬性
    for (name in old) {
        // 判斷的條件是如果old[name]中存在,但attrs[name]不存在
        if (!(attrs && attrs[name]!=null) && old[name]!=null) {
            setAccessor(dom, name, old[name], old[name] = undefined, isSvgMode);
        }
    }
    // 增加或者更新的屬性
    for (name in attrs) {
        // 如果attrs中的屬性不是 children或者 innerHTML 並且
        // 要麼 之前的old裡面沒有該屬性 ====> 說明是新增屬性
        // 要麼 如果name是value或者checked屬性(表單), attrs[name] 與 dom[name] 不同,或者不是value或者checked屬性,則和old[name]屬性不同 ====> 說明是更新屬性
        if (name!=='children' && name!=='innerHTML' && (!(name in old) || attrs[name]!==(name==='value' || name==='checked' ? dom[name] : old[name]))) {
            setAccessor(dom, name, old[name], old[name] = attrs[name], isSvgMode);
        }
    }
}複製程式碼

  diffAttributes的引數分別對應於:

  • dom: 虛擬dom對應的真實dom
  • attrs: 期望的最終鍵值屬性對
  • old: 當前或者之前的屬性(從之前的VNode或者元素props屬性快取中)

    函式diffAttributes並不複雜,首先遍歷old中的屬性,如果當前的屬性attrs中不存在是,則通過函式setAccessor將其刪除。然後將attr中的屬性賦值通過setAccessor賦值給當前的dom元素。是否需要賦值需要同時滿足下滿三個條件:

  • 屬性不能是children,原因children表示的是子元素,其實Preact在h函式已經做了處理(詳情見系列文章第一篇),這裡其實是不會存在children屬性的。

  • 屬性也不能是innerHTML。其實這一點Preact與React是在這點是相同的,不能通過innerHTML給dom新增內容,只能通過dangerouslySetInnerHTML進行設定。
  • 屬性在該dom中不存在 或者 如果當該屬性不是value或者checked時,快取的屬性(old)必須和現在的屬性(attrs)不一樣,如果該屬性是value或者checked時,則dom的屬性必須和現在不一樣,這麼判斷的主要目的就是如果屬性值是value或者checked表明該dom屬於表單元素,防止該表單元素是不受控的,快取的屬性存在可能不等於當前dom中的屬性。那為什麼不都用dom中的屬性呢?肯定是由於JavaScript物件中取屬性要比dom中拿到屬性的速度快很多。

  到這裡我們有個地方需要注意的是,呼叫函式setAccessor時的第三個實參為old[name] = undefined或者old[name] = attrs[name],我們在前面說過,如果虛擬dom中的attributes發生改變時也需要將真實dom中的__preactattr_進行更新,其實更新的過程就發生在這裡,old的實參就是props = out[ATTR_KEY],所以更新old時也對應修改了dom的快取。

  我們最後需要關注的是函式setAccessor,這個函式比較長但是結構是及其的簡單:   

function setAccessor(node, name, old, value, isSvg) {
    if (name === 'className') name = 'class';

    if (name === 'key') {
        // key屬性忽略
    }
    else if (name === 'ref') {
        // 如果是ref 函式被改變了,以null去執行之前的ref函式,並以node節點去執行新的ref函式
        if (old) old(null);
        if (value) value(node);
    }
    else if (name === 'class' && !isSvg) {
        // 直接賦值
        node.className = value || '';
    }
    else if (name === 'style') {
        if (!value || typeof value === 'string' || typeof old === 'string') {
            node.style.cssText = value || '';
        }
        if (value && typeof value === 'object') {
            if (typeof old !== 'string') {
                // 從dom的style中剔除已經被刪除的屬性
                for (let i in old) if (!(i in value)) node.style[i] = '';
            }
            for (let i in value) {
                node.style[i] = typeof value[i] === 'number' && IS_NON_DIMENSIONAL.test(i) === false ? (value[i] + 'px') : value[i];
            }
        }
    }
    else if (name === 'dangerouslySetInnerHTML') {
        //dangerouslySetInnerHTML屬性設定
        if (value) node.innerHTML = value.__html || '';
    }
    else if (name[0] == 'o' && name[1] == 'n') {
        // 事件處理函式 屬性賦值
        // 如果事件的名稱是以Capture為結尾的,則去掉,並在捕獲階段節點監聽事件
        let useCapture = name !== (name = name.replace(/Capture$/, ''));
        name = name.toLowerCase().substring(2);
        if (value) {
            if (!old) node.addEventListener(name, eventProxy, useCapture);
        }
        else {
            node.removeEventListener(name, eventProxy, useCapture);
        }
        (node._listeners || (node._listeners = {}))[name] = value;
    }
    else if (name !== 'list' && name !== 'type' && !isSvg && name in node) {
        setProperty(node, name, value == null ? '' : value);
        if (value == null || value === false) node.removeAttribute(name);
    }
    else {
        // SVG元素
        let ns = isSvg && (name !== (name = name.replace(/^xlink\:?/, '')));
        if (value == null || value === false) {
            if (ns) node.removeAttributeNS('http://www.w3.org/1999/xlink', name.toLowerCase());
            else node.removeAttribute(name);
        }
        else if (typeof value !== 'function') {
            if (ns) node.setAttributeNS('http://www.w3.org/1999/xlink', name.toLowerCase(), value);
            else node.setAttribute(name, value);
        }
    }
}複製程式碼

  整個函式都是if-else的結構,首先看看各個引數:

  • node: 對應的dom節點
  • name: 屬性名
  • old: 該屬性之前儲存的值
  • value: 該屬性當前要修改的值
  • isSvg: 是否為SVG元素

  然後看一下函式的流程:

  • 如果屬性名為className,則屬性名修改為class,這一點Preact與React是不相同的,React對css中的類僅支援屬性名className,但Preact既支援className的屬性名也支援class,並且Preact更推薦使用class.
  • 如果屬性名為key時,不做任何處理。
  • 如果屬性名為class並且不是svg元素,則直接將值賦值給dom元素。
  • 如果屬性名為style時,第一種情況是將字串型別的樣式賦值給dom.style.cssText。如果value是空或者是字串這麼賦值非常能夠理解,但是為什麼之前的屬性值old是字串符為什麼也需要通過dom.style.cssText,經過我的實驗發現作用應該是覆蓋之前通過cssText賦值的樣式(所以這裡的程式碼並不是if-else),而是兩個if的結構。下面的第二種情況是value是物件型別,所進行的操作是剔除取消的屬性,新增新的或者更改的屬性。
  • 如果屬性是dangerouslySetInnerHTML,則將value中的__html值賦值給innerHtml屬性。
  • 如果屬性是以on開頭,說明要繫結的是事件,因為我們知道Preact不同於React,並沒有採用事件代理的機制,所有的事件都會被註冊到真實的dom中。而且另一點與React不相同的是,如果你的事件名後新增Capture,例如onClickCapture,那麼該事件將在dom的捕獲階段響應,預設會在冒泡事件響應。如果value存在則是註冊事件,否則會將註冊的事件移除。我們發現在呼叫addEventListener並沒有直接將value作為其第二個引數傳入,而是傳入了eventProxy:
function eventProxy(e) {
    return this._listeners[e.type](e);
}複製程式碼

  我們看到因為有語句(node._listeners || (node._listeners = {}))[name] = value,所以某個對應事件的處理函式是儲存在node._listeners物件中,因此當函式eventProxy呼叫時,就可以觸發對應的事件處理程式,其實這也算是一種簡單的事件代理機制,如果該元素對應的某個事件處理程式發生改變時,也就不需要刪除之前的處理事件並繫結新的處理,只需要改變node._listeners物件儲存的對應事件處理函式即可。   

  • 接下來為除了typelist以外的自有屬性進行賦值或者刪除。其中函式setProperty為:
    function setProperty(node, name, value) {
     try {
         node[name] = value;
     } catch (e) {
     }
    }複製程式碼
      這個函式嘗試給為DOM的自有屬性賦值,賦值的過程可能在於IE瀏覽器和FireFox中丟擲異常。所以這裡有一個try-catch的結構。
  • 最後是為svg元素以及普通元素的非自有屬性進行賦值或者刪除。因為對於非自有屬性是無非直接通過dom物件進行設定的,僅可以通過函式setAttribute進行賦值。

  到此為止,我們已經基本全部分析完了Preact中diff演算法的過程,我們看到Preact相比於龐大的React,短短數百行語句就實現了diff的功能並能達到一個相當不錯的效能。由於本人能力所限,不能達到面面俱到,但希望這篇文章能起到拋磚引玉的作用,如果不正確指出,歡迎指出和討論~

相關文章