第二次寫文章,寫得不對的地方望各位大神指正~
之前研究Vue的響應式原理有提到, 當資料發生變化時, Watcher會呼叫 vm._update(vm._render(), hydrating)
來進行DOM更新, 接下來我們看看這個具體的更新過程是如何實現的。
//摘自core\instance\lifecycle.js
Vue.prototype._update = function(vnode: VNode, hydrating ? : boolean) {
const vm: Component = this
if (vm._isMounted) {
callHook(vm, 'beforeUpdate')
}
const prevEl = vm.$el
const prevVnode = vm._vnode
const prevActiveInstance = activeInstance
activeInstance = vm
vm._vnode = vnode
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(
vm.$el, vnode, hydrating, false /* removeOnly */ ,
vm.$options._parentElm,
vm.$options._refElm
)
vm.$options._parentElm = vm.$options._refElm = null
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
activeInstance = prevActiveInstance
if (prevEl) {
prevEl.__vue__ = null
}
if (vm.$el) {
vm.$el.__vue__ = vm
}
if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
vm.$parent.$el = vm.$el
}
}複製程式碼
( 這裡我們就將一些不太重要的程式碼忽略掉不講了, 比如callHook呼叫鉤子函式之類的, 我們只關注實現元件渲染相關程式碼。)
這裡面最重要的程式碼就是通過 vm.__patch__
進行DOM更新。 如果之前沒有渲染過, 就直接呼叫 vm.__patch__
生成真正的DOM並將生成的DOM掛載到vm.$el上, 否則會呼叫 vm.__patch__(prevVnode, vnode)
將當前vnode與之前的vnode進行diff比較, 最小化更新。
接下來我們就看一下這個最重要的 vm.__patch__
到底做了些什麼。
//摘自platforms\web\runtime\patch.js
const modules = platformModules.concat(baseModules)
export const patch: Function = createPatchFunction({ nodeOps, modules })複製程式碼
可以看到patch方法主要就是呼叫了createPatchFunction這個函式。 一步步看看它主要乾了些什麼。
顧名思義, 這個函式的作用是建立並返回一個patch函式。
//摘自core\vdom\patch.js
//......
return function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue = []
if (isUndef(oldVnode)) {
isInitialPatch = true
createElm(vnode, insertedVnodeQueue, parentElm, refElm)
} else {
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
} else {
//......
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
//......
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}複製程式碼
在這個返回的patch函式裡, 會進行許多的判斷:
- 判斷vnode和oldVnode是否isDef( 即非undefined且非null, 下面簡稱已定義), 若vnode未定義且oldVnode已定義, 沒有新的vnode就意味著要將元件銷燬掉, 就會迴圈呼叫invokeDestroyHook函式將oldVnode銷燬掉。
- 如果oldVnode未定義, 意味著這是第一次patch, 就會呼叫
createElm(vnode, insertedVnodeQueue, parentElm, refElm)
建立一個新的DOM。 - 如果oldVnode跟vnode是同一個vnode, 且oldVnode.nodeType未定義, 就呼叫
patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
來更新oldVnode並生成新的DOM。( 這裡判斷nodeType是否定義是因為vnode是沒有nodeType的, 當進行服務端渲染時會有nodeType, 這樣可以排除掉服務端渲染的情況。 ) - 如果oldVnode跟vnode不同, 會呼叫createElm函式來建立新的DOM來替換掉原來的DOM。
我們分別看一下上面的兩種情況:
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(
vm.$el, vnode, hydrating, false /* removeOnly */ ,
vm.$options._parentElm,
vm.$options._refElm
)
vm.$options._parentElm = vm.$options._refElm = null
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}複製程式碼
如果沒有prevVnode(也就是第一次渲染), 這時vm.$el如果為undefined則滿足 isUndef(oldVnode)
,會呼叫createElm函式;如果vm.$el存在,但其不滿足 sameVnode(oldVnode, vnode)
,同樣會呼叫createElm函式。也就是說如果是首次渲染,就會呼叫createElm函式建立新的DOM。
如果有prevVnode(也就是進行檢視的更新),這時如果滿足 sameVnode(oldVnode, vnode)
(即vnode相同),則會呼叫patchVnode對vnode進行更新;如果vnode不相同,則會呼叫createElm函式建立新的DOM節點替換掉原來的DOM節點。
那麼接下來分別看看這兩個函式。
//摘自\core\vdom\patch.js
function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested) {
vnode.isRootInsert = !nested // for transition enter check
//......
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
//......
createChildren(vnode, children, insertedVnodeQueue)
insert(parentElm, vnode.elm, refElm)
//......
}複製程式碼
可以看到, createElm中主要會根據vnode.ns(vnode的名稱空間)是否存在呼叫createElementNS函式或createElmement函式生成真正的DOM節點並賦給vnode.elm儲存。然後通過createChildren函式建立vnode的子節點,並且通過insert函式將vnode.elm插入到父節點中。
//摘自\core\vdom\patch.js
function createChildren (vnode, children, insertedVnodeQueue) {
if (Array.isArray(children)) {
for (let i = 0; i < children.length; ++i) {
createElm(children[i], insertedVnodeQueue, vnode.elm, null, true)
}
} else if (isPrimitive(vnode.text)) {
nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(vnode.text))
}
}複製程式碼
createChildren函式會判斷vnode的children是否是陣列,如果是,則表明vnode有子節點,迴圈呼叫createElm函式為子節點建立DOM;如果是text節點,則會呼叫createTextNode為其建立文字節點。
//摘自\core\vdom\patch.js
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
//......
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}複製程式碼
patchVnode主要是對oldVnode和vnode進行一定的對比:
- 首先判斷vnode.text未定義,意味著vnode可能有children(具有text的vnode不會有children)。
- 如果vnode和oldVnode都有children,則用updateChildren對兩者的children進行對比。
- 如果vnode有children而oldVnode沒有,則通過addVnodes函式給elm加上子節點。
- 如果oldVnode有children而vnode沒有,則通過removeVnodes函式將elm的子節點刪除。
- 同時如果oldVnode.text已定義,則通過setTextContent將elm的text設為空(因為vnode.text未定義)。
- 如果vnode.text已定義並且不等於oldVnode.text的話,則將elm的text設為vnode.text。
我們先來看下比較簡單的當vnode和oldVnode只有其中一個有children時呼叫的addVnodes和removeVnodes函式。
//摘自\core\vdom\patch.js
function addVnodes (parentElm, refElm, vnodes, startIdx, endIdx, insertedVnodeQueue) {
for (; startIdx <= endIdx; ++startIdx) {
createElm(vnodes[startIdx], insertedVnodeQueue, parentElm, refElm)
}
}複製程式碼
addVnodes函式通過迴圈呼叫createElm分別對vnode的children中的每個子vnode建立子節點並掛載到DOM上。
function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
if (isDef(ch.tag)) {
removeAndInvokeRemoveHook(ch)
invokeDestroyHook(ch)
} else { // Text node
removeNode(ch.elm)
}
}
}
}複製程式碼
removeVnodes函式通過呼叫removeNode函式(removeAndInvokeRemoveHook函式最終也是呼叫removeNode函式)將oldVnode的children節點全部移除。
接下來就看一下當vnode和oldVnode都有children時呼叫的updateChildren函式。
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, 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)) {
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 {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
newStartVnode = newCh[++newStartIdx]
} else {
elmToMove = oldCh[idxInOld]
if (sameVnode(elmToMove, newStartVnode)) {
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm)
newStartVnode = newCh[++newStartIdx]
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
newStartVnode = newCh[++newStartIdx]
}
}
}
}
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)
}
}複製程式碼
在這裡我們主要需要關注三個陣列:oldCh、newCh和parentElm.children。oldCh就是oldVnode.children,newCh就是vnode.children,parentElm就是oldVnode.elm。
而oldStartIdx、oldEndIdx、newStartIdx和newEndIdx這四個是用於標誌當前關注的vnode的頭指標和尾指標。
簡單來說,我們會將oldCh和newCh進行比較,將oldCh跟newCh差異的部分patch到parentElm中,最終得到一個根據newCh所對應的elm.children。接下來我們一步步分析這個函式到底是如何進行diff的。
- 首先我們會進行一個迴圈,當滿足
oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx
時繼續進行迴圈。 - 在迴圈中,先判斷oldStartVnode跟oldEndVnode是否存在,不存在則指標跳到下一個。在後面會講到為什麼需要這一步。
- 接下來會進行四個判斷。
- 如果滿足
sameVnode(oldStartVnode, newStartVnode)
,則遞迴呼叫patchVnode對兩者進行比較,同時頭指標往右走。因為我們最終想要得到的是newCh所對應的elm,而這個elm是oldVnode.elm,它的children一開始是根據oldCh生成的。那麼當oldStartVnode跟newStartVnode相同時,意味著elm.children中這個位置的子節點已經是跟newCh所對應的。 - 如果滿足
sameVnode(oldEndVnode, newEndVnode)
,同理,遞迴呼叫patchVnode對兩者進行比較,同時尾指標往左走。 - 如果滿足
sameVnode(oldStartVnode, newEndVnode)
,意味著newEndVnode跟oldStartVnode相同,這個時候遞迴呼叫patchVnode對兩者進行比較後我們需要通過nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
,將oldStartVnode.elm移動到parentElm.children中newEndVnode所對應的位置,也就是oldEndVnode.elm後面。 - 如果滿足
sameVnode(oldEndVnode, newStartVnode)
,同理,通過遞迴呼叫patchVnode對兩者進行比較後通過nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
將oldEndVnode.elm移動到parentElm.children中newStartVnode所對應的位置,也就是oldStartVnode.elm前面。
- 如果滿足
- 如果以上判斷都不滿足,我們就直接通過key去尋找oldCh中與newStartVnode相對應的vnode。
- 如果沒找到對應的vnode,意味著這是一個新的節點,我們通過
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
建立一個新的DOM節點並插入到oldStartVnode.elm前面。 - 如果找到了oldCh中對應的vnode,我們用elmToMove將這個vnode儲存起來,通過遞迴呼叫patchVnode對這個vnode跟newStartVnode進行對比,然後將oldCh中對應的vnode設為undefined,同時通過
nodeOps.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm)
將elmToMove.elm移動到oldStartVnode.elm前面。可以看到,我們將這個節點設為了undefined,這樣當指標移動到這裡的時候發現是undefined就會繼續移動,因為這個節點已經被複用了,這個就是上面第2步判斷的作用。
- 如果沒找到對應的vnode,意味著這是一個新的節點,我們通過
- 當不再滿足
oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx
時,迴圈結束。這時候我們就要判斷到底是oldStartIdx > oldEndIdx
還是newStartIdx > newEndIdx
。- 如果
oldStartIdx > oldEndIdx
,因為只有當oldCh中的節點被複用時,oldCh的指標才會移動,當oldCh的頭指標大於尾指標時,意味著oldCh已經沒有節點可以被複用了,這樣我們就需要直接將newCh中還未新增到parentElm.children的節點通過addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
新增到parentElm.children中。 - 如果
newStartIdx > newEndIdx
,意味著newCh中的所有節點都已經在parentElm.children中了,也就意味著OldCh中如果oldStartIdx到oldEndIdx之間(包括oldStartIdx和oldEndIdx)指標所指向的節點在newCh中沒有對應的節點,也就是說剩下的都是多餘的節點,所以我們需要通過removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
將多餘的節點都移除。
- 如果
經過這樣的一個過程之後,parentElm.children就變成了與newCh相對應了。
總的來說,updateChildren的作用是根據newCh生成相應的parentElm.children,同時儘量複用其中的節點。所以對於每一個newCh的節點,會先在oldCh中找相應的節點,找到了就將其移動到parentElm.children中與newCh對應的位置,沒找到就建立一個新的節點插入到對應的位置。最後將parentElm.children中多餘的節點移除或者將newCh中還未新增到parentElm.children中的節點新增上去。
文字描述還是有點比較難理解,用圖例來進一步解釋。
首先,假設我們的oldCh有四個節點,用數字表示,分別為1、2、3、4,newCh五個節點,分別為5、2、6、3、1。由於parentElm.children是根據oldCh生成的,所以也有四個節點1、2、3、4。oldCh的頭尾指標分別指向1和4,newCh的頭尾指標分別指向5、1。
parentElm.children | 1 | 2 | 3 | 4 | - |
---|---|---|---|---|---|
oldCh指標 | ↓ | ↓ | |||
oldCh | 1 | 2 | 3 | 4 | |
newCh | 5 | 2 | 6 | 3 | 1 |
newCh指標 | ↑ | ↑ |
根據上面我們說到的updateChildren的判斷過程,判斷到oldCh的頭節點和newCh的尾節點相同,於是就將parentElm.children中的oldCh頭節點移動到oldCh尾節點後面。然後oldCh跟newCh的指標分別移動,於是就變成了下面這樣。
parentElm.children | 2 | 3 | 4 | 1 | - |
---|---|---|---|---|---|
oldCh指標 | ↓ | ↓ | |||
oldCh | 1 | 2 | 3 | 4 | |
newCh | 5 | 2 | 6 | 3 | 1 |
newCh指標 | ↑ | ↑ |
繼續進行迴圈判斷,發現頭尾的節點都沒有相同的,這個時候我們就要去oldCh中根據key找與newCh頭節點相同的節點。但是沒有找到,所以我們會建立一個新的節點插入到parentElm.children中頭節點前面,然後指標移動。結果如下。
parentElm.children | 5 | 2 | 3 | 4 | 1 |
---|---|---|---|---|---|
oldCh指標 | ↓ | ↓ | |||
oldCh | 1 | 2 | 3 | 4 | |
newCh | 5 | 2 | 6 | 3 | 1 |
newCh指標 | ↑ | ↑ |
繼續進行迴圈。發現頭節點相同,無需移動,直接對頭節點進行patch,指標移動。結果如下。
parentElm.children | 5 | 2 | 3 | 4 | 1 |
---|---|---|---|---|---|
oldCh指標 | ↓ | ↓ | |||
oldCh | 1 | 2 | 3 | 4 | |
newCh | 5 | 2 | 6 | 3 | 1 |
newCh指標 | ↑ | ↑ |
繼續進行迴圈。發現newCh尾節點和oldCh頭節點相同,將parentElm.children中的3節點移動到parentElm.children的尾指標後面,指標移動。結果如下。
parentElm.children | 5 | 2 | 4 | 3 | 1 |
---|---|---|---|---|---|
oldCh指標 | ↓↓ | ||||
oldCh | 1 | 2 | 3 | 4 | |
newCh | 5 | 2 | 6 | 3 | 1 |
newCh指標 | ↑↑ |
現在兩個頭尾指標都相等了,但還是符合迴圈的條件,於是繼續進行迴圈。由於兩個節點不相同,於是會建立一個新的節點插入到parentElm.children的頭指標前面,指標移動。結果如下。
parentElm.children | 5 | 2 | 6 | 4 | 3 | 1 |
---|---|---|---|---|---|---|
oldCh指標 | ↓↓ | |||||
oldCh | 1 | 2 | 3 | 4 | ||
newCh | 5 | 2 | 6 | 3 | 1 | |
newCh指標 | ↑ | ↑ |
這樣之後newStartIdx > newEndIdx
,迴圈結束。因為newStartIdx > newEndIdx
,意味著parentElm.children中可能還有多餘的節點,我們再呼叫removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
將多餘的節點移除。結果如下。
parentElm.children | 5 | 2 | 6 | 3 | 1 |
---|---|---|---|---|---|
oldCh指標 | ↓↓ | ||||
oldCh | 1 | 2 | 3 | 4 | |
newCh | 5 | 2 | 6 | 3 | 1 |
newCh指標 | ↑ | ↑ |
這樣,我們就完成了整一個updateChildren的過程,parentElm.children已經變成了與newCh相對應了。整一個patch的遞迴完成後,vnode.elm就變成全新的elm了,檢視也就更新完畢啦。