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

前言

  首先歡迎你們關注個人掘金帳號和Github博客,也算是對個人一點鼓勵,畢竟寫東西無法得到變現,能堅持下去也是靠的是本身的熱情和你們的鼓勵。
  以前分享過幾篇關於React的文章:javascript

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

渲染與diff

render函數

  咱們知道在React中渲染是並非由React完成的,而是由ReactDOM中的render函數去實現的。其實在最先的版本中,render函數也是屬於React的,只不事後來React的開發者想實現一個於平臺無關的庫(其目的也是爲了React Native服務的),所以將Web中渲染的部分獨立成ReactDOM庫。Preact做爲一個極度精簡的庫,render函數是屬於Preact自己的。Preact的render函數與ReactDOM的render函數也是有有所區別的:html

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

  ReactDOM.render接受三個參數,element是須要渲染的React元素,而container掛載點,即React元素將被渲染進container中,第三個參數callback是可選的,當組件被渲染或者更新的時候會被調用。ReactDOM.render會返回渲染組元素的真實DOM節點。若是以前container中含有dom節點,則渲染時會將以前的全部節點清除。例如:java

html:node

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

javascript:react

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

  最終的顯示效果爲:git

Hello, world!github

  而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的功能並能達到一個至關不錯的性能。因爲本人能力所限,不能達到面面俱到,但但願這篇文章能起到拋磚引玉的做用,若是不正確指出,歡迎指出和討論~

相關文章
相關標籤/搜索