你們好,我是林三心,在平常面試中,Diff算法
都是繞不過去的一道坎,用最通俗的話,講最難的知識點一直是我寫文章的宗旨,今天我就用通俗的方式來說解一下Diff算法
吧?Lets Gohtml
講Diff算法
前,我先給你們講一講什麼是虛擬DOM
吧。這有利於後面你們對Diff算法
的理解加深。node
虛擬DOM
是一個對象
,一個什麼樣的對象呢?一個用來表示真實DOM的對象,要記住這句話。我舉個例子,請看如下真實DOM
:面試
<ul id="list">
<li class="item">哈哈</li>
<li class="item">呵呵</li>
<li class="item">嘿嘿</li>
</ul>
複製代碼
對應的虛擬DOM
爲:算法
let oldVDOM = { // 舊虛擬DOM
tagName: 'ul', // 標籤名
props: { // 標籤屬性
id: 'list'
},
children: [ // 標籤子節點
{
tagName: 'li', props: { class: 'item' }, children: ['哈哈']
},
{
tagName: 'li', props: { class: 'item' }, children: ['呵呵']
},
{
tagName: 'li', props: { class: 'item' }, children: ['嘿嘿']
},
]
}
複製代碼
這時候,我修改一個li標籤
的文本:api
<ul id="list">
<li class="item">哈哈</li>
<li class="item">呵呵</li>
<li class="item">林三心哈哈哈哈哈</li> // 修改
</ul>
複製代碼
這時候生成的新虛擬DOM
爲:數組
let newVDOM = { // 新虛擬DOM
tagName: 'ul', // 標籤名
props: { // 標籤屬性
id: 'list'
},
children: [ // 標籤子節點
{
tagName: 'li', props: { class: 'item' }, children: ['哈哈']
},
{
tagName: 'li', props: { class: 'item' }, children: ['呵呵']
},
{
tagName: 'li', props: { class: 'item' }, children: ['林三心哈哈哈哈哈']
},
]
}
複製代碼
這就是我們日常說的新舊兩個虛擬DOM
,這個時候的新虛擬DOM
是數據的最新狀態,那麼咱們直接拿新虛擬DOM
去渲染成真實DOM
的話,效率真的會比直接操做真實DOM高嗎?那確定是不會的,看下圖:markdown
由上圖,一看便知,確定是第2種方式比較快,由於第1種方式中間還夾着一個虛擬DOM
的步驟,因此虛擬DOM比真實DOM快這句話實際上是錯的,或者說是不嚴謹的。那正確的說法是什麼呢?虛擬DOM算法操做真實DOM,性能高於直接操做真實DOM,虛擬DOM
和虛擬DOM算法
是兩種概念。虛擬DOM算法 = 虛擬DOM + Diff算法
app
上面我們說了虛擬DOM
,也知道了只有虛擬DOM + Diff算法
才能真正的提升性能,那講完虛擬DOM
,咱們再來說講Diff算法
吧,仍是上面的例子(這張圖被壓縮的有點小,你們能夠打開看,比較清晰):ide
上圖中,其實只有一個li標籤修改了文本,其餘都是不變的,因此不必全部的節點都要更新,只更新這個li標籤就行,Diff算法就是查出這個li標籤的算法。函數
總結:Diff算法是一種對比算法。對比二者是舊虛擬DOM和新虛擬DOM
,對比出是哪一個虛擬節點
更改了,找出這個虛擬節點
,並只更新這個虛擬節點所對應的真實節點
,而不用更新其餘數據沒發生改變的節點,實現精準
地更新真實DOM,進而提升效率
。
使用虛擬DOM算法的損耗計算
: 總損耗 = 虛擬DOM增刪改+(與Diff算法效率有關)真實DOM差別增刪改+(較少的節點)排版與重繪
直接操做真實DOM的損耗計算
: 總損耗 = 真實DOM徹底增刪改+(可能較多的節點)排版與重繪
新舊虛擬DOM對比的時候,Diff算法比較只會在同層級進行, 不會跨層級比較。 因此Diff算法是:廣度優先算法
。 時間複雜度:O(n)
當數據改變時,會觸發setter
,而且經過Dep.notify
去通知全部訂閱者Watcher
,訂閱者們就會調用patch方法
,給真實DOM打補丁,更新相應的視圖。對於這一步不太瞭解的能夠看一下我以前寫Vue源碼系列
newVnode和oldVnode
:同層的新舊虛擬節點
這個方法做用就是,對比當前同層的虛擬節點是否爲同一種類型的標籤(同一類型的標準,下面會講)
:
patchVnode方法
進行深層比對新虛擬節點
來看看patch
的核心原理代碼
function patch(oldVnode, newVnode) {
// 比較是否爲一個類型的節點
if (sameVnode(oldVnode, newVnode)) {
// 是:繼續進行深層比較
patchVnode(oldVnode, newVnode)
} else {
// 否
const oldEl = oldVnode.el // 舊虛擬節點的真實DOM節點
const parentEle = api.parentNode(oldEl) // 獲取父節點
createEle(newVnode) // 建立新虛擬節點對應的真實DOM節點
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 將新元素添加進父元素
api.removeChild(parentEle, oldVnode.el) // 移除之前的舊元素節點
// 設置null,釋放內存
oldVnode = null
}
}
return newVnode
}
複製代碼
patch關鍵的一步就是sameVnode方法判斷是否爲同一類型節點
,那問題來了,怎麼纔算是同一類型節點呢?這個類型
的標準是什麼呢?
我們來看看sameVnode方法的核心原理代碼,就一目瞭然了
function sameVnode(oldVnode, newVnode) {
return (
oldVnode.key === newVnode.key && // key值是否同樣
oldVnode.tagName === newVnode.tagName && // 標籤名是否同樣
oldVnode.isComment === newVnode.isComment && // 是否都爲註釋節點
isDef(oldVnode.data) === isDef(newVnode.data) && // 是否都定義了data
sameInputType(oldVnode, newVnode) // 當標籤爲input時,type必須是否相同
)
}
複製代碼
這個函數作了如下事情:
真實DOM
,稱爲el
newVnode
和oldVnode
是否指向同一個對象,若是是,那麼直接return
el
的文本節點設置爲newVnode
的文本節點。oldVnode
有子節點而newVnode
沒有,則刪除el
的子節點oldVnode
沒有子節點而newVnode
有,則將newVnode
的子節點真實化以後添加到el
updateChildren
函數比較子節點,這一步很重要function patchVnode(oldVnode, newVnode) {
const el = newVnode.el = oldVnode.el // 獲取真實DOM對象
// 獲取新舊虛擬節點的子節點數組
const oldCh = oldVnode.children, newCh = newVnode.children
// 若是新舊虛擬節點是同一個對象,則終止
if (oldVnode === newVnode) return
// 若是新舊虛擬節點是文本節點,且文本不同
if (oldVnode.text !== null && newVnode.text !== null && oldVnode.text !== newVnode.text) {
// 則直接將真實DOM中文本更新爲新虛擬節點的文本
api.setTextContent(el, newVnode.text)
} else {
// 不然
if (oldCh && newCh && oldCh !== newCh) {
// 新舊虛擬節點都有子節點,且子節點不同
// 對比子節點,並更新
updateChildren(el, oldCh, newCh)
} else if (newCh) {
// 新虛擬節點有子節點,舊虛擬節點沒有
// 建立新虛擬節點的子節點,並更新到真實DOM上去
createEle(newVnode)
} else if (oldCh) {
// 舊虛擬節點有子節點,新虛擬節點沒有
//直接刪除真實DOM裏對應的子節點
api.removeChild(el)
}
}
}
複製代碼
其餘幾個點都很好理解,咱們詳細來說一下updateChildren
這是patchVnode
裏最重要的一個方法,新舊虛擬節點的子節點對比,就是發生在updateChildren方法
中,接下來就結合一些圖來說,讓你們更好理解吧
是怎麼樣一個對比方法呢?就是首尾指針法
,新的子節點集合和舊的子節點集合,各有首尾兩個指針,舉個例子:
<ul>
<li>a</li>
<li>b</li>
<li>c</li>
</ul>
修改數據後
<ul>
<li>b</li>
<li>c</li>
<li>e</li>
<li>a</li>
</ul>
複製代碼
那麼新舊兩個子節點集合以及其首尾指針爲:
而後會進行互相進行比較,總共有五種比較狀況:
oldS 和 newS
使用sameVnode方法
進行比較,sameVnode(oldS, newS)
oldS 和 newE
使用sameVnode方法
進行比較,sameVnode(oldS, newE)
oldE 和 newS
使用sameVnode方法
進行比較,sameVnode(oldE, newS)
oldE 和 newE
使用sameVnode方法
進行比較,sameVnode(oldE, newE)
key
作一個映射到舊節點下標的 key -> index
表,而後用新 vnode
的 key
去找出在舊節點中能夠複用的位置。接下來就以上面代碼爲例,分析一下比較的過程
分析以前,請你們記住一點,最終的渲染結果都要以newVDOM爲準,這也解釋了爲何以後的節點移動須要移動到newVDOM所對應的位置
oldS = a, oldE = c
newS = b, newE = a
複製代碼
比較結果:oldS 和 newE
相等,須要把節點a
移動到newE
所對應的位置,也就是末尾,同時oldS++
,newE--
oldS = b, oldE = c
newS = b, newE = e
複製代碼
比較結果:oldS 和 newS
相等,須要把節點b
移動到newS
所對應的位置,同時oldS++
,newS++
oldS = c, oldE = c
newS = c, newE = e
複製代碼
比較結果:oldS、oldE 和 newS
相等,須要把節點c
移動到newS
所對應的位置,同時oldS++
,oldE--
,newS++
oldS > oldE
,則oldCh
先遍歷完成了,而newCh
還沒遍歷完,說明newCh比oldCh多
,因此須要將多出來的節點,插入到真實DOM上對應的位置上
我在這裏給你們留一個思考題哈。上面的例子是newCh比oldCh多
,假如相反,是oldCh比newCh多
的話,那就是newCh
先走完循環,而後oldCh
會有多出的節點,結果會在真實DOM裏進行刪除這些舊節點。你們能夠本身思考一下,模擬一下這個過程,像我同樣,畫圖模擬,才能鞏固上面的知識。
附上updateChildren
的核心原理代碼
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0, 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
let idxInOld
let elmToMove
let before
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 使用key時的比較
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
}
idxInOld = oldKeyToIdx[newStartVnode.key]
if (!idxInOld) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
newStartVnode = newCh[++newStartIdx]
}
else {
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
} else {
patchVnode(elmToMove, newStartVnode)
oldCh[idxInOld] = null
api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
}
newStartVnode = newCh[++newStartIdx]
}
}
}
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
複製代碼
日常v-for循環渲染的時候,爲何不建議用index做爲循環項的key呢?
咱們舉個例子,左邊是初始數據,而後我在數據前插入一個新數據,變成右邊的列表
<ul> <ul> <li key="0">a</li> <li key="0">林三心</li> <li key="1">b</li> <li key="1">a</li> <li key="2">c</li> <li key="2">b</li> <li key="3">c</li> </ul> </ul>
複製代碼
按理說,最理想的結果是:只插入一個li標籤新節點,其餘都不動,確保操做DOM效率最高。可是咱們這裏用了index來當key的話,真的會實現咱們的理想結果嗎?廢話很少說,實踐一下:
<ul>
<li v-for="(item, index) in list" :key="index">{{ item.title }}</li>
</ul>
<button @click="add">增長</button>
list: [
{ title: "a", id: "100" },
{ title: "b", id: "101" },
{ title: "c", id: "102" },
]
add() {
this.list.unshift({ title: "林三心", id: "99" });
}
複製代碼
點擊按鈕咱們能夠看到,並非咱們預想的結果,而是全部li標籤都更新了
爲何會這樣呢?仍是經過圖來解釋
按理說,a,b,c
三個li標籤都是複用以前的,由於他們三個根本沒改變,改變的只是前面新增了一個林三心
可是咱們前面說了,在進行子節點的 diff算法
過程當中,會進行 舊首節點和新首節點的sameNode
對比,這一步命中了邏輯,由於如今新舊兩次首部節點
的 key
都是 0
了,同理,key爲1和2的也是命中了邏輯,致使相同key的節點
會去進行patchVnode
更新文本,而本來就有的c節點
,卻由於以前沒有key爲4的節點,而被當作了新節點,因此很搞笑,使用index作key,最後新增的竟然是原本就已有的c節點。因此前三個都進行patchVnode
更新文本,最後一個進行了新增
,那就解釋了爲何全部li標籤都更新了。
那咱們能夠怎麼解決呢?其實咱們只要使用一個獨一無二的值來當作key就好了
<ul>
<li v-for="item in list" :key="item.id">{{ item.title }}</li>
</ul>
複製代碼
如今再來看看效果
爲何用了id來當作key就實現了咱們的理想效果呢,由於這麼作的話,a,b,c節點
的key
就會是永遠不變的,更新先後key都是同樣的,而且又因爲a,b,c節點
的內容原本就沒變,因此就算是進行了patchVnode
,也不會執行裏面複雜的更新操做,節省了性能,而林三心節點,因爲更新前沒有他的key所對應的節點,因此他被當作新的節點,增長到真實DOM上去了。
但願能幫到那些一直想了解虛擬DOM和Diff算法的同窗
若是你以爲本文有幫到你一點點的話,請點個讚唄哈哈
歡迎各位同窗指出個人錯誤,我會及時更改滴
學習羣請點這裏,一塊兒學習,一塊兒摸魚!!!