找到 patch 出處
首先在 Vue.prototype._update 中會調用 vm._ patch_ 方法:
<code>//source-code\\vue\\src\\core\\instance\\lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
//...
\tconst prevVnode = vm._vnode
//...
vm._vnode = vnode
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
//...
}/<code>
vm._ patch_ 對應就是 Vue.prototype._ patch_ 方法:
<code>Vue.prototype.__patch__ = inBrowser ? patch : noop
export const patch: Function = createPatchFunction({ nodeOps, modules })/<code>
最後在 createPatchFunction 方法中找到 patch 方法:
<code>//source-code\\vue\\src\\core\\vdom\\patch.js
export function createPatchFunction (backend) {
const { modules, nodeOps } = backend
//...
return function patch (oldVnode, vnode, hydrating, removeOnly) {
//...
return vnode.elm
}
}/<code>
oldVnode 和 vnode 在哪裡定義?
oldVnode 的定義
在最初的 init 方法中,會向 $mount 方法傳入 $options.el 屬性:
<code>Vue.prototype._init = function (options?: Object) {
\t//...
vm.$mount(vm.$options.el)
}/<code>
通過 query (querySelector) 選擇器方法,獲取對應的 dom 內容:
<code>Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el)
//...
return mount.call(this, el, hydrating)
}/<code>
將 el 賦值給 vm.$el:
<code>export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
//...
}/<code>
再回到最初的 update 方法,看到 oldVnode 就是 vm.$el:
<code>if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}/<code>
當然首次 prevVnode 變量的值為 null,則會走到第一個 _ patch_ 方法進行首次渲染。
vnode 的定義
再回到 _update 方法,我們能看到它的入參列表中就有 vnode,再把 vnode 傳給 _ patch_ 方法:
<code>//source-code\\vue\\src\\core\\instance\\lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
//...
const prevVnode = vm._vnode
//...
vm._vnode = vnode
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
//...
}/<code>
這個 vnode 對象 就是由 vm._render() 方法執行後得到:
<code>Vue.prototype._render = function (): VNode {
//...
\tvnode = render.call(vm._renderProxy, vm.$createElement)
//...
return vnode
}/<code>
<code>updateComponent = () => {
vm._update(vm._render(), hydrating)
}/<code>
初始 elm 的掛載
emptyNodeAt 生成 oldVnode 節點
現在我們正式進入 patch 方法,看它最後 vnode.elm 的 dom 元素如何產生?
<code>return function patch (oldVnode, vnode, hydrating, removeOnly) {
\t//...
return vnode.elm
}/<code>
我們的 vnode 目前是有定義的,之前已經看過了 render 方法的解析過程,並且 oldVnode 也是存在的(通過 query 選擇器取得),所以將進入 else 判斷:
<code>return function patch (oldVnode, vnode, hydrating, removeOnly) {
\tif (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
//...
if (isUndef(oldVnode)) {
}else{
\t// patchNode
}
return vnode.elm
}/<code>
這裡是 else 中的邏輯:
根據 oldVnode.nodeType 來判斷是否是個真實元素 isRealElement,因為我們 AST 解析出來的虛擬節點是不包括 nodeType 屬性的,所以 isRealElement=false。
另外,hydrating 為 undefined,所以將直接通過 emptyNodeAt 添加一個空節點,vnode 的 tag 屬性為當前 dom 的 tagName。
<code>const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
//...
} else {
if (isRealElement) {
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
//...
}
if (isTrue(hydrating)) {
//...
}
// either not server-rendered, or hydration failed.
// create an empty node and replace it
oldVnode = emptyNodeAt(oldVnode)
}
//...
}/<code>
createElm 創建節點
之後會將原來 oldVnode 的 dom 數據保存到 oldVnode.elm 中,並獲取父級元素:
<code>const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)/<code>
之後將使用 vnode,通過 createElm 創建新的虛擬節點,並掛載到 parentElm 上:
<code>// create new node
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)/<code>
當然 createElm 也略複雜,但我們能看到其中的 createChildren 和 insert 方法,也能猜到在做子節點的循環遍歷,以及子節點的插入操作:
<code>function createElm (
vnode,
insertedVnodeQueue,
parentElm,
refElm,
nested,
ownerArray,
index
) {
\t//...
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
//...
createChildren(vnode, children, insertedVnodeQueue)
if (isDef(data)) {
invokeCreateHooks(vnode, insertedVnodeQueue)
}
insert(parentElm, vnode.elm, refElm)
//...
}/<code>
最後將 oldVnode 上的一些舊的事件監聽、各種 hook 鉤子一併移除掉,再銷燬當前元素:
<code>// destroy old node
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}/<code>
如此,將新生成的 vnode.elm 樹渲染到 html 頁面上了。
新 vnode 的更新
patch 中的 updateChildren
在 _update 方法裡,第一個 if 判斷中 patch 已經在首次渲染的時候走過了,對
vm._vnode 初始化了值(即,prevVnode 非 false),所以當後續數據觸發更新,再次執行 _update,則會進入 else 中的 patch 方法:<code>Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
//...
\tconst prevVnode = vm._vnode
vm._vnode = vnode
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
//...
}/<code>
順著之前的 patch 邏輯,會發現 isRealElement 不再是 false 了,因為此時的 oldVnode 是虛擬節點,就不包含標準的 nodeType 屬性,則會跳過 emptyNodeAt 方法,從而進入對應的 if 邏輯條件,進入到 patchVnode 方法:
<code>if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}/<code>
在 patchVnode 方法中,我們跳過多數目前不太關心的條件,直接進到解析子節點 updateChildren 的判斷中,它會解析我們 oldVnode 和 vnode 中子節點,並逐層解析:
<code>function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
//...
const elm = vnode.elm = oldVnode.elm
//...
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 (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
//...
}/<code>
updateChildren 的邏輯相當複雜,可以說是整個 vnode 和 oldVnode 之間 diff 對比的核心:
<code>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
//...
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, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--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]
} 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 {
//...
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(oldCh, oldStartIdx, oldEndIdx)
}
}/<code>
所以下面我會根據一個簡單的 demo,來示例新老節點更新的 diff 對比過程。
詳解 updateChildren 中的 diff 過程
準備新老 node
通過 v-if 來切換的兩個不同標籤模板內容,來模擬新老節點(oldVnode 和 vnode):
通過 updateChildren 剝離出子節點
注意 text 第二級的子元素本篇不做判斷。
4種解析條件判斷過程
在 updateChildren 方法中,一般都會通過如下 4種核心判斷,對不同子元素進行比較:
<code>while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
\t//...
}else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--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]
} 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 {
//...
}
}/<code>
首先,會用 oldStartIdx,oldEndIdx 和 newStartIdx,oldEndIdx 來分別表示 oldCh 和 newCh 子節點隊列的起始和結束位置。
然後,依次對比 oldStartVnode 和 newStartVnode、oldEndVnode 和 newEndVnode、oldStartVnode 和 newEndVnode、oldEndVnode 和 newStartVnode 四種位置的對比方式來判斷比較節點是否是"相同的"虛擬節點?
當然 sameVnode 方法是有特殊條件的,必須節點上 key 屬性一致並且 tag、isComment、data、inputType 一致,或者和其他一些屬性一致才算相同元素:
<code>function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}/<code>
下面展示就目前的 demo 節點是如何在這幾種判斷中"遊走的":
首先 oldCh 和 ch 的 start 座標都在第一個元素。第一次:先對比新老節點的首個元素(1 vs 3):
目前,我們這兩個元素分別是 span 標籤和註釋標籤,肯定不符合 sameVnode 的判斷。
然後第二次:判斷兩個末尾元素是否相同(2 vs 5):
很不湊巧,這兩者也是不一致。
第三次,判斷老節點的首個元素和新節點的末元素(1 vs 5):
這兩個 tag 都是 span 標籤,雖然他們的內容 text 不一致,當然這會交給內部調用的 patchVnode 繼續往下一級比較(這裡第二級的 children 元素不做展開,最終他們是替換 text 內容):
<code>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]
}/<code>
這裡運氣比較好,第三次就匹配到了,如果這次沒有匹配中,將拿老節點的末個元素和新節點的首個元素對比(2 vs 3,這裡不做展開)
該判斷內會對 oldStartIdx+1,newEndIdx-1 做偏移操作,並且挪到新位置:
這一輪 while 結束了,注意 oldStatrIdx 和 newEndIdx 已經指向定位置(如果以上被其他條件匹配中,是會有不同偏移方式的)。
之後,將通過 while 再一次進入這四種條件的判斷,還是從首個元素開始判斷,因為 1 和 5 已在上次判斷中匹配過了,對應索引已經變更,這裡將從 2 和 3 兩個新的首個元素開始:
由於這兩個元素都是註釋元素,所以 sameVnode 匹配一致。
之後,再次偏移 oldStartIdx 和 newStartIdx 位置,最後 oldStartIdx 溢出到 oldCh 之外,之後進入 while 循環將不再符合條件:
但是我們 節點4 還未被處理過,這將走到 while 外的那段邏輯:
<code>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(oldCh, oldStartIdx, oldEndIdx)
}/<code>
目前會進入 addVnodes 方法,將 節點4 作為 refElm 掛載到 parentElm 中(oldCh 解析完畢);
相反,如果 newStartIdx > newEndIdx ,則會通過 removeVnodes 方法,移除 oldCh 中的部分元素。(ch 解析完畢。不全面的理解:四種判斷中,如果多數匹配到 oldEndVnode vs newStartVnode 條件,將使得 newStartIdx 增大,導致大於 newEndIdx,或者 oldCh 數量大於 endCh 等),
如此,整個 oldVnode.elm 將更新至最新結果,return 給 vm.$el 渲染到頁面上。
有關 key 屬性的判斷
有注意到,上面將 4種條件判斷時,沒有講 while 中 else 的邏輯,這塊內容將涉及 key 屬性的邏輯:
<code>while(){
\telse {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}/<code>
平時我們編寫業務代碼時,一定會遇到不同 input 標籤在做 v-if/else 之類的切換時,導致 value 還停留在上面,vue 官方也給了示例:使用 key 來解決;同時 key 也建議在 v-for 中使用,以便提升性能。
現在我們逐步看下 vnode 上設置 key 屬性是如何工作的?
在 oldCh 中解析 key,組成老節點的 key 集合
在 oldCh 子節點集合中,從 oldStartIdx 開始,到 oldEndIdx 結束,取存在 key 的元素,對應 value 為索引值:
<code>oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)/<code>
<code>function createKeyToOldIdx (children, beginIdx, endIdx) {
let i, key
const map = {}
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key
if (isDef(key)) map[key] = i
}
return map
}/<code>
判斷老節點 key 集合中是否存在新節點 key
如果當前新節點 newStartVnode 存在 key 屬性,則會判斷該 key 是否是老節點 key 集合之一?如果不是,則會通過
sameVnode 依次比較 oldCh 中所有元素,哪個和 newStartVnode 為"相同元素":<code>idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)/<code>
<code>function findIdxInOld (node, oldCh, start, end) {
for (let i = start; i < end; i++) {
const c = oldCh[i]
if (isDef(c) && sameVnode(node, c)) return i
}
}/<code>
idxInOld 不存在
如果 idxInOld 不存在,則會把 newStartVnode 節點作為新節點,通過 createElm 加入到 parentElm 中:
<code>createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)/<code>
idxInOld 存在
通過
oldCh[idxInOld] 取得將要移動的元素節點 vnodeToMove:<code>vnodeToMove = oldCh[idxInOld]/<code>
把 vnodeToMove 和 newStartVnode 做 sameVnode 比較:
<code>if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}/<code>
如果是"相同元素",則會通過 patchVnode 來做節點間的交換操作。之後為 oldCh[idxInOld] 做置空,以免後續 findIdxInOld 方法還會匹配到結果。
接著把 vnodeToMove.elm 節點加入到 oldStartVnode.elm 之前,結束。
相反 else,通過 createElm 把 newStartVnode 加入到 parentElm 中。
demo 示意
對此過程也做了一個 demo 用於示意:
首先是我們模板中,新老節點樹的說明:
然後在 while 判斷中,命中於:oldEndVnode, 和 newStartVnode 的判斷:
並且偏移 oldStartIdx 和 newEndIdx 的索引位置,再次進入 while 循環,這次將直接進入 else 的邏輯:
分別提取出每個 oldCh 子節點集合的 key 屬性,整合到 oldKeyToIdx 中:
然後判斷當前 newStartIdx.key 是否屬於 oldKeyToIdx 之中:
這裡的 節點5 和 節點2 的 key 相同(key=a)。
最後比較這兩個節點,是否是 sameVnode,是的話:通過 patchNode 做節點更新操作,將 oldCh[idxInOld] 新節點插入至 parentElm,清空老節點 oldCh[idxInOld];
反之,根據 newStartIVnode 創建新節點,掛載到 parentElm 中。
可能會問 節點1 怎麼辦?因為每次 else 執行後,會對 newStartIdx 做+1操作,所以以上步驟結束後,newStartIdx 將溢出於 ch 隊列;之後會匹配到 newStartIdx > newEndIdx 的判斷,從而通過 removeVnodes 溢出 oldCh 中相關元素。
補充:有關 document 的 node 操作
insertBefore 和 nextSibling
基於如下 vue 涉及的源碼:
<code>nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))/<code>
通過一個 demo 來熟悉下 insertBefore 和 nextSibling 有什麼用?(一段簡單的 html,ul 下依次顯示三個 li 標籤)
<code>
- 1
- 2
- 3
接下來我在 id=app 父節點下,提取出 id=one 的 dom 節點,並把它插入到最後個 li 標籤之前:
<code>let parentElm = document.getElementById('app');
let startNode = document.getElementById('one');
let restNode = document.getElementById('two').nextSibling;
parentElm.insertBefore(startNode, restNode);/<code>
一頓操作後,頁面就會如下顯示:
閱讀更多 前端雨爸 的文章