目錄
- 前言
- virtual dom
- 分析diff
- 總結(jié)
前言
vue2.0加入了virtual dom载萌,有向react靠攏的意思。vue的diff位于patch.js文件中奈揍,一個(gè)小框架aoy也同樣使用此算法拥褂,該算法來源于snabbdom,復(fù)雜度為O(n)争占。
了解diff過程可以讓我們更高效的使用框架。
本文力求以圖文并茂的方式來講明這個(gè)diff的過程序目。
virtual dom
如果不了解virtual dom,要理解diff的過程是比較困難的伯襟。虛擬dom對應(yīng)的是真實(shí)dom猿涨, 使用document.CreateElement
和 document.CreateTextNode
創(chuàng)建的就是真實(shí)節(jié)點(diǎn)。
我們可以做個(gè)試驗(yàn)姆怪。打印出一個(gè)空元素的第一層屬性叛赚,可以看到標(biāo)準(zhǔn)讓元素實(shí)現(xiàn)的東西太多了。如果每次都重新生成新的元素稽揭,對性能是巨大的浪費(fèi)俺附。
var mydiv = document.createElement('div');
for(var k in mydiv ){
console.log(k)
}
virtual dom就是解決這個(gè)問題的一個(gè)思路,到底什么是virtual dom呢溪掀?通俗易懂的來說就是用一個(gè)簡單的對象去代替復(fù)雜的dom對象事镣。
舉個(gè)簡單的例子,我們在body里插入一個(gè)class為a的div揪胃。
var mydiv = document.createElement('div');
mydiv.className = 'a';
document.body.appendChild(mydiv);
對于這個(gè)div我們可以用一個(gè)簡單的對象mydivVirtual代表它璃哟,它存儲(chǔ)了對應(yīng)dom的一些重要參數(shù)氛琢,在改變dom之前,會(huì)先比較相應(yīng)虛擬dom的數(shù)據(jù)随闪,如果需要改變阳似,才會(huì)將改變應(yīng)用到真實(shí)dom上。
//偽代碼
var mydivVirtual = {
tagName: 'DIV',
className: 'a'
};
var newmydivVirtual = {
tagName: 'DIV',
className: 'b'
}
if(mydivVirtual.tagName !== newmydivVirtual.tagName || mydivVirtual.className !== newmydivVirtual.className){
change(mydiv)
}
// 會(huì)執(zhí)行相應(yīng)的修改 mydiv.className = 'b';
//最后 <div class='b'></div>
讀到這里就會(huì)產(chǎn)生一個(gè)疑問铐伴,為什么不直接修改dom而需要加一層virtual dom呢撮奏?
很多時(shí)候手工優(yōu)化dom確實(shí)會(huì)比virtual dom效率高,對于比較簡單的dom結(jié)構(gòu)用手工優(yōu)化沒有問題当宴,但當(dāng)頁面結(jié)構(gòu)很龐大畜吊,結(jié)構(gòu)很復(fù)雜時(shí),手工優(yōu)化會(huì)花去大量時(shí)間即供,而且可維護(hù)性也不高定拟,不能保證每個(gè)人都有手工優(yōu)化的能力。至此逗嫡,virtual dom的解決方案應(yīng)運(yùn)而生青自,virtual dom很多時(shí)候都不是最優(yōu)的操作,但它具有普適性驱证,在效率延窜、可維護(hù)性之間達(dá)平衡曼尊。
virtual dom 另一個(gè)重大意義就是提供一個(gè)中間層粘招,js去寫ui,ios安卓之類的負(fù)責(zé)渲染怀喉,就像reactNative一樣伙单。
分析diff
一篇相當(dāng)經(jīng)典的文章React’s diff algorithm中的圖获高,react的diff其實(shí)和vue的diff大同小異。所以這張圖能很好的解釋過程吻育。比較只會(huì)在同層級進(jìn)行, 不會(huì)跨層級比較念秧。
舉個(gè)形象的例子。
<!-- 之前 -->
<div> <!-- 層級1 -->
<p> <!-- 層級2 -->
<b> aoy </b> <!-- 層級3 -->
<span>diff</Span>
</P>
</div>
<!-- 之后 -->
<div> <!-- 層級1 -->
<p> <!-- 層級2 -->
<b> aoy </b> <!-- 層級3 -->
</p>
<span>diff</Span>
</div>
我們可能期望將<span>
直接移動(dòng)到<p>
的后邊布疼,這是最優(yōu)的操作摊趾。但是實(shí)際的diff操作是移除<p>
里的<span>
在創(chuàng)建一個(gè)新的<span>
插到<p>
的后邊。
因?yàn)樾录拥?code><span>在層級2游两,舊的在層級3砾层,屬于不同層級的比較。
源碼分析
文中的代碼位于aoy-diff中贱案,已經(jīng)精簡了很多代碼肛炮,留下最核心的部分。
diff的過程就是調(diào)用patch函數(shù),就像打補(bǔ)丁一樣修改真實(shí)dom铸董。
function patch (oldVnode, vnode) {
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode)
} else {
const oEl = oldVnode.el
let parentEle = api.parentNode(oEl)
createEle(vnode)
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
api.removeChild(parentEle, oldVnode.el)
oldVnode = null
}
}
return vnode
}
patch
函數(shù)有兩個(gè)參數(shù)祟印,vnode
和oldVnode
,也就是新舊兩個(gè)虛擬節(jié)點(diǎn)粟害。在這之前蕴忆,我們先了解完整的vnode
都有什么屬性,舉個(gè)一個(gè)簡單的例子:
// body下的 <div id="v" class="classA"><div> 對應(yīng)的 oldVnode 就是
{
el: div //對真實(shí)的節(jié)點(diǎn)的引用悲幅,本例中就是document.querySelector('#id.classA')
tagName: 'DIV', //節(jié)點(diǎn)的標(biāo)簽
sel: 'div#v.classA' //節(jié)點(diǎn)的選擇器
data: null, // 一個(gè)存儲(chǔ)節(jié)點(diǎn)屬性的對象套鹅,對應(yīng)節(jié)點(diǎn)的el[prop]屬性,例如onclick , style
children: [], //存儲(chǔ)子節(jié)點(diǎn)的數(shù)組汰具,每個(gè)子節(jié)點(diǎn)也是vnode結(jié)構(gòu)
text: null, //如果是文本節(jié)點(diǎn)卓鹿,對應(yīng)文本節(jié)點(diǎn)的textContent,否則為null
}
需要注意的是留荔,el
屬性引用的是此 virtual dom
對應(yīng)的真實(shí)dom
吟孙,patch
的vnode
參數(shù)的el
最初是null
,因?yàn)?code>patch之前它還沒有對應(yīng)的真實(shí)dom
聚蝶。
來到patch的第一部分杰妓,
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode)
}
sameVnode
函數(shù)就是看這兩個(gè)節(jié)點(diǎn)是否值得比較,代碼相當(dāng)簡單:
function sameVnode (a, b) {
return (
a.key === b.key && // key值
a.tagName === b.tagName && // 標(biāo)簽名
a.isComment === b.isComment && // 是否為注釋節(jié)點(diǎn)
// 是否都定義了data碘勉,data包含一些具體信息巷挥,例如onclick , style
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b) // 當(dāng)標(biāo)簽是<input>的時(shí)候,type必須相同
)
}
兩個(gè)vnode
的key
和sel
相同才去比較它們验靡,比如p
和span
倍宾,div.classA
和div.classB
都被認(rèn)為是不同結(jié)構(gòu)而不去比較它們。
如果值得比較會(huì)執(zhí)行patchVnode(oldVnode, vnode)
胜嗓,稍后會(huì)詳細(xì)講patchVnode
函數(shù)高职。
當(dāng)節(jié)點(diǎn)不值得比較,進(jìn)入else中
else {
const oEl = oldVnode.el
let parentEle = api.parentNode(oEl)
createEle(vnode)
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
api.removeChild(parentEle, oldVnode.el)
oldVnode = null
}
}
過程如下:
取得
oldvnode.el
的父節(jié)點(diǎn)辞州,parentEle
是真實(shí)dom
createEle(vnode)
會(huì)為vnode
創(chuàng)建它的真實(shí)dom
初厚,令vnode.el
=真實(shí)dom
parentEle
將新的dom
插入,移除舊的dom
當(dāng)不值得比較時(shí)孙技,新節(jié)點(diǎn)直接把老節(jié)點(diǎn)整個(gè)替換了
最后
return vnode
patch最后會(huì)返回vnode,vnode和進(jìn)入patch之前的不同在哪排作?
沒錯(cuò)牵啦,就是vnode.el,唯一的改變就是之前vnode.el = null, 而現(xiàn)在它引用的是對應(yīng)的真實(shí)dom妄痪。
var oldVnode = patch (oldVnode, vnode)
至此完成一個(gè)patch過程哈雏。
patchVnode
兩個(gè)節(jié)點(diǎn)值得比較時(shí),會(huì)調(diào)用patchVnode
函數(shù)
patchVnode (oldVnode, vnode) {
const el = vnode.el = oldVnode.el
let i, oldCh = oldVnode.children, ch = vnode.children
if (oldVnode === vnode) return
if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
api.setTextContent(el, vnode.text)
}else {
updateEle(el, vnode, oldVnode)
if (oldCh && ch && oldCh !== ch) {
updateChildren(el, oldCh, ch)
}else if (ch){
createEle(vnode) //create el's children dom
}else if (oldCh){
api.removeChildren(el)
}
}
}
const el = vnode.el = oldVnode.el 這是很重要的一步,讓vnode.el引用到現(xiàn)在的真實(shí)dom裳瘪,當(dāng)el修改時(shí)土浸,vnode.el會(huì)同步變化。
節(jié)點(diǎn)的比較有5種情況:
if (oldVnode === vnode)
彭羹,他們的引用一致黄伊,可以認(rèn)為沒有變化。if(oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text)
派殷,文本節(jié)點(diǎn)的比較还最,需要修改,則會(huì)調(diào)用Node.textContent = vnode.text
毡惜。if( oldCh && ch && oldCh !== ch )
, 兩個(gè)節(jié)點(diǎn)都有子節(jié)點(diǎn)拓轻,而且它們不一樣,這樣我們會(huì)調(diào)用updateChildren
函數(shù)比較子節(jié)點(diǎn)经伙,這是diff的核心扶叉,后邊會(huì)講到。else if (ch)
帕膜,只有新的節(jié)點(diǎn)有子節(jié)點(diǎn)枣氧,調(diào)用createEle(vnode)
,vnode.el
已經(jīng)引用了老的dom
節(jié)點(diǎn)泳叠,createEle
函數(shù)會(huì)在老dom
節(jié)點(diǎn)上添加子節(jié)點(diǎn)作瞄。else if (oldCh)
,新節(jié)點(diǎn)沒有子節(jié)點(diǎn)危纫,老節(jié)點(diǎn)有子節(jié)點(diǎn)宗挥,直接刪除老節(jié)點(diǎn)。
updateChildren
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx, idxInOld, elmToMove, refElm
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
/*前四種情況其實(shí)是指定key的時(shí)候种蝶,判定為同一個(gè)VNode契耿,則直接patchVnode即可,分別比較oldCh以及newCh的兩頭節(jié)點(diǎn)2*2=4種情況*/
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
/*
生成一個(gè)key與舊VNode的key對應(yīng)的哈希表(只有第一次進(jìn)來undefined的時(shí)候會(huì)生成螃征,也為后面檢測重復(fù)的key值做鋪墊)
比如childre是這樣的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}] beginIdx = 0 endIdx = 2
結(jié)果生成{key0: 0, key1: 1, key2: 2}
*/
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
/*如果newStartVnode新的VNode節(jié)點(diǎn)存在key并且這個(gè)key在oldVnode中能找到則返回這個(gè)節(jié)點(diǎn)的idxInOld(即第幾個(gè)節(jié)點(diǎn)搪桂,下標(biāo))*/
idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null
if (isUndef(idxInOld)) { // New element
/*newStartVnode沒有key或者是該key沒有在老節(jié)點(diǎn)中找到則創(chuàng)建一個(gè)新的節(jié)點(diǎn)*/
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
newStartVnode = newCh[++newStartIdx]
} else {
/*獲取同key的老節(jié)點(diǎn)*/
elmToMove = oldCh[idxInOld]
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !elmToMove) {
/*如果elmToMove不存在說明之前已經(jīng)有新節(jié)點(diǎn)放入過這個(gè)key的DOM中,提示可能存在重復(fù)的key盯滚,確保v-for的時(shí)候item有唯一的key值*/
warn(
'It seems there are duplicate keys that is causing an update error. ' +
'Make sure each v-for item has a unique key.'
)
}
if (sameVnode(elmToMove, newStartVnode)) {
/*Github:https://github.com/answershuto*/
/*如果新VNode與得到的有相同key的節(jié)點(diǎn)是同一個(gè)VNode則進(jìn)行patchVnode*/
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
/*因?yàn)橐呀?jīng)patchVnode進(jìn)去了踢械,所以將這個(gè)老節(jié)點(diǎn)賦值undefined,之后如果還有新節(jié)點(diǎn)與該節(jié)點(diǎn)key相同可以檢測出來提示已有重復(fù)的key*/
oldCh[idxInOld] = undefined
/*當(dāng)有標(biāo)識(shí)位canMove實(shí)可以直接插入oldStartVnode對應(yīng)的真實(shí)DOM節(jié)點(diǎn)前面*/
canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm)
newStartVnode = newCh[++newStartIdx]
} else {
// same key but different element. treat as new element
/*當(dāng)新的VNode與找到的同樣key的VNode不是sameVNode的時(shí)候(比如說tag不一樣或者是有不一樣type的input標(biāo)簽)魄藕,創(chuàng)建一個(gè)新的節(jié)點(diǎn)*/
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
newStartVnode = newCh[++newStartIdx]
}
}
}
}
if (oldStartIdx > oldEndIdx) {
/*全部比較完成以后内列,發(fā)現(xiàn)oldStartIdx > oldEndIdx的話,說明老節(jié)點(diǎn)已經(jīng)遍歷完了背率,新節(jié)點(diǎn)比老節(jié)點(diǎn)多话瞧,所以這時(shí)候多出來的新節(jié)點(diǎn)需要一個(gè)一個(gè)創(chuàng)建出來加入到真實(shí)DOM中*/
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
/*如果全部比較完成以后發(fā)現(xiàn)newStartIdx > newEndIdx嫩与,則說明新節(jié)點(diǎn)已經(jīng)遍歷完了,老節(jié)點(diǎn)多余新節(jié)點(diǎn)交排,這個(gè)時(shí)候需要將多余的老節(jié)點(diǎn)從真實(shí)DOM中移除*/
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
代碼很密集划滋,為了形象的描述這個(gè)過程,可以看看這張圖埃篓。
過程可以概括為:oldCh
和newCh
各有兩個(gè)頭尾的變量StartIdx
和EndIdx
处坪,它們的2個(gè)變量相互比較,一共有4種比較方式都许。如果4種比較都沒匹配稻薇,如果設(shè)置了key
,就會(huì)用key
進(jìn)行比較胶征,在比較的過程中塞椎,變量會(huì)往中間靠,一旦StartIdx>EndIdx
表明oldCh
和newCh
至少有一個(gè)已經(jīng)遍歷完了睛低,就會(huì)結(jié)束比較案狠。
具體的diff分析
設(shè)置key和不設(shè)置key的區(qū)別:
不設(shè)key
,newCh
和oldCh
只會(huì)進(jìn)行頭尾兩端的相互比較钱雷,設(shè)key
后骂铁,除了頭尾兩端的比較外,還會(huì)從用key
生成的對象oldKeyToIdx
中查找匹配的節(jié)點(diǎn)罩抗,所以為節(jié)點(diǎn)設(shè)置key
可以更高效的利用dom
拉庵。
diff
的遍歷過程中,只要是對dom進(jìn)行的操作都調(diào)用api.insertBefore
套蒂,api.insertBefore
只是原生insertBefore
的簡單封裝钞支。
比較分為兩種,一種是有vnode.key
的操刀,一種是沒有的烁挟。但這兩種比較對真實(shí)dom
的操作是一致的。
對于與sameVnode(oldStartVnode, newStartVnode)
和sameVnode(oldEndVnode,newEndVnode)
為true
的情況骨坑,不需要對dom
進(jìn)行移動(dòng)撼嗓。
總結(jié)遍歷過程,有3種dom操作:
- 當(dāng)
oldStartVnode
欢唾,newEndVnode
值得比較且警,說明oldStartVnode.el
跑到oldEndVnode.el的后邊了。
圖中假設(shè)startIdx遍歷到1礁遣。
- 當(dāng)
oldEndVnode
振湾,newStartVnode
值得比較,oldEndVnode.el
跑到了oldStartVnode.el
的前邊亡脸,準(zhǔn)確的說應(yīng)該是oldEndVnode.el
需要移動(dòng)到oldStartVnode.el
的前邊押搪。
-
newCh
中的節(jié)點(diǎn)oldCh
里沒有, 將新節(jié)點(diǎn)插入到oldStartVnode.el
的前邊浅碾。
在結(jié)束時(shí)大州,分為兩種情況:
-
oldStartIdx > oldEndIdx
,可以認(rèn)為oldCh
先遍歷完垂谢。當(dāng)然也有可能newCh
此時(shí)也正好完成了遍歷厦画,統(tǒng)一都?xì)w為此類。此時(shí)newStartIdx
和newEndIdx
之間的vnode
是新增的滥朱,調(diào)用addVnodes
根暑,把他們?nèi)坎暹M(jìn)before
的后邊,before
很多時(shí)候是為null
的徙邻。addVnodes
調(diào)用的是insertBefore
操作dom
節(jié)點(diǎn)排嫌,我們看看insertBefore
的文檔:parentElement.insertBefore(newElement, referenceElement)
如果referenceElement
為null
則newElement
將被插入到子節(jié)點(diǎn)的末尾。如果newElement
已經(jīng)在DOM
樹中缰犁,newElement
首先會(huì)從DOM
樹中移除淳地。所以before為null,newElement將被插入到子節(jié)點(diǎn)的末尾帅容。
-
newStartIdx > newEndIdx
颇象,可以認(rèn)為newCh
先遍歷完。此時(shí)oldStartIdx
和oldEndIdx
之間的vnode
在新的子節(jié)點(diǎn)里已經(jīng)不存在了并徘,調(diào)用removeVnodes
將它們從dom
里刪除遣钳。
下面舉個(gè)例子,畫出diff完整的過程麦乞,每一步dom的變化都用不同顏色的線標(biāo)出蕴茴。
-
a,b,c,d,e假設(shè)是4個(gè)不同的元素,我們沒有設(shè)置key時(shí)路幸,b沒有復(fù)用荐开,而是直接創(chuàng)建新的,刪除舊的简肴。
當(dāng)我們給4個(gè)元素加上唯一key時(shí)晃听,b得到了的復(fù)用。
這個(gè)例子如果我們使用手工優(yōu)化砰识,只需要3步就可以達(dá)到能扒。
總結(jié)
盡量不要跨層級的修改dom
設(shè)置key可以最大化的利用節(jié)點(diǎn)
不要盲目相信diff的效率,在必要時(shí)可以手工優(yōu)化