node.js極速入門課程:進入學習
虛擬dom
和diff
演演算法是vue
學習過程中的一個難點,也是面試中必須掌握的一個知識點。這兩者相輔相成,是vue
框架的核心。今天我們再來總結下vue2
中的虛擬dom
和 diff
演演算法。(學習視訊分享:)
我們知道,render function
會被轉化成 VNode
。VNode
其實就是一棵以 JavaScript
物件作為基礎的樹,用物件屬性來描述節點,實際上它只是一層對真實 DOM
的抽象。最終可以通過一系列操作使這棵樹對映到真實環境上。
比如有如下template
<template>
<span class="demo" v-show="isShow"> This is a span. </span>
</template>
登入後複製
它換成 VNode
以後大概就是下面這個樣子
{
tag: "span",
data: {
/* 指令集合陣列 */
directives: [
{
/* v-show指令 */
rawName: "v-show",
expression: "isShow",
name: "show",
value: true,
},
],
/* 靜態class */
staticClass: "demo",
},
text: undefined,
children: [
/* 子節點是一個文字VNode節點 */
{
tag: undefined,
data: undefined,
text: "This is a span.",
children: undefined,
},
],
};
登入後複製
總的來說,VNode
就是一個 JavaScript
物件。這個JavaScript
物件能完整地表示出真實DOM
。
筆者認為有兩點原因
由於 Virtual DOM
是以 JavaScript
物件為基礎而不依賴真實平臺環境,所以使它具有了跨平臺的能力,比如說瀏覽器平臺、Weex、Node 等。
減少操作DOM
,任何頁面的變化,都只使用VNode
進行操作對比,只需要在最後一次進行掛載更新DOM
,避免了頻繁操作DOM
,減少了瀏覽器的迴流和重繪從而提高頁面效能。
下面我們來看看元件更新所涉及到的diff演演算法
。
前面我們講依賴收集的時候有說到,渲染watcher
傳遞給Watcher
的get
方法其實是updateComponent
方法。
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
登入後複製
所以元件在響應式資料發生變化的時候會再次觸發該方法,接下來我們來詳細分析一下updateComponent
裡面的_update
方法。
_update
在_update
方法中做了初始渲染和更新的區分,雖然都是呼叫__patch__
方法,但是傳遞的引數不一樣。
// src/core/instance/lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
vm._vnode = vnode
// 初次渲染沒有 prevVnode,元件更新才會有
if (!prevVnode) {
// 初次渲染
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// 更新
vm.$el = vm.__patch__(prevVnode, vnode)
}
// ...
}
登入後複製
下面我們再來看看__patch__
方法
__patch__
patch
方法接收四個引數,由於初始渲染的時候oldVnode
為vm.$el
是null
,所以初始渲染是沒有oldVnode
。
// src/core/vdom/patch.js
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// 新節點不存在,只有oldVnode就直接銷燬,然後返回
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue = []
// 沒有老節點,直接建立,也就是初始渲染
if (isUndef(oldVnode)) {
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
const isRealElement = isDef(oldVnode.nodeType)
// 不是真實dom,並且是相同節點走patch
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 這裡才會涉及到diff演演算法
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
if (isRealElement) {
// ...
}
// replacing existing element
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// 1.建立一個新節點
createElm(
vnode,
insertedVnodeQueue,
// extremely rare edge case: do not insert if old element is in a
// leaving transition. Only happens when combining transition +
// keep-alive + HOCs. (#4590)
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
// 2.更新父節點預留位置
if (isDef(vnode.parent)) {
let ancestor = vnode.parent
const patchable = isPatchable(vnode)
while (ancestor) {
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor)
}
ancestor.elm = vnode.elm
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor)
}
const insert = ancestor.data.hook.insert
if (insert.merged) {
// start at index 1 to avoid re-invoking component mounted hook
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]()
}
}
} else {
registerRef(ancestor)
}
ancestor = ancestor.parent
}
}
// 3.刪除老節點
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
//觸發插入勾點
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
登入後複製
patch
方法大概流程如下:
沒有新節點只有老節點直接刪除老節點。
只有新節點沒有老節點直接新增新節點。
既有新節點又有老節點則判斷是不是相同節點,相同則進入pathVnode
。patchVnode
我們後面會重點分析。
既有新節點又有老節點則判斷是不是相同節點,不相同則直接刪除老節點新增新節點。
我們再來看看它是怎麼判斷是同一個節點的。
// src/core/vdom/patch.js
function sameVnode (a, b) {
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
function sameInputType (a, b) {
if (a.tag !== 'input') return true
let i
const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)
}
登入後複製
判斷兩個VNode
節點是否是同一個節點,需要同時滿足以下條件
key
相同
都有非同步元件工廠函數
tag
(當前節點的標籤名)相同
isComment
是否同為註釋節點
是否data
(當前節點對應的物件,包含了具體的一些資料資訊,是一個VNodeData型別)
當標籤是<input>
的時候,type
必須相同
當兩個VNode
的tag、key、isComment
都相同,並且同時定義或未定義data
的時候,且如果標籤為input則type
必須相同。這時候這兩個VNode
則算sameVnode
,可以直接進行patchVnode
操作。
下面我們再來詳細分析下patchVnode
方法。
// src/core/vdom/patch.js
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
// 兩個vnode相同則直接返回
if (oldVnode === vnode) {
return
}
if (isDef(vnode.elm) && isDef(ownerArray)) {
// clone reused vnode
vnode = ownerArray[index] = cloneVNode(vnode)
}
const elm = vnode.elm = oldVnode.elm
if (isTrue(oldVnode.isAsyncPlaceholder)) {
if (isDef(vnode.asyncFactory.resolved)) {
hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
} else {
vnode.isAsyncPlaceholder = true
}
return
}
/*
如果新舊VNode都是靜態的,同時它們的key相同(代表同一節點),
並且新的VNode是clone或者是標記了once(標記v-once屬性,只渲染一次),
那麼只需要替換componentInstance即可。
*/
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
let i
const data = vnode.data
/*呼叫prepatch勾點*/
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
// 獲取新老虛擬節點的子節點
const oldCh = oldVnode.children
const ch = vnode.children
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
// 新節點不是文位元組點
if (isUndef(vnode.text)) {
/*新老節點均有children子節點,則對子節點進行diff操作,呼叫updateChildren*/
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
/*如果只有新節點有子節點,先清空elm文字內容,然後為當前DOM節點加入子節點。*/
} else if (isDef(ch)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
/*如果只有老節點有子節點,則移除elm所有子節點*/
} else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1)
/*當新老節點都無子節點的時候,因為這個邏輯中新節點text不存在,所以直接去除ele的文字*/
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
// 新節點是文位元組點,如果文字不一樣就設定新的文字
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
/*呼叫postpatch勾點*/
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}
登入後複製
patchVnode
方法大概流程如下:
1.新老節點相同就直接返回。
2.如果新舊VNode都是靜態的,同時它們的key相同(代表同一節點),並且新的VNode是clone或者是標記了once(標記v-once屬性,只渲染一次),那麼只需要替換componentInstance即可。
3.新節點不是文位元組點,新老節點均有children
子節點,則對子節點進行diff
操作,呼叫updateChildren
,這個updateChildren
是diff演演算法
的核心,後面我們會重點說。
4.新節點不是文位元組點,如果老節點沒有子節點而新節點存在子節點,先清空老節點DOM的文字內容,然後為當前DOM節點加入子節點。
5.新節點不是文位元組點,當新節點沒有子節點而老節點有子節點的時候,則移除該DOM節點的所有子節點。
6.新節點不是文位元組點,並且新老節點都無子節點的時候,只需要將老節點文字清空。
7.新節點是文位元組點,並且新老節點文字不一樣,則進行文字的替換。
updateChildren
是diff
演演算法的核心,下面我們來重點分析。
這兩張圖代表舊的VNode與新VNode進行patch的過程,他們只是在同層級的VNode之間進行比較得到變化(相同顏色的方塊代表互相進行比較的VNode節點),然後修改變化的檢視,所以十分高效。所以Diff演演算法是:深度優先演演算法
。 時間複雜度:O(n)
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, vnodeToMove, refElm
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
// 老 VNode 節點的頭部與新 VNode 節點的頭部是相同的 VNode 節點,直接進行 patchVnode,同時 oldStartIdx 與 newStartIdx 向後移動一位。
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
// 兩個 VNode 的結尾是相同的 VNode,同樣進行 patchVnode 操作。並將 oldEndVnode 與 newEndVnode 向前移動一位。
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
// oldStartVnode 與 newEndVnode 符合 sameVnode 的時候,
// 將 oldStartVnode.elm 這個節點直接移動到 oldEndVnode.elm 這個節點的後面即可。
// 然後 oldStartIdx 向後移動一位,newEndIdx 向前移動一位。
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
// oldEndVnode 與 newStartVnode 符合 sameVnode 時,
// 將 oldEndVnode.elm 插入到 oldStartVnode.elm 前面。
// oldEndIdx 向前移動一位,newStartIdx 向後移動一位。
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// createKeyToOldIdx 的作用是產生 key 與 index 索引對應的一個 map 表
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// 如果沒有找到相同的節點,則通過 createElm 建立一個新節點,並將 newStartIdx 向後移動一位。
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
// 如果找到了節點,同時它符合 sameVnode,則將這兩個節點進行 patchVnode,將該位置的老節點賦值 undefined
// 同時將 newStartVnode.elm 插入到 oldStartVnode.elm 的前面
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// 如果不符合 sameVnode,只能建立一個新節點插入到 parentElm 的子節點中,newStartIdx 往後移動一位。
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
// 當 while 迴圈結束以後,如果 oldStartIdx > oldEndIdx,說明老節點比對完了,但是新節點還有多的,
// 需要將新節點插入到真實 DOM 中去,呼叫 addVnodes 將這些節點插入即可。
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
// 如果滿足 newStartIdx > newEndIdx 條件,說明新節點比對完了,老節點還有多,
// 將這些無用的老節點通過 removeVnodes 批次刪除即可。
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}
登入後複製
vue2
的diff
演演算法採用的是雙端比較
,所謂雙端比較
就是新列表和舊列表兩個列表的頭與尾互相對比,在對比的過程中指標會逐漸向內靠攏,直到某一個列表的節點全部遍歷過,對比停止。
我們首先來看看首尾對比的四種情況。
使用舊列表的頭一個節點oldStartNode
與新列表的頭一個節點newStartNode
對比
使用舊列表的最後一個節點oldEndNode
與新列表的最後一個節點newEndNode
對比
使用舊列表的頭一個節點oldStartNode
與新列表的最後一個節點newEndNode
對比
使用舊列表的最後一個節點oldEndNode
與新列表的頭一個節點newStartNode
對比
首先是 oldStartVnode
與 newStartVnode
符合 sameVnode
時,說明老 VNode 節點的頭部與新 VNode 節點的頭部是相同的 VNode 節點,直接進行 patchVnode
,同時 oldStartIdx
與 newStartIdx
向後移動一位。
其次是 oldEndVnode
與 newEndVnode
符合 sameVnode
,也就是兩個 VNode 的結尾是相同的 VNode,同樣進行 patchVnode
操作並將 oldEndVnode
與 newEndVnode
向前移動一位。
接下來是兩種交叉的情況。
先是 oldStartVnode
與 newEndVnode
符合 sameVnode
的時候,也就是老 VNode 節點的頭部與新 VNode 節點的尾部是同一節點的時候,將 oldStartVnode.elm
這個節點直接移動到 oldEndVnode.elm
這個節點的後面即可。然後 oldStartIdx
向後移動一位,newEndIdx
向前移動一位。
同理,oldEndVnode
與 newStartVnode
符合 sameVnode
時,也就是老 VNode 節點的尾部與新 VNode 節點的頭部是同一節點的時候,將 oldEndVnode.elm
插入到 oldStartVnode.elm
前面。同樣的,oldEndIdx
向前移動一位,newStartIdx
向後移動一位。
最後是當以上情況都不符合的時候,這種情況怎麼處理呢?
那就是查詢對比。
首先通過createKeyToOldIdx
方法生成oldVnode
的key
與 index
索引對應的一個 map
表。
然後我們根據newStartVnode.key
,可以快速地從 oldKeyToIdx
(createKeyToOldIdx
的返回值)中獲取相同 key
的節點的索引 idxInOld
,然後找到相同的節點。
這裡又分三種情況
如果沒有找到相同的節點,則通過 createElm
建立一個新節點,並將 newStartIdx
向後移動一位。
如果找到了節點,同時它符合 sameVnode
,則將這兩個節點進行 patchVnode
,將該位置的老節點賦值 undefined
(之後如果還有新節點與該節點key相同可以檢測出來提示已有重複的 key ),同時將 newStartVnode.elm
插入到 oldStartVnode.elm
的前面。同理,newStartIdx
往後移動一位。
如果不符合 sameVnode
,只能建立一個新節點插入到 parentElm
的子節點中,newStartIdx
往後移動一位。
最後一步就很容易啦,當 while
迴圈結束以後,如果 oldStartIdx > oldEndIdx
,說明老節點比對完了,但是新節點還有多的,需要將新節點插入到真實 DOM 中去,呼叫 addVnodes
將這些節點插入即可。
同理,如果滿足 newStartIdx > newEndIdx
條件,說明新節點比對完了,老節點還有多,將這些無用的老節點通過 removeVnodes
批次刪除即可。
Diff演演算法是一種對比演演算法。對比兩者是舊虛擬DOM和新虛擬DOM
,對比出是哪個虛擬節點
更改了,找出這個虛擬節點
,並只更新這個虛擬節點所對應的真實節點
,而不用更新其他資料沒發生改變的節點,實現精準
地更新真實DOM,進而提高效率和效能
。
精準
主要體現在,diff
演演算法首先就是找到可複用的節點,然後移動到正確的位置。當元素沒有找到的話再來建立新節點。
key
是 Vue
中 vnode
的唯一標記,通過這個 key
,diff
操作可以更準確、更快速。
key
就不是就地複用了,在 sameNode
函數 a.key === b.key
對比中可以避免就地複用的情況。所以會更加準確。key
的唯一性生成 map
物件來獲取對應節點,比遍歷方式更快。當我們的列表只涉及到 展示,不涉及到排序、刪除、新增的時候使用index
作為key
是沒什麼問題的。因為此時的index
在每個元素上是唯一的。
但是如果涉及到排序、刪除、新增的時候就不能再使用index
作為key
了,因為每個元素key
不再唯一了。不唯一的key
,對diff
演演算法沒有任何幫助,寫和沒寫是一樣的。
(學習視訊分享:、)
以上就是深入理解vue2中的VNode和diff演演算法的詳細內容,更多請關注TW511.COM其它相關文章!