摘要:本系列文章將重點分析類似于的這類框架是如何實現(xiàn)的,歡迎大家關注和討論。作為一個極度精簡的庫,函數(shù)是屬于本身的。
前言
首先歡迎大家關注我的掘金賬號和Github博客,也算是對我的一點鼓勵,畢竟寫東西沒法獲得變現(xiàn),能堅持下去也是靠的是自己的熱情和大家的鼓勵。
之前分享過幾篇關于React的文章:
React技術內幕: key帶來了什么
React技術內幕: setState的秘密
其實我在閱讀React源碼的時候,真的非常痛苦。React的代碼及其復雜、龐大,閱讀起來挑戰(zhàn)非常大,但是這卻又擋不住我們的React的原理的好奇。前段時間有人就安利過Preact,千行代碼就基本實現(xiàn)了React的絕大部分功能,相比于React動輒幾萬行的代碼,Preact顯得別樣的簡潔,這也就為了我們學習React開辟了另一條路。本系列文章將重點分析類似于React的這類框架是如何實現(xiàn)的,歡迎大家關注和討論。如有不準確的地方,歡迎大家指正。
在上篇文章從preact了解一個類React的框架是怎么實現(xiàn)的(一): 元素創(chuàng)建我們了解了我們平時所書寫的JSX是怎樣轉化成Preact中的虛擬DOM結構的,接下來我們就要了解一下這些虛擬DOM節(jié)點是如何渲染成真實的DOM節(jié)點的以及虛擬DOM節(jié)點的改變如何映射到真實DOM節(jié)點的改變(也就是diff算法的過程)。這篇文章相比第一篇會比較冗長和枯燥,為了能集中分析diff過程,我們只關注dom元素,暫時不去考慮組件。
我們知道在React中渲染是并不是由React完成的,而是由ReactDOM中的render函數(shù)去實現(xiàn)的。其實在最早的版本中,render函數(shù)也是屬于React的,只不過后來React的開發(fā)者想實現(xiàn)一個于平臺無關的庫(其目的也是為了React Native服務的),因此將Web中渲染的部分獨立成ReactDOM庫。Preact作為一個極度精簡的庫,render函數(shù)是屬于Preact本身的。Preact的render函數(shù)與ReactDOM的render函數(shù)也是有有所區(qū)別的:
ReactDOM.render( element, container, [callback] )
ReactDOM.render接受三個參數(shù),element是需要渲染的React元素,而container掛載點,即React元素將被渲染進container中,第三個參數(shù)callback是可選的,當組件被渲染或者更新的時候會被調用。ReactDOM.render會返回渲染組元素的真實DOM節(jié)點。如果之前container中含有dom節(jié)點,則渲染時會將之前的所有節(jié)點清除。例如:
html:
Hello React!
javascript:
ReactDOM.render(Hello, world!
, document.getElementById("root") );
最終的顯示效果為:
Hello, world!
而Preact的render函數(shù)為:
Preact.render( vnode, parent, [merge] )
Preact.render與ReactDOM.render的前兩個參數(shù)代表的意義相同,區(qū)域在于最后一個,Preact.render可選的第三個參數(shù)merge,要求必須是第二個參數(shù)的子元素,是指會被替換的根節(jié)點,否則,如果沒有這個參數(shù),Preact 默認追加,而不是像React進行替換。
例如不存在第三個參數(shù)的情況下:
html:
Hello Preact!
javascript:
preact.render(Hello, world!
, document.getElementById("root") );
最終的顯示效果為:
Hello Preact
Hello, world!
如果調用函數(shù)有第三個參數(shù):
javascript:
preact.render(Hello, world!
, document.getElementById("root"), document.getElementById("container") );
顯示效果是:
Hello, world!
實現(xiàn)
其實在Preact中無論是初次渲染還是之后虛擬DOM改變導致的UI更新最終調用的都是diff函數(shù),這也是非常合理的,畢竟我們可以將首次渲染當做是diff過程中用現(xiàn)有的虛擬dom去與空的真實dom基礎上進行更新的過程。下面我們首先給出整個diff過程的大致流程圖,我們可以對照流程圖對代碼進行分析:
首先從render函數(shù)入手,render函數(shù)調用的就是diff函數(shù):
function render(vnode, parent, merge) { return diff(merge, vnode, {}, false, parent, false); }
我們可以看到Preact中的render調用了diff函數(shù),而diff定義在vdom/diff中:
function diff(dom, vnode, context, mountAll, parent, componentRoot) { // diffLevel為 0 時表示第一次進入diff函數(shù) if (!diffLevel++) { // 第一次執(zhí)行diff,查看我們是否在diff SVG元素或者是元素在SVG內部 isSvgMode = parent!=null && parent.ownerSVGElement!==undefined; // hydration 指示的是被diff的現(xiàn)存元素是否含有屬性props的緩存 // 屬性props的緩存被存在dom節(jié)點的__preactattr_屬性中 hydrating = dom!=null && !(ATTR_KEY in dom); } let ret = idiff(dom, vnode, context, mountAll, componentRoot); // 如果父節(jié)點之前沒有創(chuàng)建的這個子節(jié)點,則將子節(jié)點添加到父節(jié)點之后 if (parent && ret.parentNode!==parent) parent.appendChild(ret); // diffLevel回減到0說明已經要結束diff的調用 if (!--diffLevel) { hydrating = false; // 負責觸發(fā)組件的componentDidMount生命周期函數(shù) if (!componentRoot) flushMounts(); } return ret; }
這部分的函數(shù)內容比較龐雜,很難做到面面俱到,我會在代碼中做相關的注釋。diff函數(shù)主要負責就是將當前的虛擬node節(jié)點映射到真實的DOM節(jié)點中。參數(shù)如下:
vnode: 不用說,就是我們需要渲染的虛擬dom節(jié)點
parent: 就是你要將虛擬dom掛載的父節(jié)點
dom: 這里的dom其實就是當前的vnode所對應的之前未更新的真實dom。那么就有兩種可能: 第一就是null或者是上面例子的contaienr(就是render函數(shù)對應的第三個參數(shù)),其本質都是首次渲染,第二種就是vnode的對應的未更新的真實dom,那么對應的就是渲染刷新界面。
context: 組件相關,暫時可以不考慮,對應React中的context。
mountAll: 組件相關,暫時可以不考慮
componentRoot: 組件相關,暫時可以不考慮
vnode對應的就是一個遞歸的結構,那么不用想diff函數(shù)肯定也是遞歸的。我們首先看一下函數(shù)初始的幾個變量:
diffLevel:用來記錄當前渲染的層數(shù)(遞歸的深度),其實在代碼中并沒有在進入每層遞歸的時候都增加并且退出遞歸的時候減小。只是記錄了是不是渲染的第一層,所以對應的值只有0與1。
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元素必須存在。那么什么時候才會出現(xiàn)dom節(jié)點中沒有存儲緩存?只有當前的dom節(jié)點并不是由Preact所創(chuàng)建并渲染的才會使得hydrating為true。
idiff函數(shù)就是diff算法的內部實現(xiàn),相對來說代碼會比較復雜,idiff會返回虛擬dom對應創(chuàng)建的真實dom節(jié)點。下面的代碼是是向父級元素有選擇性添加創(chuàng)建的dom節(jié)點,之所以這么做,主要是有可能之前該節(jié)點就沒有渲染過,所以需要將新創(chuàng)建的dom節(jié)點添加到父級dom。但是如果僅僅只是修改了之前dom中的某一個屬性(比如樣式),那么其實是不需要添加的,因為該dom節(jié)點已經存在于父級dom。
后面的內容,一方面結束遞歸之后,回置diffLevel(diffLevel此時應該為0,表明此時要退出diff函數(shù)),退出diff前,將hydrating置為false,相當于一個復位的功能。下面的flushMounts函數(shù)是組件相關,在這里我們只需要知道它要做的就是去執(zhí)行所有剛才安裝組件的componentDidMount生命周期函數(shù)。
下面讓我們看看idiff的實現(xiàn)(代碼已經分塊,具體見注釋),代碼比較長,可以先大致瀏覽一下,做到心里有數(shù),下面會逐塊分析,可以對照流程圖看:
/** 內部的diff函數(shù) */ function idiff(dom, vnode, context, mountAll, componentRoot) { // block-1 let out = dom, prevSvgMode = isSvgMode; // 空的node 渲染空的文本節(jié)點 if (vnode==null || typeof vnode==="boolean") vnode = ""; // String & Number 類型的節(jié)點 創(chuàng)建/更新 文本節(jié)點 if (typeof vnode==="string" || typeof vnode==="number") { // 更新如果存在的原有文本節(jié)點 // 這里如果節(jié)點值是文本類型,其父節(jié)點又是文本類型的節(jié)點,則直接更新 if (dom && dom.splitText!==undefined && dom.parentNode && (!dom._component || componentRoot)) { if (dom.nodeValue!=vnode) { dom.nodeValue = vnode; } } else { // 不是文本節(jié)點,替換之前的節(jié)點,回收之前的節(jié)點 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; // 如果不是一個已經存在的元素或者類型有問題,則重新創(chuàng)建一個 vnodeName = String(vnodeName); if (!dom || !isNamedNode(dom, vnodeName)) { out = createNode(vnodeName, isSvgMode); if (dom) { // 移動dom中的子元素到out中 while (dom.firstChild) out.appendChild(dom.firstChild); // 如果之前的元素已經屬于某一個DOM節(jié)點,則將其替換 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; } // 優(yōu)化: 對于元素只包含一個單一文本節(jié)點的優(yōu)化路徑 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]; } } // 否則,如果有存在的子節(jié)點或者新的孩子節(jié)點,執(zhí)行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函數(shù)所接受的參數(shù)與diff是完全相同的,但是二者也是有所區(qū)別的。diff在渲染過程(或者更新過程)中僅僅會調用一次,所以說diff函數(shù)接受的vnode就是整個應用的虛擬dom,而dom也就是當前整個虛擬dom所對應的節(jié)點。但是idiff的調用是遞歸的,因此dom和vnode在開始時與diff函數(shù)相等,但是在之后遞歸的過程中,就對應的是整個應用的部分。
首先來看第一塊(block-1)的代碼:
變量prevSvgMode用來存儲之前的isSvgMode,目的就是在退出這一次遞歸調用時恢復到調用前的值。然后如果vnode是null或者布爾類型,都按照空字符去處理。接下的渲染是整對于字符串(sting或者number類型),主要分為兩部分: 更新或者創(chuàng)建元素。如果dom本身存在并且就是一個文本節(jié)點,那就只需要將其中的值更新為當前的值即可。否則創(chuàng)建一個新的文本節(jié)點,并且將其替換到父元素上,并回收之前的節(jié)點值。因為文本節(jié)點是沒有什么需要緩存的屬性值(文本的顏色等屬性實際是存儲的父級的元素中),所以直接將其ATTR_KEY(實際值為__preactattr_)賦值為true,并返回新創(chuàng)建的元素。這段代碼有兩個需要注意的地方:
if (dom.nodeValue!=vnode) { dom.nodeValue = vnode; }
為什么在賦值文本節(jié)點值時,需要首先進行一個判斷?根據(jù)代碼注釋得知Firfox瀏覽器不會默認做等值比較(其他的瀏覽器例如Chrome即使直接賦值,如果相等也不會修改dom元素),所以人為的增加了比較的過程,目的就是為了防止文本節(jié)點每次都會被更新,這算是一個瀏覽器怪癖(quirk)。
回收dom節(jié)點的recollectNodeTree函數(shù)做了什么?看代碼:
/** * 遞歸地回收(或者卸載)節(jié)點及其后代節(jié)點 * @param node * @param unmountOnly 如果為`true`,僅僅觸發(fā)卸載的生命周期,跳過刪除 */ function recollectNodeTree(node, unmountOnly) { let component = node._component; if (component) { // 如果該節(jié)點屬于某個組件,卸載該組件(最終在這里遞歸),主要包括組件的回收和相依卸載生命周期的調用 unmountComponent(component); } else { // 如果節(jié)點含有ref函數(shù),則執(zhí)行ref函數(shù),參數(shù)為null(這里是React的規(guī)范,用于取消設置引用) // 確實在React如果設置了ref的話,在卸載的時候,也會被回調,得到的參數(shù)是null if (node[ATTR_KEY]!=null && node[ATTR_KEY].ref) node[ATTR_KEY].ref(null); if (unmountOnly===false || node[ATTR_KEY]==null) { //要做的無非是從父節(jié)點將該子節(jié)點刪除 removeNode(node); } //遞歸刪除子節(jié)點 removeChildren(node); } } /** * 回收/卸載所有的子元素 * 我們在這里使用了.lastChild而不是使用.firstChild,是因為訪問節(jié)點的代價更低。 */ export function removeChildren(node) { node = node.lastChild; while (node) { let next = node.previousSibling; recollectNodeTree(node, true); node = next; } } /** 從父節(jié)點刪除該節(jié)點 * @param {Element} node 待刪除的節(jié)點 */ function removeNode(node) { let parentNode = node.parentNode; if (parentNode) parentNode.removeChild(node); }
我們看到在函數(shù)recollectNodeTree中,如果dom元素屬于某個組件,首先遞歸卸載組件(不是本次講述的重點,主要包括組件的回收和相依卸載生命周期的調用)。否則,只需要先判別該dom節(jié)點中是否被在jsx中存在ref函數(shù)(也是緩存在__preactattr_屬性中),因為存在ref函數(shù)時,我們在組件卸載時以null參數(shù)作為回調(React文檔做了相應的規(guī)定,詳情見Refs and the DOM)。recollectNodeTree中第二個參數(shù)unmountOnly,表示僅僅觸發(fā)卸載的生命周期,跳過刪除的過程,如果unmountOnly為false或者dom中的ATTR_KEY屬性不存在(說明這個屬性不是preact所渲染的,否則肯定會存在該屬性),則直接將其從父節(jié)點刪除。最后遞歸刪除子節(jié)點,我們可以看到遞歸刪除子元素的過程是從右到左刪除的(首先刪除的lastChild元素),主要考慮到的是從后訪問會有性能的優(yōu)勢。我們在這里(block-1)調用函數(shù)recollectNodeTree的第二個參數(shù)是true,原因是在調用之前我們已經將其在父元素中進行替換,所以是不需要進行調用的函數(shù)removeNode再進行刪除該節(jié)點的。
第二塊代碼,主要是針對的組件的渲染,如果vnode.nodeName對應的是函數(shù)類型,表明要渲染的是一個組件,直接調用了函數(shù)buildComponentFromVNode(組件不是本次敘述內容)。
第三塊代碼,首先:
isSvgMode = vnodeName==="svg" ? true : vnodeName==="foreignObject" ? false : isSvgMode;
變量isSvgMode還是用來標記當前創(chuàng)建的元素是否是SVG元素。foreignObject元素允許包含外來的XML命名空間,一個foreignObject內部的任何SVG元素都不會被繪制,所以如果是vnodeName為foreignObject話,isSvgMode會被置為false(其實Svg對我來說也是比較生疏的內容,但是不影響我們分析整個渲染過程)。
// 如果不是一個已經存在的元素或者類型有問題,則重新創(chuàng)建一個 vnodeName = String(vnodeName); if (!dom || !isNamedNode(dom, vnodeName)) { out = createNode(vnodeName, isSvgMode); if (dom) { // 移動dom中的子元素到out中 while (dom.firstChild) out.appendChild(dom.firstChild); // 如果之前的元素已經屬于某一個DOM節(jié)點,則將其替換 if (dom.parentNode) dom.parentNode.replaceChild(out, dom); // 回收之前的dom元素(跳過非元素類型) recollectNodeTree(dom, true); } }
然后開始嘗試創(chuàng)建dom元素,如果之前的dom為空(說明之前沒有渲染)或者dom的名稱與vnode.nodename不一致時,說明我們要創(chuàng)建新的元素,然后如果之前的dom節(jié)點中存在子元素,則將其全部移入新創(chuàng)建的元素中。如果之前的dom已經有父元素了,則將其替換成新的元素,最后回收該元素。
在判斷節(jié)點dom類型與虛擬dom的vnodeName類型是否相同時使用了函數(shù)isNamedNode:
function isNamedNode(node, nodeName) { return node.normalizedNodeName===nodeName || node.nodeName.toLowerCase()===nodeName.toLowerCase(); }
如果節(jié)點是由Preact創(chuàng)建的(即由函數(shù)createNode創(chuàng)建的),其中dom節(jié)點中含有屬性normalizedNodeName(node.normalizedNodeName = nodeName),則使用normalizedNodeName去判斷節(jié)點類型是否相等,否則直接采用dom節(jié)點中的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; } // 優(yōu)化: 對于元素只包含一個單一文本節(jié)點的優(yōu)化路徑 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]; } } // 否則,如果有存在的子節(jié)點或者新的孩子節(jié)點,執(zhí)行diff else if (vchildren && vchildren.length || fc!=null) { innerDiffNode(out, vchildren, context, mountAll, hydrating || props.dangerouslySetInnerHTML!=null); }
然后我們看到,如果out是新創(chuàng)建的元素或者該元素不是由Preact創(chuàng)建的(即不存在屬性__preactattr_),我們會初始化out中的__preactattr_屬性中并將out元素(剛創(chuàng)建的dom元素)中屬性attributes緩存在out元素的ATTR_KEY(__preactattr_)屬性上。但是需要注意的是,比如某個節(jié)點的屬性發(fā)生改變,比如name由1變成了2,那么out屬性中的緩存(__preactattr_)也需要得到更新,但是更新的操作并不發(fā)生在這里,而是下面的diffAttributes函數(shù)中。
接下來就是處理子元素只有一個文本節(jié)點的情況(其實這部分也可以沒有,通過下一層的遞歸也能解決,這樣做只不過是為了優(yōu)化性能),比如處理下面的情形:
1
進入單個節(jié)點的判斷條件也是比較明確的,唯一需要注意的一點是,必須滿足hydrating不為true,因為我們知道當hydrating為true是說明當前的節(jié)點并不是由Preact渲染的,因此不能進行直接的優(yōu)化,需要由下一層遞歸中創(chuàng)建新的文本元素。
//將props和atrributes從VNode中應用到DOM元素 diffAttributes(out, vnode.attributes, props); // 恢復之前的SVG模式 isSvgMode = prevSvgMode; return out;
函數(shù)diffAttributes的主要作用就是將虛擬dom中attributes更新到真實的dom中(后面詳細講)。最后重置變量isSvgMode,并返回vnode所渲染的真實dom節(jié)點。
看完了函數(shù)idiff,接下來要關心的就是,在idiff中對虛擬dom的子元素調用的innerDiffNode函數(shù)(代碼依然很長,我們依然做分塊,對照流程圖看):
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 // 創(chuàng)建一個包含key的子元素和一個不包含有子元素的Map if (len!==0) { for (let i=0; i首先看innerDiffNode函數(shù)的參數(shù):
dom: diff的虛擬子元素的父元素對應的真實dom節(jié)點
vchildren: diff的虛擬子元素
context: 類似于React中的context,組件使用
mountAll: 組件相關,暫時可以不考慮
componentRoot: 組件相關,暫時可以不考慮
函數(shù)代碼將近百行,為了方便閱讀,我們將其分為四個部分(看代碼注釋):
第一部分代碼:
// 創(chuàng)建一個包含key的子元素和一個不包含有子元素的Map if (len!==0) { //len === dom.childNodes.length for (let i=0; i我們所希望的diff的過程肯定是以最少的dom操作使得更改后的dom與虛擬dom相匹配,所以之前父節(jié)點的dom重用也是非常必要。len是父級dom的子元素個數(shù),首先對所有的子元素進行遍歷,如果該元素是由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)我們分析一下,我們知道hydrating為true時表示的是dom元素不是Preact創(chuàng)建的,我們知道調用函數(shù)innerDiffNode時,isHydrating的值是hydrating || props.dangerouslySetInnerHTML!=null,那么isHydrating為true表示的就是子dom節(jié)點不是由Preact所創(chuàng)建的,那么現(xiàn)在看起來上面的判斷條件也非常容易理解了。如果節(jié)點child不是文本節(jié)點,根據(jù)該節(jié)點是否是由Preact所創(chuàng)建的做決定,如果是不是由Preact創(chuàng)建的,則添加到children,否則不添加。如果是文本節(jié)點的話,如果是由Preact創(chuàng)建的話則添加,否則執(zhí)行child.nodeValue.trim(),我們知道函數(shù)trim返回的是去掉字符串前后空格的新字符串,如果該節(jié)點有非空字符,則會被添加到children中,否則不添加。這樣做的目的也無非是最大程度利用之前的文本節(jié)點,減少創(chuàng)建不必要的文本節(jié)點。
第二部分代碼:
if (vlen!==0) { for (let i=0; i該部分代碼首先對虛擬dom中的子元素進行遍歷,對每一個子元素,首先判斷該子元素是否含有屬性key,如果含有則在keyed中查找對應keyed的dom元素,并在keyed將該元素刪除。否則在children查找是否含有和該元素相同類型的節(jié)點(利用函數(shù)isSameNodeType),如果查找到相同類型的節(jié)點,則在children中刪除并根據(jù)對應的情況(即查到的元素在children查找范圍的首尾)縮小排查范圍。然后遞歸執(zhí)行函數(shù)idiff,如果之前child沒有查找到的話,會在idiff中創(chuàng)建對應類型的節(jié)點。然后根據(jù)之前的所分析的,idiff會返回新的dom節(jié)點。
如果idiff返回dom不為空并且該dom與原始dom中對應位置的dom不相同時,將其添加到父節(jié)點。如果不存在對應位置的真實節(jié)點,則直接添加到父節(jié)點。如果child已經添加到對應位置的真實dom后,則直接將其移除當前位置的真實dom,否則都將其添加到對應位置之前。第三塊代碼:
if (keyedLen) { for (let i in keyed) if (keyed[i]!==undefined) recollectNodeTree(keyed[i], false); } // 移除沒有父節(jié)點的不帶有key值的子元素 while (min<=childrenLen) { if ((child = children[childrenLen--])!==undefined) recollectNodeTree(child, false); }這段代碼所作的工作就是將keyed中與children中沒有用到的原始dom節(jié)點回收。到此我們已經基本講完了整個diff的所有大致流程,還剩idiff中的diffAttributes函數(shù)沒有講,因為里面涉及到dom中的事件觸發(fā),所以還是有必要講一下:
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的參數(shù)分別對應于:
dom: 虛擬dom對應的真實dom
attrs: 期望的最終鍵值屬性對
old: 當前或者之前的屬性(從之前的VNode或者元素props屬性緩存中)
函數(shù)diffAttributes并不復雜,首先遍歷old中的屬性,如果當前的屬性attrs中不存在是,則通過函數(shù)setAccessor將其刪除。然后將attr中的屬性賦值通過setAccessor賦值給當前的dom元素。是否需要賦值需要同時滿足下滿三個條件:
屬性不能是children,原因children表示的是子元素,其實Preact在h函數(shù)已經做了處理(詳情見系列文章第一篇),這里其實是不會存在children屬性的。
屬性也不能是innerHTML。其實這一點Preact與React是在這點是相同的,不能通過innerHTML給dom添加內容,只能通過dangerouslySetInnerHTML進行設置。
屬性在該dom中不存在 或者 如果當該屬性不是value或者checked時,緩存的屬性(old)必須和現(xiàn)在的屬性(attrs)不一樣,如果該屬性是value或者checked時,則dom的屬性必須和現(xiàn)在不一樣,這么判斷的主要目的就是如果屬性值是value或者checked表明該dom屬于表單元素,防止該表單元素是不受控的,緩存的屬性存在可能不等于當前dom中的屬性。那為什么不都用dom中的屬性呢?肯定是由于JavaScript對象中取屬性要比dom中拿到屬性的速度快很多。
到這里我們有個地方需要注意的是,調用函數(shù)setAccessor時的第三個實參為old[name] = undefined或者old[name] = attrs[name],我們在前面說過,如果虛擬dom中的attributes發(fā)生改變時也需要將真實dom中的__preactattr_進行更新,其實更新的過程就發(fā)生在這里,old的實參就是props = out[ATTR_KEY],所以更新old時也對應修改了dom的緩存。
我們最后需要關注的是函數(shù)setAccessor,這個函數(shù)比較長但是結構是及其的簡單:
function setAccessor(node, name, old, value, isSvg) { if (name === "className") name = "class"; if (name === "key") { // key屬性忽略 } else if (name === "ref") { // 如果是ref 函數(shù)被改變了,以null去執(zhí)行之前的ref函數(shù),并以node節(jié)點去執(zhí)行新的ref函數(shù) 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") { // 事件處理函數(shù) 屬性賦值 // 如果事件的名稱是以Capture為結尾的,則去掉,并在捕獲階段節(jié)點監(jiān)聽事件 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); } } }整個函數(shù)都是if-else的結構,首先看看各個參數(shù):
node: 對應的dom節(jié)點
name: 屬性名
old: 該屬性之前存儲的值
value: 該屬性當前要修改的值
isSvg: 是否為SVG元素
然后看一下函數(shù)的流程:
如果屬性名為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,經過我的實驗發(fā)現(xiàn)作用應該是覆蓋之前通過cssText賦值的樣式(所以這里的代碼并不是if-else),而是兩個if的結構。下面的第二種情況是value是對象類型,所進行的操作是剔除取消的屬性,添加新的或者更改的屬性。
如果屬性是dangerouslySetInnerHTML,則將value中的__html值賦值給innerHtml屬性。
如果屬性是以on開頭,說明要綁定的是事件,因為我們知道Preact不同于React,并沒有采用事件代理的機制,所有的事件都會被注冊到真實的dom中。而且另一點與React不相同的是,如果你的事件名后添加Capture,例如onClickCapture,那么該事件將在dom的捕獲階段響應,默認會在冒泡事件響應。如果value存在則是注冊事件,否則會將注冊的事件移除。我們發(fā)現(xiàn)在調用addEventListener并沒有直接將value作為其第二個參數(shù)傳入,而是傳入了eventProxy:
function eventProxy(e) { return this._listeners[e.type](e); }我們看到因為有語句(node._listeners || (node._listeners = {}))[name] = value,所以某個對應事件的處理函數(shù)是保存在node._listeners對象中,因此當函數(shù)eventProxy調用時,就可以觸發(fā)對應的事件處理程序,其實這也算是一種簡單的事件代理機制,如果該元素對應的某個事件處理程序發(fā)生改變時,也就不需要刪除之前的處理事件并綁定新的處理,只需要改變node._listeners對象存儲的對應事件處理函數(shù)即可。
接下來為除了type和list以外的自有屬性進行賦值或者刪除。其中函數(shù)setProperty為:
function setProperty(node, name, value) { try { node[name] = value; } catch (e) { } }這個函數(shù)嘗試給為DOM的自有屬性賦值,賦值的過程可能在于IE瀏覽器和FireFox中拋出異常。所以這里有一個try-catch的結構。
最后是為svg元素以及普通元素的非自有屬性進行賦值或者刪除。因為對于非自有屬性是無非直接通過dom對象進行設置的,僅可以通過函數(shù)setAttribute進行賦值。
到此為止,我們已經基本全部分析完了Preact中diff算法的過程,我們看到Preact相比于龐大的React,短短數(shù)百行語句就實現(xiàn)了diff的功能并能達到一個相當不錯的性能。由于本人能力所限,不能達到面面俱到,但希望這篇文章能起到拋磚引玉的作用,如果不正確指出,歡迎指出和討論~
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/88629.html
摘要:組件渲染首先我們來了解組件返回的虛擬是怎么渲染為真實,來看一下的組件是如何構造的可能我們會想當然地認為組件的構造函數(shù)定義將會及其復雜,事實上恰恰相反,的組件定義代碼極少。 前言 首先歡迎大家關注我的掘金賬號和Github博客,也算是對我的一點鼓勵,畢竟寫東西沒法獲得變現(xiàn),能堅持下去也是靠的是自己的熱情和大家的鼓勵。 之前分享過幾篇關于React的文章: React技術內幕: k...
摘要:是一個最小的庫,但由于其對尺寸的追求,它的很多代碼可讀性比較差,市面上也很少有全面且詳細介紹的文章,本篇文章希望能幫助你學習的源碼。建議與源碼一起閱讀本文。 作為一名前端,我們需要深入學習react的運行機制,但是react源碼量已經相當龐大,從學習的角度,性價比不高,所以學習一個react mini庫是一個深入學習react的一個不錯的方法。 preact是一個最小的react mi...
摘要:市面上竟然擁有多個虛擬庫。虛擬庫,就是出來后的一種新式庫,以虛擬與算法為核心,屏蔽操作,操作數(shù)據(jù)即操作視圖。及其他虛擬庫已經將虛擬的生成交由與處理了,因此不同點是,虛擬的結構與算法。因此虛擬庫是分為兩大派系算法派與擬態(tài)派。 去哪兒網迷你React是年初立項的新作品,在這前,去哪兒網已經深耕多年,擁有QRN(react-native的公司制定版),HY(基于React的hybird方案)...
摘要:最后刪除新的樹中不存在的節(jié)點。而中會記錄對其做了相應的優(yōu)化,節(jié)點的的情況下,不做移動操作。這種情況,在中得到了優(yōu)化,通過四個指針,在每次循環(huán)中先處理特殊情況,并通過縮小指針范圍,獲得性能上的提升。 上篇文章已經介紹過idff的處理邏輯主要分為三塊,處理textNode,element及component,但具體怎么處理component還沒有詳細介紹,接下來講一下preact是如何處理...
摘要:用過的同學都知道,性能優(yōu)化的關鍵就是,最被詬病的也是這個,很多開發(fā)者也吐槽這個鉤子函數(shù),也可以配合不可變數(shù)據(jù)類型,直接進行引用地址比較,來決定組件是否需要更新。 大家好,這次給大家講下 Omi 框架 以及即將發(fā)布的 Omim 大家有沒有數(shù)左邊的圖片里有多少個 Omi?Omi 團隊很在意這里,特意數(shù)了下,有三個。Omi 團隊希望 Omi 以后在各大會議里能夠印刷得更加大一些。今天給大家?guī)淼闹?..
閱讀 2077·2023-04-25 19:15
閱讀 2245·2021-11-23 09:51
閱讀 1264·2021-11-17 09:33
閱讀 2165·2021-08-26 14:15
閱讀 2476·2019-08-30 15:54
閱讀 1582·2019-08-30 15:54
閱讀 2167·2019-08-30 12:50
閱讀 1132·2019-08-29 17:08