上一篇:Vue原理解析(七):全面深刻理解響應式原理(下)-數組進階篇html
以前章節介紹了VNode
如何生成真實Dom
,這只是patch
內首次渲染作的事,完成了一小部分功能而已,而它作的最重要的事情是當響應式觸發時,讓頁面的從新渲染這一過程能高效完成。其實頁面的從新渲染徹底可使用新生成的Dom
去整個替換掉舊的Dom
,然而這麼作比較低效,因此就藉助接下來將介紹的diff
比較算法來完成。vue
diff
算法作的事情是比較VNode
和oldVNode
,再以VNode
爲標準的狀況下在oldVNode
上作小的改動,完成VNode
對應的Dom
渲染。node
回到以前_update
方法的實現,這個時候就會走到else
的邏輯了:面試
Vue.prototype._update = function(vnode) {
const vm = this
const prevVnode = vm._vnode
vm._vnode = vnode // 緩存爲以前vnode
if(!prevVnode) { // 首次渲染
vm.$el = vm.__patch__(vm.$el, vnode)
} else { // 從新渲染
vm.$el = vm.__patch__(prevVnode, vnode)
}
}
複製代碼
既然是在現有的VNode
上修修補補來達到從新渲染的目的,因此無非是作三件事情:算法
建立新增節點數組
刪除廢棄節點緩存
更新已有節點bash
接下來咱們將介紹以上三種狀況分別什麼狀況下會遇到。dom
新增節點兩種狀況下會遇到:異步
VNode
中有的節點而oldVNode
沒有
VNode
中有的節點而oldVNode
中沒有,最明顯的場景就是首次渲染了,這個時候是沒有oldVNode
的,因此將整個VNode
渲染爲真實Dom
插入到根節點以內便可,這一詳細過程以前章節有詳細說明。
VNode
和oldVNode
徹底不一樣
VNode
和oldVNode
不是同一個節點時,直接會將VNode
建立爲真實Dom
,插入到舊節點的後面,這個時候舊節點就變成了廢棄節點,移除以完成替換過程。判斷兩個節點是否爲同一個節點,內部是這樣定義的:
function sameVnode (a, b) { // 是不是相同的VNode節點
return (
a.key === b.key && ( // 如平時v-for內寫的key
(
a.tag === b.tag && // tag相同
a.isComment === b.isComment && // 註釋節點
isDef(a.data) === isDef(b.data) && // 都有data屬性
sameInputType(a, b) // 相同的input類型
) || (
isTrue(a.isAsyncPlaceholder) && // 是異步佔位符節點
a.asyncFactory === b.asyncFactory && // 異步工廠方法
isUndef(b.asyncFactory.error)
)
)
)
}
複製代碼
上面建立新增節點的第二種狀況以略有說起,比較vnode
和oldVnode
,若是根節點不相同就將Vnode
整顆渲染爲真實Dom
,插入到舊節點的後面,最後刪除掉已經廢棄的舊節點便可:
patch
方法內將建立好的
Dom
插入到廢棄節點後面以後:
if (isDef(parentElm)) { // 在它們的父節點內刪除舊節點
removeVnodes(parentElm, [oldVnode], 0, 0)
}
-------------------------------------------------------------
function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
removeNode(ch.elm)
}
}
} // 移除從startIdx到endIdx之間的內容
------------------------------------------------------------
function removeNode(el) { // 單個節點移除
const parent = nodeOps.parentNode(el)
if(isDef(parent)) {
nodeOps.removeChild(parent, el)
}
}
複製代碼
這個纔是diff
算法的重點,當兩個節點是相同的節點時,這個時候就須要找出它們的不一樣之處,比較它們主要是使用patchVnode
方法,這個方法裏面主要也是處理幾種分支狀況:
都是靜態節點
function patchVnode(oldVnode, vnode) {
if (oldVnode === vnode) { // 徹底同樣
return
}
const elm = vnode.elm = oldVnode.elm
if(isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic)) {
vnode.componentInstance = oldVnode.componentInstance
return // 都是靜態節點,跳過
}
...
}
複製代碼
什麼是靜態節點了?這是編譯階段作的事情,它會找出模板中的靜態節點並作上標記(isStatic
爲true
),例如:
<template>
<div>
<h2>{{title}}</h2>
<p>新鮮食材</p>
</div>
</template>
複製代碼
這裏的h2
標籤就不是靜態節點,由於是根據插值變化的,而p
標籤就是靜態節點,由於不會改變。若是都是靜態節點就跳過此次比較,這也是編譯階段爲diff
比對作的優化。
vnode
節點沒有文本屬性
function patchVnode(oldVnode, vnode) {
const elm = vnode.elm = oldVnode.elm
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) { // vnode沒有text屬性
if (isDef(oldCh) && isDef(ch)) { // // 都有children
if (oldCh !== ch) { // 且children不一樣
updateChildren(elm, oldCh, ch) // 更新子節點
}
}
else if (isDef(ch)) { // 只有vnode有children
if (isDef(oldVnode.text)) { // oldVnode有文本節點
nodeOps.setTextContent(elm, '') // 設置oldVnode文本爲空
}
addVnodes(elm, null, ch, 0, ch.length - 1)
// 往oldVnode空的標籤內插入vnode的children的真實dom
}
else if (isDef(oldCh)) { // 只有oldVnode有children
removeVnodes(elm, oldCh, 0, oldCh.length - 1) // 所有移除
}
else if (isDef(oldVnode.text)) { // oldVnode有文本節點
nodeOps.setTextContent(elm, '') // 設置爲空
}
}
else { vnode有text屬性
...
}
...
複製代碼
若是vnode
沒有文本節點,又會有接下來的四個分支:
1. 都有children
且不相同
updateChildren
方法更詳細的比對它們的children
,若是說更新已有節點是patch
的核心,那這裏的更新children
就是核心中的核心,這個以後使用流程圖的方式仔仔細細說明。2. 只有vnode
有children
oldVnode
要麼是一個空標籤或者是文本節點,若是是文本節點就清空文本節點,而後將vnode
的children
建立爲真實Dom
後插入到空標籤內。3. 只有oldVnode
有children
vnode
爲標準的,因此vnode
沒有的東西,oldVnode
內就是廢棄節點,須要刪除掉。4. 只有oldVnode
有文本
oldVnode
有而vnode
沒有的,清空或移除便可。
vnode
節點有文本屬性
function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
const elm = vnode.elm = oldVnode.elm
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) { // vnode沒有text屬性
...
} else if(oldVnode.text !== vnode.text) { // vnode有text屬性且不一樣
nodeOps.setTextContent(elm, vnode.text) // 設置文本
}
...
複製代碼
仍是那句話,以vnode
爲標準,因此vnode
有文本節點的話,不管oldVnode
是什麼類型節點,直接設置爲vnode
內的文本便可。至此,整個diff
比對的大體過程就算是說明完畢了,咱們仍是以一張流程圖來理清思路:
更新子節點示例:
<template>
<ul>
<li v-for='item in list' :key='item.id'>{{item.name}}</li>
</ul>
</template>
export default {
data() {
return {
list: [{
id: 'a1',name: 'A'}, {
id: 'b2',name: 'B'}, {
id: 'c3',name: 'C'}, {
id: 'd4',name: 'D'}
]
}
},
mounted() {
setTimeout(() => {
this.list.sort(() => Math.random() - .5)
.unshift({id: 'e5', name: 'E'})
}, 1000)
}
}
複製代碼
上述代碼中首先渲染一個列表,而後將其隨機打亂順序後並添加一項到列表最前面,這個時候就會觸發該組件更新子節點的邏輯,以前也會有一些其餘的邏輯,這裏只用關注更新子節點相關,來看下它怎麼更新Dom
的:
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0 // 舊第一個下標
let oldStartVnode = oldCh[0] // 舊第一個節點
let oldEndIdx = oldCh.length - 1 // 舊最後下標
let oldEndVnode = oldCh[oldEndIdx] // 舊最後節點
let newStartIdx = 0 // 新第一個下標
let newStartVnode = newCh[0] // 新第一個節點
let newEndIdx = newCh.length - 1 // 新最後下標
let newEndVnode = newCh[newEndIdx] // 新最後節點
let oldKeyToIdx // 舊節點key和下標的對象集合
let idxInOld // 新節點key在舊節點key集合裏的下標
let vnodeToMove // idxInOld對應的舊節點
let refElm // 參考節點
checkDuplicateKeys(newCh) // 檢測newVnode的key是否有重複
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 開始遍歷children
if (isUndef(oldStartVnode)) { // 跳過因位移留下的undefined
oldStartVnode = oldCh[++oldStartIdx]
} else if (isUndef(oldEndVnode)) { // 跳過因位移留下的undefine
oldEndVnode = oldCh[--oldEndIdx]
}
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) // 遞歸調用
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
// 將舊第一節點右移到最後,視圖馬上呈現
oldStartVnode = oldCh[++oldStartIdx] // 舊開始節點被處理,舊開始節點爲第二個
newEndVnode = newCh[--newEndIdx] // 新最後節點被處理,新最後節點爲倒數第二個
}
else if (sameVnode(oldEndVnode, newStartVnode)) { // 比對舊最後和新第一節點
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) // 遞歸調用
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
// 將舊最後節點左移到最前面,視圖馬上呈現
oldEndVnode = oldCh[--oldEndIdx] // 舊最後節點被處理,舊最後節點爲倒數第二個
newStartVnode = newCh[++newStartIdx] // 新第一節點被處理,新第一節點爲第二個
}
else { // 不包括以上四種快捷比對方式
if (isUndef(oldKeyToIdx)) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 獲取舊開始到結束節點的key和下表集合
}
idxInOld = isDef(newStartVnode.key) // 獲取新節點key在舊節點key集合裏的下標
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // 找不到對應的下標,表示新節點是新增的,須要建立新dom
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
)
}
else { // 能找到對應的下標,表示是已有的節點,移動位置便可
vnodeToMove = oldCh[idxInOld] // 獲取對應已有的舊節點
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
}
newStartVnode = newCh[++newStartIdx] // 新開始下標和節點更新爲第二個節點
}
}
...
}
複製代碼
函數內首先會定義一堆let
定義的變量,這些變量是隨着while
循環體而改變當前值的,循環的退出條件爲只要新舊節點列表有一個處理完就退出,看着循環體代碼挺複雜,其實它只是作了三件事,明白了哪三件事再看循環體,會發現其實並不複雜:
1. 跳過undefined
爲何會有undefined
,以後的流程圖會說明清楚。這裏只要記住,若是舊開始節點爲undefined
,就後移一位;若是舊結束節點爲undefined
,就前移一位。
2. 快捷查找
首先會嘗試四種快速查找的方式,若是不匹配,再作進一步處理:
若是匹配,表示它們位置都是對的,Dom
不用改,就將新舊節點開始的下標日後移一位便可。
若是匹配,也表示它們位置是對的,Dom
不用改,就將新舊節點結束的下標前移一位便可。
若是匹配,位置不對須要更新Dom
視圖,將舊開始節點對應的真實Dom
插入到最後一位,舊開始節點下標後移一位,新結束節點下標前移一位。
若是匹配,位置不對須要更新Dom
視圖,將舊結束節點對應的真實Dom
插入到舊開始節點對應真實Dom
的前面,舊結束節點下標前移一位,新開始節點下標後移一位。
3. key值查找
那就說明是已有的節點,只是位置不對,那就移動節點位置便可。
再已有的key
值集合內找不到,那就說明是新的節點,那就建立一個對應的真實Dom
節點,插入到舊開始節點對應的真實Dom
前面便可。
這麼說並不太好理解,結合以前的示例,根據如下的流程圖將會明白不少:
start
和
end
標記。
key
值列表查找,並無找到說明
E
是新增的節點,建立對應的真實
Dom
,插入到舊節點裏
start
對應真實
Dom
的前面,也就是
A
的前面,已經處理完了一個,新
start
位置後移一位。
key
值列表查找。發現是已有的節點,只是位置不對,那麼進行插入操做,參考節點仍是
A
節點,將原來舊節點
C
設置爲
undefined
,這裏以後會跳過它。又處理完了一個節點,新
start
後移一位。
Dom
位置是對的,新
start
和舊
start
都後移一位。
Dom
位置是不對的,插入節點到最後位置,最後將新
end
前移一位,舊
start
後移一位。
undefined
的邏輯,而後再開始快捷比對,匹配到的是新開始節點和舊開始節點,它們各自
start
後移一位,這個時候就會跳出循環了。接着看下最後的收尾代碼:
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0
...
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
...
}
if (oldStartIdx > oldEndIdx) { // 若是舊節點列表先處理完,處理剩餘新節點
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) // 添加
}
else if (newStartIdx > newEndIdx) { // 若是新節點列表先處理完,處理剩餘舊節點
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) // 刪除廢棄節點
}
}
複製代碼
咱們以前的示例恰好是新舊節點列表同時處理完退出的循環,這裏是退出循環後爲還有沒有處理完的節點,作不一樣的處理:
Dom
並插入到視圖便可。這就是整個
diff
算法過程了,你們能夠對比以前的遞歸流程圖再看一遍,相信思路會清晰不少。
最後按照慣例咱們仍是以一道vue
可能會被問到的面試題做爲本章的結束~
面試官微笑而又不失禮貌的問道:
v-for
裏建議爲每一項綁定key
,並且最好具備惟一性,而不建議使用index
?懟回去:
diff
比對內部作更新子節點時,會根據oldVnode
內沒有處理的節點獲得一個key
值和下標對應的對象集合,爲的就是當處理vnode
每個節點時,能快速查找該節點是不是已有的節點,從而提升整個diff
比對的性能。若是是一個動態列表,key
值最好能保持惟一性,但像輪播圖那種不會變動的列表,使用index
也是沒問題的。順手點個贊或關注唄,找起來也方便~