在組件實例初始化的過程當中,Watcher實例與updateComponent建立了關聯。node
let updateComponent = () => {
//更新 Component的定義,主要作了兩個事情:render(生成vdom)、update(轉換vdom爲dom)
vm._update(vm._render(), hydrating)
}
複製代碼
重點關注vm._update()
。查看/core/instance/lifecycle.js
:web
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
...//省略
if (!prevVnode) {
//初始化渲染
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
...//省略
}
複製代碼
此處的vm.__patch__()
是在源碼入口階段的platforms/web/runtime/index.js
中定義:算法
import { patch } from './patch'
//定義補丁函數,這是將虛擬DOM轉換爲真實DOM的操做,很是重要
Vue.prototype.__patch__ = inBrowser ? patch : noop
複製代碼
進入到platforms/web/runtime/patch.js
:數組
import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'
const modules = platformModules.concat(baseModules)
//引入平臺特有代碼,其中: nodeOps是節點操做,modules是屬性操做
export const patch: Function = createPatchFunction({ nodeOps, modules })
複製代碼
createPatchFunction({nodeOps, modules})
中的兩個參數nodeOps與modules都是和特定平臺相關的代碼。其中:瀏覽器
nodeOps是節點操做,定義各類原生dom基礎操做方法。 modules 是屬性操做,定義屬性更新實現。bash
createPatchFunction在文件core/vdom/patch.js
中。該文件是整個Vue項目中最大的文件,詳細記錄了patch的過程。下面咱們來詳細看看patch算法的原理與實現。dom
patch算法經過新老VNode樹的對比,根據比較結果進行最小單位地修改視圖(打補丁的由來),而不是將整個視圖根據新的VNode重繪。patch的核心在於diff算法。async
diff算法使用同層的樹節點進行比較(而非對整棵樹進行逐層搜索遍歷),時間複雜度只有O(n),是一種至關高效的算法。示意以下圖:兩棵樹之間,相同顏色的方塊表明互相進行比較的VNode節點。函數
看看patch算法的源碼:oop
return function patch (oldVnode, vnode, hydrating, removeOnly) {
//新節點不存在,老節點存在,直接移除老節點
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue = []
if (isUndef(oldVnode)) {
// empty mount (likely as component), create new root element
isInitialPatch = true
//新節點存在,老節點不存在,新增該節點
createElm(vnode, insertedVnodeQueue)
} else {
//判斷是不是真實DOM,用於區分是不是初始化邏輯
const isRealElement = isDef(oldVnode.nodeType)
//不是真實DOM,而且是相同的VNode元素
if (!isRealElement && sameVnode(oldVnode, vnode)) {
//更新操做
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
//真實DOM,即初始化邏輯
if (isRealElement) {
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
//當舊的VNode是服務端渲染的元素,hydrating記爲true
oldVnode.removeAttribute(SSR_ATTR)
hydrating = true
}
if (isTrue(hydrating)) {
//須要合併服務端渲染的頁面到真實DOM上
if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
invokeInsertHook(vnode, insertedVnodeQueue, true)
return oldVnode
}
}
// 不是服務端渲染節點,或者服務端渲染合併到真實DOM失敗,返回一個空節點
oldVnode = emptyNodeAt(oldVnode)
}
// 取代現有元素
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
//生成真實DOM
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm, //指定父節點
nodeOps.nextSibling(oldElm) //指定插入位置:現有元素的旁邊
)
...//省略
// 移除老節點
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
複製代碼
上面代碼的邏輯不算難,主要進行的是同層的樹節點進行比較。其中包含的操做分別是:增長新節點、刪除舊節點、更新節點。
查看源碼發現,只有當斷定新舊節點是同一個節點的時候,纔會執行patchVnode()
更新操做。怎樣纔算同一個節點呢?咱們來看sameVnode()
:
/*
判斷兩個VNode節點是不是同一個節點,須要知足如下條件:
key相同
tag(當前節點的標籤名)相同
isComment(是否爲註釋節點)相同
是否data(當前節點對應的對象,包含了具體的一些數據信息,是一個VNodeData類型,能夠參考VNodeData類型中的數據信息)都有定義
當標籤是<input>的時候,type必須相同
*/
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)
)
)
)
}
/*
判斷當標籤是<input>的時候,type是否相同
某些瀏覽器不支持動態修改<input>類型,因此他們被視爲不一樣類型
*/
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的tag、key、isComment都相同,而且同時定義或未定義data的時候,且若是標籤爲input則type必須相同。這時候這兩個VNode則算sameVnode,能夠直接進行patchVnode操做。
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
//兩個VNode節點相同則直接返回
if (oldVnode === vnode) {
return
}
const elm = vnode.elm = oldVnode.elm
...//省略
/*
若是新舊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
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
//i = data.hook.prepatch,若是存在,見"./create-component componentVNodeHooks"
i(oldVnode, vnode)
}
const oldCh = oldVnode.children
const ch = vnode.children
if (isDef(data) && isPatchable(vnode)) {
//調用update回調以及update鉤子
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)
}
//vnode的text屬性與children屬性是互斥關係,若沒有text屬性,必有children
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
//老的有子節點,新的也有子節點,對子節點進行diff操做
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
//新的有子節點,老的沒有子節點,先清空老節點的text內容
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
//再增長新節點
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
//老的有子節點,新的沒有子節點,移除老節點
removeVnodes(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)) {
//i = data.hook.postpatch,若是存在,見"./create-component componentVNodeHooks"
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}
複製代碼
代碼不難理解。整個patchVnode的規則是這樣的:
1.若是新舊VNode都是靜態的,同時它們的key相同(表明同一節點),而且新的VNode是clone或者是標記了once(標記v-once屬性,只渲染一次),那麼只須要替換elm以及componentInstance便可。
2.新老節點均有children子節點,則對子節點進行diff操做,調用updateChildren,這個updateChildren也是diff的核心。
3.若是老節點沒有子節點而新節點存在子節點,先清空老節點DOM的文本內容,而後爲當前DOM節點加入子節點。
4.當新節點沒有子節點而老節點有子節點的時候,則移除該DOM節點的全部子節點。
5.當新老節點都無子節點的時候,只是文本的替換。
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
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
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]
/*
前四種狀況實際上是指定key的時候,斷定爲同一個VNode,則直接patchVnode便可
分別比較oldCh以及newCh的兩頭節點2*2=4種狀況
*/
} 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)) { //頭尾相同
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
//挪動oldStartVnode到oldEndVnode前面
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { //尾頭相同
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
//挪動oldEndVnode到oldStartVnode前面
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
//在OldCh中,建立一張key<==>idx對應的map表,可加快查找newStartVnode是否在OldCh中
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { //若是不存在OldCh中,那麼認定newStartVnode是新元素,建立
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
/*
對比OldCh中找到的【相同key】的Vnode與newStartVnode,是不是相同的節點
判斷是不是相同節點,除了key相同外,還有:
tag(當前節點的標籤名)相同
isComment(是否爲註釋節點)相同
是否data都有定義
當標籤是<input>的時候,type必須相同
*/
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// 若是key相同,可是其餘不一樣,那麼認爲這是新元素,建立
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
//若是oldCh數組已經遍歷完,newCh數組還有元素,那麼將剩餘元素都建立
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
//若是newCh數組已經遍歷完,oldCh數組還有元素,那麼將剩餘元素都刪除
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}
複製代碼
這個過程不算複雜。經過畫圖的方式會更加清晰一些。
在新老兩個VNode節點的左右頭尾兩側都有一個變量標記,在遍歷過程當中這幾個變量都會向中間靠攏。當oldStartIdx <= oldEndIdx或者newStartIdx <= newEndIdx時結束循環。 首先,oldStartVnode、oldEndVnode與newStartVnode、newEndVnode兩兩比較一共有2*2=4種比較方法。
當新老VNode節點的start或者end知足sameVnode()
時,直接將對應VNode節點進行patchVnode便可。
若是oldStartVnode與newEndVnode知足sameVnode()
,即sameVnode(oldStartVnode, newEndVnode)
。這時候說明oldStartVnode已經跑到oldEndVnode後面了。進行patchVnode的同時,還須要將oldStartVnode移動到oldEndVnode的後面。
若是oldEndVnode與newStartVnode知足sameVnode()
,即sameVnode(oldEndVnode, newStartVnode)
。這說明oldEndVnode跑到了oldStartVnode的前面。進行patchVnode()
的同時,還須要將oldEndVnode移動到oldStartVnode的前面。
若是不符合以上4種簡單狀況,那麼在OldCh中,建立一張key<==>idx對應的map表,可加快查找newStartVnode是否在OldCh中。從這個map表中能夠找到是否有與newStartVnode一致key的舊的VNode節點。若是知足sameVnode()
,patchVnode()
的同時會將這個真實DOM(elmToMove)移動到oldStartVnode對應的真實DOM的前面。
固然,知足相同節點的狀況是美好的,更多的狀況是不知足相同節點的時候:key不相同,或者key相同但依然不知足sameVnode()
。這時,須要建立新節點:
固然,還有一些終止條件的判斷。
若是oldCh數組已經遍歷完,newCh數組還有元素,那麼將剩餘元素都建立。
若是newCh數組已經遍歷完,oldCh數組還有元素,那麼將剩餘元素都刪除整個patch算法很長。總體一遍理下來,會對其實現過程有一個更加深入的理解。
接下來想要問一個問題:updateChildren()
的時候,爲何會產生頭尾交叉的4種比較的方式呢?它有什麼好處麼?
結合實際平常網頁操做的習慣:咱們可能會是隻拖拖拽拽一個DOM元素,其餘元素都不改變;或者點擊一個讓展現元素逆向排序的按鈕;或者清除了一個DOM元素。
這些操做,都只是簡單的改變DOM樹的同層結構而已。頭尾交叉的比較方式,徹底可以高效應對這些平常操做。
這也能夠做爲一個面向工程化的算法優化案例了~
關於DOM的操做更新,patch算法已經向咱們展現了所有過程。不過依然還有細節沒有處理完:patch的過程只是將虛擬DOM映射成了真實的DOM。那如何給這些DOM加入attr、class、style等DOM屬性呢?
實際上,源碼中已經有答案,只是還不夠明細。它的實現依賴於VDOM的生命週期函數。在createPatchFunction()
中,有這麼一段:
//VDOM生命週期鉤子函數
const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
//引入DOM屬性相關的生命週期鉤子函數
for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = []
//modules包含[attrs,klass,events,domProps,style,transition,ref,directives]等DOM屬性
for (j = 0; j < modules.length; ++j) {
if (isDef(modules[j][hooks[i]])) {
cbs[hooks[i]].push(modules[j][hooks[i]])
}
}
}
複製代碼
這個cbs數組中的函數在哪裏被調用了呢?來看patchVNode()
中容易被忽視的一行代碼:
if (isDef(data) && isPatchable(vnode)) {
//調用各個DOM屬性相關的update鉤子函數
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)
}
複製代碼
此處正式更新對應DOM屬性的所在。實際上,也就意味着,在patchVNode()
過程當中,DOM屬性對應的更新也已經悄悄作完了。