Vue 源碼patch過程解析

在這篇文章深刻源碼學習Vue響應式原理講解了當數據更改時,Vue是如何通知依賴他的數據進行更新的,這篇文章講得就是:視圖知道了依賴的數據的更改,可是怎麼更新視圖的。html

Vnode Tree

在真實的HTML中有DOM樹與之對應,在Vue中也有相似的Vnode Tree與之對應。vue

抽象DOM

jquery時代,實現一個功能,每每是直接對DOM進行操做來達到改變視圖的目的。可是咱們知道直接操做DOM每每會影響重繪和重排,這兩個是最影響性能的兩個元素。
進入Virtual DOM時代之後,將真實的DOM樹抽象成了由js對象構成的抽象樹。virtual DOM就是對真實DOM的抽象,用屬性來描述真實DOM的各類特性。當virtual DOM發生改變時,就去修改視圖。在Vue中就是Vnode Tree的概念node

VNode

當修改某條數據的時候,這時候js會將整個DOM Tree進行替換,這種操做是至關消耗性能的。因此在Vue中引入了Vnode的概念:Vnode是對真實DOM節點的模擬,能夠對Vnode Tree進行增長節點、刪除節點和修改節點操做。這些過程都只須要操做VNode Tree,不須要操做真實的DOM,大大的提高了性能。修改以後使用diff算法計算出修改的最小單位,在將這些小單位的視圖進行更新。jquery

// core/vdom/vnode.js
class Vnode {
    constructor(tag, data, children, text, elm, context, componentOptions) {
        // ...
    }
}
複製代碼

生成vnode

生成vnode有兩種狀況:git

  1. 建立非組件節點的vnode
    • tag不存在,建立空節點、註釋、文本節點
    • 使用vue內部列出的元素類型的vnode
    • 沒有列出的建立元素類型的vnode

<p>123</p>爲例,會被生成兩個vnode:github

  • tagp,可是沒有text值的節點
  • 另外一個是沒有tag類型,可是有text值的節點
  1. 建立組件節點的VNode 組件節點生成的Vnode,不會和DOM Tree的節點一一對應,只存在VNode Tree
    // core/vdom/create-component
    function createComponent() {
        // ...
        const vnode = new VNode(
            `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
            data, undefined, undefined, undefined, context,
            { Ctor, propsData, listeners, tag, children }
        )
    }
    複製代碼
    這裏建立一個組件佔位vnode,也就不會有真實的DOM節點與之對應

組件vnode的創建,結合下面例子進行講解:算法

<!--parent.vue-->
<div classs="parent">
    <child></child>
</div>
<!--child.vue-->
<template>
    <div class="child"></div>
</template>
複製代碼

真實渲染出來的DOM Tree是不會存在child這個標籤的。child.vue是一個子組件,在Vue中會給這個組件建立一個佔位的vnode,這個vnode在最終的DOM Tree不會與DOM節點一一對應,即只會出現vnode Tree中。數組

/* core/vdom/create-component.js */
export function createComponent () {
    // ...
     const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children }
    )
}
複製代碼

那最後生成的Vnode Tree就大概以下:bash

vue-component-${cid}-parent
    vue-component-${cid}-child
        div.child
複製代碼

最後生成的DOM結構爲:app

<div class="parent">
    <div class="child"></div>
</div>
複製代碼

在兩個組件文件中打印自身,能夠看出二者之間的關係 chlid實例對象

parent實例對象
能夠看到如下關係:

  1. vnode經過children指向子vnode
  2. vnode經過$parent指向父vnode
  3. 佔位vnode爲對象的$vnode
  4. 渲染的vnode爲對象的_vnode

patch

在上一篇文章提到當建立Vue實例的時候,會執行如下代碼:

updateComponent = () => {
    const vnode = vm._render();
    vm._update(vnode)
}
vm._watcher = new Watcher(vm, updateComponent, noop)
複製代碼

當數據發生改變時會觸發回調函數updateComponent進行模板數據更新,updateComponent其實是對__patch__的封裝。patch的本質是將新舊vnode進行比較,建立或者更新DOM節點/組件實例,若是是首次的話,那麼就建立DOM或者組件實例。

// core/vdom/patch.js
function createPatchFunction(backend) {
    const { modules, nodeOps } = backend;
    for (i = 0; i < hooks.length; ++i) {
        cbs[hooks[i]] = []
        for (j = 0; j < modules.length; ++j) {
          if (isDef(modules[j][hooks[i]])) {
            cbs[hooks[i]].push(modules[j][hooks[i]])
          }
        }
    }
    
    return function patch(oldVnode, vnode) {
        if (isUndef(oldVnode)) {
            let isInitialPatch = true
            createElm(vnode, insertedVnodeQueue, parentElm, refElm)
        } else {
            const isRealElement = isDef(oldVnode.nodeType)
            if (!isRealElement && sameVnode(oldVnode, vnode)) {
                patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
            } else {
                if (isRealElement) {
                    oldVnode = emptyNodeAt(oldVnode)
                }
                const oldElm = oldVnode.elm
                const parentElm = nodeOps.parentNode(oldElm)
                createElm(
                    vnode,
                    insertedVnodeQueue,
                    oldElm._leaveC ? null : parentELm,
                    nodeOps.nextSibling(oldElm)
                )
                
                if (isDef(vnode.parent)) {
                    let ancestor = vnode.parent;
                    while(ancestor) {
                        ancestor.elm = vnode.elm;
                        ancestor = ancestor.parent
                    }
                    if (isPatchable(vnode)) {
                        for (let i = 0; i < cbs.create.length; ++i) {
                            cbs.create[i](emptyNode, vnode.parent)
                        }
                    }
                }
                if (isDef(parentElm)) {
                    removeVnodes(parentElm, [oldVnode], 0, 0)
                } else if (isDef(oldVnode.tag)) {
                    invokeDestroyHook(oldVnode)
                }
            }
        }
        
        invokeInsertHook(vnode, insertedVnodeQueue)
        return vode.elm
    }
}
複製代碼
  • 若是是首次patch,就建立一個新的節點
  • 老節點存在
    • 老節點不是真實DOM而且和新節點相同
      • 調用patchVnode修改現有節點
    • 新老節點不相同
      • 若是老節點是真實DOM,建立真實的DOM
      • 爲新的Vnode建立元素/組件實例,若parentElm存在,則插入到父元素上
      • 若是組件根節點被替換,遍歷更新父節點element。而後移除老節點
  • 調用insert鉤子
    • 是首次patch而且vnode.parent存在,設置vnode.parent.data.pendingInsert = queue
    • 若是不知足上面條件則對每一個vnode調用insert鉤子
  • 返回vnode.elm nodeOps上封裝了針對各類平臺對於DOM的操做,modules表示各類模塊,這些模塊都提供了createupdate鉤子,用於建立完成和更新完成後處理對應的模塊;有些模塊還提供了activateremovedestory等鉤子。通過處理後cbs的最終結構爲:
cbs = {
    create: [
        attrs.create,
        events.create
        // ...
    ]
}
複製代碼

最後該函數返回的就是patch方法。

createElm

createElm的目的建立VNode節點的vnode.elm。不一樣類型的VNode,其vnode.elm建立過程也不同。對於組件佔位VNode,會調用createComponent來建立組件佔位VNode的組件實例;對於非組件佔位VNode會建立對應的DOM節點。 如今有三種節點:

  • 元素類型的VNode:
    • 建立vnode對應的DOM元素節點vnode.elm
    • 設置vnodescope
    • 調用createChildren建立子vnodeDOM節點
    • 執行create鉤子函數
    • DOM元素插入到父元素中
  • 註釋和本文節點
    • 建立註釋/文本節點vnode.elm,並插入到父元素中
  • 組件節點:調用createComponent
function createElm(vnode, insertedVnodeQueue, parentElm, refElm, nested) {
    // 建立一個組件節點
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
        return
    }
    const data = vnode.data;
    const childre = vnode.children;
    const tag = vnode.tag;
    // ...

    if (isDef(tag)) {
        vnode.elm = vnode.ns
            ? nodeOps.createElementNS(vnode.ns, tag)
            : nodeOps.createElement(tag, vnode)
        setScope(vnode)
        if (isDef(data)) {
            invokeCreateHooks(vnode, insertedVnodeQueue)
        }
        createChildren(vnode, children, insertedVnodeQueue)  
    } else if (isTrue(vnode.isComment)) {
        vnode.elm = nodeOps.createComment(vnode.text);
    } else {
        vnode.elm = nodeOps.createTextNode(vnode.te)
    }
    insert(parentElm, vnode.elm, refElm)
}
複製代碼

createComponent的主要做用是在於建立組件佔位Vnode的組件實例, 初始化組件,而且從新激活組件。在從新激活組件中使用insert方法操做DOMcreateChildren用於建立子節點,若是子節點是數組,則遍歷執行createElm方法,若是子節點的text屬性有數據,則使用nodeOps.appendChild()在真實DOM中插入文本內容。insert用將元素插入到真實DOM中。

// core/vdom/patch.js
function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
    // ...
    let i = vnode.data.hook.init
    i(vnode, false, parentElm, refElm)
    if (isDef(vnode.componentInstance)) {
        initComponent(vnode, insertedVnodeQueue)
        insert(parentElm, vnode.elm, refElm)
        return true;
    }
}
複製代碼
  • 執行init鉤子生成componentInstance組件實例
  • 調用initComponent初始化組件
    • 把以前已經存在的vnode隊列進行合併
    • 獲取到組件實例的DOM根元素節點,賦給vnode.elm
    • 若是vnode是可patch
      • 調用create函數,設置scope
    • 若是不可patch
      • 註冊組件的ref,把組件佔位vnode加入insertedVnodeQueue
  • vnode.elm插入到DOM Tree

在組件建立過程當中會調用core/vdom/create-component中的createComponent,這個函數會建立一個組件VNode,而後會再vnode上建立聲明各個聲明周期函數,init就是其中的一個週期,他會爲vnode建立componentInstance屬性,這裏componentInstance表示繼承Vue的一個實例。在進行new vnodeComponentOptions.Ctor(options)的時候就會從新建立一個vue實例,也就會從新把各個生命週期執行一遍如created-->mounted

init (vnode) {
    // 建立子組件實例
    const child = vnode.componentInstance = createComponentInstanceForVnode(vnode, activeInstance)
    chid.$mount(undefined)
}
function createComponentInstanceForVnode(vn) {
    // ... options的定義
    return new vnodeComponentOptions.Ctor(options)
}
複製代碼

這樣child就表示一個Vue實例,在實例建立的過程當中,會執行各類初始化操做, 例如調用各個生命週期。而後調用$mount,實際上會調用mountComponent函數,

// core/instance/lifecycle
function mountComponent(vm, el) {
    // ...
    updateComponent = () => {
        vm._update(vm._render())
    }
    vm._watcher = new Watcher(vm, updateComponent, noop)
}
複製代碼

在這裏就會執行vm._render

// core/instance/render.js
Vue.propotype._render = function () {
    // ...
    vnode = render.call(vm._renderProxy, vm.$createElement)
    return vnode
}
複製代碼

能夠看到的時候調用_render函數,最後生成了一個vnode。而後調用vm._update進而調用vm.__patch__生成組件的DOM Tree,可是不會把DOM Tree插入到父元素上,若是子組件中還有子組件,就會建立子孫組件的實例,建立子孫組件的DOM Tree。當調用insert(parentElm, vnode.elm, refElm)纔會將當前的DOM Tree插入到父元素中。
在回到patch函數,當不是第一次渲染的時候,就會執行到另外的邏輯,而後oldVnode是否爲真實的DOM,若是不是,而且新老VNode不相同,就執行patchVnode

// core/vdom/patch.js
function sameVnode(a, b) {
    return (
        a.key === b.key &&
        a.tag === b.tag && 
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType
    )
}
複製代碼

sameVnode就是用於判斷兩個vnode是不是同一個節點。

patchVnode

若是符合sameVnode,就不會渲染vnode從新建立DOM節點,而是在原有的DOM節點上進行修補,儘量複用原有的DOM節點。

  • 若是兩個節點相同則直接返回
  • 處理靜態節點的狀況
  • vnode是可patch
    • 調用組件佔位vnodeprepatch鉤子
    • update鉤子存在,調用update鉤子
  • vnode不存在text文本
    • 新老節點都有children子節點,且children不相同,則調用updateChildren遞歸更新children(這個函數的內容放到diff中進行講解)
    • 只有新節點有子節點:先清空文本內容,而後爲當前節點添加子節點
    • 只有老節點存在子節點: 移除全部子節點
    • 都沒有子節點的時候,就直接移除節點的文本
  • 新老節點文本不同: 替換節點文本
  • 調用vnodepostpatch鉤子
function patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) {
    if (oldVnode === vnode) return
    // 靜態節點的處理程序
    const data = vnode.data;
    i = data.hook.prepatch
    i(oldVnode, vnode);
    if (isPatchable(vnode)) {
        for(i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
        i = data.hook.update
        i(oldVnode, vnode)
    }
    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)
    }
    i = data.hook.postpatch
    i(oldVnode, vnode)
}
複製代碼

diff算法

patchVnode中提到,若是新老節點都有子節點,可是不相同的時候就會調用updateChildren,這個函數經過diff算法儘量的複用先前的DOM節點。

function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) {
    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, elmToMove, refElm 
    
    while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isUndef(oldStartVnode)) {
            oldStartVnode = oldCh[++oldStartIdx]
        } 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)) {
            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)) {
            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)) {
                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, newStartVnode.elm, oldStartVnode.elm)
                    newStartVnode = newCh[++newStartIdx]
                } else {
                    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)
    }
}
複製代碼

算了這個圖沒畫明白,借用網上的圖

oldStartIdxnewStartIdxoldEndIdx以及 newEndIdx分別是新老兩個 VNode兩邊的索引,同時 oldStartVnodenewStartVnodeoldEndVnodenew EndVnode分別指向這幾個索引對應的 vnode。整個遍歷須要在 oldStartIdx小於 oldEndIdx而且 newStartIdx小於 newEndIdx(這裏爲了簡便,稱 sameVnode爲類似)

  1. oldStartVnode不存在的時候,oldStartVnode向右移動,oldStartIdx1
  2. oldEndVnode不存在的時候,oldEndVnode向右移動,oldEndIdx1
  3. oldStartVnodenewStartVnode類似,oldStartVnodenewStartVnode都向右移動,oldStartIdxnewStartIdx都增長1
  4. oldEndVnodenewEndVnode類似,oldEndVnodenewEndVnode都向左移動,oldEndIdxnewEndIdx都減1
  5. oldStartVnodenewEndVnode類似,則把oldStartVnode.elm移動到oldEndVnode.elm的節點後面。而後oldStartIdx向後移動一位,newEndIdx向前移動一位

6. oldEndVnodenewStartVnode類似時,把 oldEndVnode.elm插入到 oldStartVnode.elm前面。一樣的, oldEndIdx向前移動一位, newStartIdx向後移動一位。
7. 當以上狀況都不符合的時候 生成一個 key與舊 vnode對應的哈希表

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
}
複製代碼

最後生成的對象就是以childrenkey爲屬性,遞增的數字爲屬性值的對象例如

children = [{
    key: 'key1'
}, {
    key: 'key2'
}]
// 最後生成的map
map = {
    key1: 0,
    key2: 1,
}
複製代碼

因此oldKeyToIdx就是key和舊vnodekey對應的哈希表 根據newStartVnodekey看可否找到對應的oldVnode

  • 若是oldVnode不存在,就建立一個新節點,newStartVnode向右移動
  • 若是找到節點:
    • 而且和newStartVnode類似。將map表中該位置的賦值undefined(用於保證key是惟一的)。同時將newStartVnode.elm插入啊到oldStartVnode.elm的前面,而後index向後移動一位
    • 若是不符合sameVnode,只能建立一個新節點插入到parentElm的子節點中,newStartIdx向後移動一位
  1. 結束循環後

    • oldStartIdx又大於oldEndIdx,就將新節點中沒有對比的節點加到隊尾中

    • 若是newStartIdx > newEndIdx,就說明還存在新節點,就將這些節點進行刪除

總結

本篇文章對數據發生改變時,視圖是如何更新進行了講解。對一些細節地方進行了省略,若是須要了解更加深刻,結合源碼更加合適。個人github請多多關注,謝謝

相關文章
相關標籤/搜索