從 Template 到 DOM(Vue.js 源碼角度看內部運行機制)

SauMarko 7年前發布 | 39K 次閱讀 Vue.js Vue.js開發

寫在前面

因為對Vue.js很感興趣,而且平時工作的技術棧也是Vue.js,這幾個月花了些時間研究學習了一下Vue.js源碼,并做了總結與輸出。

在學習過程中,為Vue加上了中文的注釋 https://github.com/answershuto/learnVue/tree/master/vue-src ,希望可以對其他想學習Vue源碼的小伙伴有所幫助。

可能會有理解存在偏差的地方,歡迎提issue指出,共同學習,共同進步。

從new一個Vue對象開始

let vm = new Vue({  
    el: '#app',
    /*some options*/
});

很多同學好奇,在new一個Vue對象的時候,內部究竟發生了什么?

究竟Vue.js是如何將data中的數據渲染到真實的宿主環境環境中的?

又是如何通過“響應式”修改數據的?

template是如何被編譯成真實環境中可用的HTML的?

Vue指令又是執行的?

帶著這些疑問,我們從Vue的構造類開始看起。

Vue構造類

function Vue (options) {  
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  /*初始化*/
  this._init(options)
}

Vue的構造類只做了一件事情,就是調用_init函數進行

來看一下init的代碼

Vue.prototype._init = function (options?: Object) {
const vm: Component = this // a uid vm._uid = uid++

let startTag, endTag
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
  startTag = `vue-perf-init:${vm._uid}`
  endTag = `vue-perf-end:${vm._uid}`
  mark(startTag)
}

// a flag to avoid this being observed
/*一個防止vm實例自身被觀察的標志位*/
vm._isVue = true
// merge options
if (options && options._isComponent) {
  // optimize internal component instantiation
  // since dynamic options merging is pretty slow, and none of the
  // internal component options needs special treatment.
  initInternalComponent(vm, options)
} else {
  vm.$options = mergeOptions(
    resolveConstructorOptions(vm.constructor),
    options || {},
    vm
  )
}
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
  initProxy(vm)
} else {
  vm._renderProxy = vm
}
// expose real self
vm._self = vm
/*初始化生命周期*/
initLifecycle(vm)
/*初始化事件*/
initEvents(vm)
/*初始化render*/
initRender(vm)
/*調用beforeCreate鉤子函數并且觸發beforeCreate鉤子事件*/
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
/*初始化props、methods、data、computed與watch*/
initState(vm)
initProvide(vm) // resolve provide after data/props
/*調用created鉤子函數并且觸發created鉤子事件*/
callHook(vm, 'created')

/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
  /*格式化組件名*/
  vm._name = formatComponentName(vm, false)
  mark(endTag)
  measure(`${vm._name} init`, startTag, endTag)
}

if (vm.$options.el) {
  /*掛載組件*/
  vm.$mount(vm.$options.el)
}

}</code></pre>

_init主要做了這兩件事:

1.初始化(包括生命周期、事件、render函數、state等)。

2.$mount組件。

在生命鉤子beforeCreate與created之間會初始化state,在此過程中,會依次初始化props、methods、data、computed與watch,這也就是Vue.js對options中的數據進行“響應式化”(即雙向綁定)的過程。對于Vue.js響應式原理不了解的同學可以先看一下筆者的另一片文章 《Vue.js響應式原理》

/*初始化props、methods、data、computed與watch*/
export function initState (vm: Component) {  
  vm._watchers = []
  const opts = vm.$options
  /*初始化props*/
  if (opts.props) initProps(vm, opts.props)
  /*初始化方法*/
  if (opts.methods) initMethods(vm, opts.methods)
  /*初始化data*/
  if (opts.data) {
    initData(vm)
  } else {
    /*該組件沒有data的時候綁定一個空對象*/
    observe(vm._data = {}, true /* asRootData */)
  }
  /*初始化computed*/
  if (opts.computed) initComputed(vm, opts.computed)
  /*初始化watchers*/
  if (opts.watch) initWatch(vm, opts.watch)
}

雙向綁定

以initData為例,對option的data的數據進行雙向綁定Oberver,其他option參數雙向綁定的核心原理是一致的。

function initData (vm: Component) {

/得到data數據/ let data = vm.$options.data data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {}

/判斷是否是對象/ if (!isPlainObject(data)) { data = {} process.env.NODE_ENV !== 'production' && warn( 'data functions should return an object:\n' + '

// proxy data on instance /遍歷data對象/ const keys = Object.keys(data) const props = vm.$options.props let i = keys.length

//遍歷data中的數據 while (i--) { /保證data中的key不與props中的key重復,props優先,如果有沖突會產生warning/ if (props && hasOwn(props, keys[i])) { process.env.NODE_ENV !== 'production' && warn( The data property "${keys[i]}" is already declared as a prop. + Use prop default value instead., vm ) } else if (!isReserved(keys[i])) { /判斷是否是保留字段/

  /*這里是我們前面講過的代理,將data上面的屬性代理到了vm實例上*/
  proxy(vm, `_data`, keys[i])
}

} /Github:https://github.com/answershuto/ // observe data /從這里開始我們要observe了,開始對數據進行綁定,這里有尤大大的注釋asRootData,這步作為根數據,下面會進行遞歸observe進行對深層對象的綁定。/ observe(data, true / asRootData /) }</code></pre>

observe會通過defineReactive對data中的對象進行雙向綁定,最終通過Object.defineProperty對對象設置setter以及getter的方法。getter的方法主要用來進行依賴收集,對于依賴收集不了解的同學可以參考筆者的另一篇文章 《依賴收集》 。setter方法會在對象被修改的時候觸發(不存在添加屬性的情況,添加屬性請用Vue.set),這時候setter會通知閉包中的Dep,Dep中有一些訂閱了這個對象改變的Watcher觀察者對象,Dep會通知Watcher對象更新視圖。

如果是修改一個數組的成員,該成員是一個對象,那只需要遞歸對數組的成員進行雙向綁定即可。但這時候出現了一個問題,?如果我們進行pop、push等操作的時候,push進去的對象根本沒有進行過雙向綁定,更別說pop了,那么我們如何監聽數組的這些變化呢? Vue.js提供的方法是重寫push、pop、shift、unshift、splice、sort、reverse這七個 數組方法 。修改數組原型方法的代碼可以參考 observer/array.js

/*

  • not type checking this file because flow doesn't play well with
  • dynamically accessing methods on Array prototype */

import { def } from '../util/index'

/取得原生數組的原型/ const arrayProto = Array.prototype
/創建一個新的數組對象,修改該對象上的數組的七個方法,防止污染原生數組方法/ export const arrayMethods = Object.create(arrayProto)

/**

  • Intercept mutating methods and emit events / /這里重寫了數組的這些方法,在保證不污染原生數組原型的情況下重寫數組的這些方法,截獲數組的成員發生的變化,執行原生數組操作的同時dep通知關聯的所有觀察者進行響應式處理/ ;[ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ] .forEach(function (method) { // cache original method /將數組的原生方法緩存起來,后面要調用/ const original = arrayProto[method] def(arrayMethods, method, function mutator () { // avoid leaking arguments: // http://jsperf.com/closure-with-arguments let i = arguments.length const args = new Array(i) while (i--) { args[i] = arguments[i] } /調用原生的數組方法*/ const result = original.apply(this, args)

    /數組新插入的元素需要重新進行observe才能響應式/ const ob = this.ob let inserted switch (method) { case 'push':

     inserted = args
     break
    

    case 'unshift':

     inserted = args
     break
    

    case 'splice':

     inserted = args.slice(2)
     break
    

    } if (inserted) ob.observeArray(inserted)

    // notify change /dep通知所有注冊的觀察者進行響應式處理/ ob.dep.notify() return result }) })</code></pre>

    從數組的原型新建一個Object.create(arrayProto)對象,通過修改此原型可以保證原生數組方法不被污染。如果當前瀏覽器支持 proto 這個屬性的話就可以直接覆蓋該屬性則使數組對象具有了重寫后的數組方法。如果沒有該屬性的瀏覽器,則必須通過遍歷def所有需要重寫的數組方法,這種方法效率較低,所以優先使用第一種。 在保證不污染不覆蓋數組原生方法添加監聽,主要做了兩個操作,第一是通知所有注冊的觀察者進行響應式處理,第二是如果是添加成員的操作,需要對新成員進行observe。 但是修改了數組的原生方法以后我們還是沒法像原生數組一樣直接通過數組的下標或者設置length來修改數組,Vue.js提供了 $set()及$remove()方法

    對于更具體的講解數據雙向綁定以及Dep、Watcher的實現可以參考筆者的文章 《從源碼角度再看數據綁定》

    template編譯

    在$mount過程中,如果是獨立構建構建,則會在此過程中將template編譯成render function。當然,你也可以采用運行時構建。具體參考 運行時-編譯器-vs-只包含運行時

    template是如何被編譯成render function的呢?

    function baseCompile (  
    template: string,
    options: CompilerOptions
    ): CompiledResult {
    /*parse解析得到ast樹*/
    const ast = parse(template.trim(), options)
    /*
     將AST樹進行優化
     優化的目標:生成模板AST樹,檢測不需要進行DOM改變的靜態子樹。
     一旦檢測到這些靜態樹,我們就能做以下這些事情:
     1.把它們變成常數,這樣我們就再也不需要每次重新渲染時創建新的節點了。
     2.在patch的過程中直接跳過。
    */
    optimize(ast, options)
    /*根據ast樹生成所需的code(內部包含render與staticRenderFns)*/
    const code = generate(ast, options)
    return {
     ast,
     render: code.render,
     staticRenderFns: code.staticRenderFns
    }
    }

    baseCompile首先會將模板template進行parse得到一個AST語法樹,再通過optimize做一些優化,最后通過generate得到render以及staticRenderFns。

    parse

    parse的源碼可以參見 https://github.com/answershuto/learnVue/blob/master/vue-src/compiler/parser/index.js#L53

    parse會用正則等方式解析template模板中的指令、class、style等數據,形成AST語法樹。

    optimize

    optimize的主要作用是標記static靜態節點,這是Vue在編譯過程中的一處優化,后面當update更新界面時,會有一個patch的過程,diff算法會直接跳過靜態節點,從而減少了比較的過程,優化了patch的性能。

    generate

    generate是將AST語法樹轉化成render funtion字符串的過程,得到結果是render的字符串以及staticRenderFns字符串。

    具體的template編譯實現請參考 《聊聊Vue.js的template編譯》

    Watcher到視圖

    Watcher對象會通過調用updateComponent方法來達到更新視圖的目的。這里提一下,其實Watcher并不是實時更新視圖的,Vue.js默認會將Watcher對象存在一個隊列中,在下一個tick時更新異步更新視圖,完成了性能優化。關于nextTick感興趣的小伙伴可以參考 《Vue.js異步更新DOM策略及nextTick》

    updateComponent = () => {  
     vm._update(vm._render(), hydrating)
    }

    updateComponent就執行一句話, render函數會返回一個新的Vnode節點,傳入 update中與舊的VNode對象進行對比,經過一個patch的過程得到兩個VNode節點的差異,最后將這些差異渲染到真實環境形成視圖。

    什么是VNode?

    VNode

    在刀耕火種的年代,我們需要在各個事件方法中直接操作DOM來達到修改視圖的目的。但是當應用一大就會變得難以維護。

    那我們是不是可以把真實DOM樹抽象成一棵以JavaScript對象構成的抽象樹,在修改抽象樹數據后將抽象樹轉化成真實DOM重繪到頁面上呢?于是虛擬DOM出現了,它是真實DOM的一層抽象,用屬性描述真實DOM的各個特性。當它發生變化的時候,就會去修改視圖。

    但是這樣的JavaScript操作DOM進行重繪整個視圖層是相當消耗性能的,我們是不是可以每次只更新它的修改呢?所以Vue.js將DOM抽象成一個以JavaScript對象為節點的虛擬DOM樹,以VNode節點模擬真實DOM,可以對這顆抽象樹進行創建節點、刪除節點以及修改節點等操作,在這過程中都不需要操作真實DOM,只需要操作JavaScript對象,大大提升了性能。修改以后經過diff算法得出一些需要修改的最小單位,再將這些小單位的視圖進行更新。這樣做減少了很多不需要的DOM操作,大大提高了性能。

    Vue就使用了這樣的抽象節點VNode,它是對真實DOM的一層抽象,而不依賴某個平臺,它可以是瀏覽器平臺,也可以是weex,甚至是node平臺也可以對這樣一棵抽象DOM樹進行創建刪除修改等操作,這也為前后端同構提供了可能。

    先來看一下Vue.js源碼中對VNode類的定義。

    export default class VNode {
    tag: string | void; data: VNodeData | void; children: ?Array<VNode>; text: string | void; elm: Node | void; ns: string | void; context: Component | void; // rendered in this component's scope functionalContext: Component | void; // only for functional component root nodes key: string | number | void; componentOptions: VNodeComponentOptions | void; componentInstance: Component | void; // component instance parent: VNode | void; // component placeholder node raw: boolean; // contains raw HTML? (server only) isStatic: boolean; // hoisted static node isRootInsert: boolean; // necessary for enter transition check isComment: boolean; // empty comment placeholder? isCloned: boolean; // is a cloned node? isOnce: boolean; // is a v-once node?

    constructor ( tag?: string, data?: VNodeData, children?: ?Array<VNode>, text?: string, elm?: Node, context?: Component, componentOptions?: VNodeComponentOptions ) { /當前節點的標簽名/ this.tag = tag /當前節點對應的對象,包含了具體的一些數據信息,是一個VNodeData類型,可以參考VNodeData類型中的數據信息/ this.data = data /當前節點的子節點,是一個數組/ this.children = children /當前節點的文本/ this.text = text /當前虛擬節點對應的真實dom節點/ this.elm = elm /當前節點的名字空間/ this.ns = undefined /編譯作用域/ this.context = context /函數化組件作用域/ this.functionalContext = undefined /節點的key屬性,被當作節點的標志,用以優化/ this.key = data && data.key /組件的option選項/ this.componentOptions = componentOptions /當前節點對應的組件的實例/ this.componentInstance = undefined /當前節點的父節點/ this.parent = undefined /簡而言之就是是否為原生HTML或只是普通文本,innerHTML的時候為true,textContent的時候為false/ this.raw = false /靜態節點標志/ this.isStatic = false /是否作為跟節點插入/ this.isRootInsert = true /是否為注釋節點/ this.isComment = false /是否為克隆節點/ this.isCloned = false /是否有v-once指令/ this.isOnce = false }

    // DEPRECATED: alias for componentInstance for backwards compat. / istanbul ignore next / get child (): Component | void { return this.componentInstance } }</code></pre>

    這是一個最基礎的VNode節點,作為其他派生VNode類的基類,里面定義了下面這些數據。

    tag: 當前節點的標簽名

    data: 當前節點對應的對象,包含了具體的一些數據信息,是一個VNodeData類型,可以參考VNodeData類型中的數據信息

    children: 當前節點的子節點,是一個數組

    text: 當前節點的文本

    elm: 當前虛擬節點對應的真實dom節點

    ns: 當前節點的名字空間

    context: 當前節點的編譯作用域

    functionalContext: 函數化組件作用域

    key: 節點的key屬性,被當作節點的標志,用以優化

    componentOptions: 組件的option選項

    componentInstance: 當前節點對應的組件的實例

    parent: 當前節點的父節點

    raw: 簡而言之就是是否為原生HTML或只是普通文本,innerHTML的時候為true,textContent的時候為false

    isStatic: 是否為靜態節點

    isRootInsert: 是否作為跟節點插入

    isComment: 是否為注釋節點

    isCloned: 是否為克隆節點

    isOnce: 是否有v-once指令

    打個比方,比如說我現在有這么一個VNode樹

    {
     tag: 'div'
     data: {

     class: 'test'
    

    }, children: [

     {
         tag: 'span',
         data: {
             class: 'demo'
         }
         text: 'hello,VNode'
     }
    

    ] }</code></pre>

    渲染之后的結果就是這樣的

    <div class="test">  
     <span class="demo">hello,VNode</span>
    </div>

    更多操作VNode的方法,請參考 《VNode節點》

    patch

    最后_update會將新舊兩個VNode進行一次patch的過程,得出兩個VNode最小的差異,然后將這些差異渲染到視圖上。

    首先說一下patch的核心diff算法,diff算法是通過同層的樹節點進行比較而非對樹進行逐層搜索遍歷的方式,所以時間復雜度只有O(n),是一種相當高效的算法。

    這兩張圖代表舊的VNode與新VNode進行patch的過程,他們只是在同層級的VNode之間進行比較得到變化(第二張圖中相同顏色的方塊代表互相進行比較的VNode節點),然后修改變化的視圖,所以十分高效。

    在patch的過程中,如果兩個VNode被認為是同一個VNode(sameVnode),則會進行深度的比較,得出最小差異,否則直接刪除舊有DOM節點,創建新的DOM節點。

    什么是sameVnode?

    我們來看一下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) ) }

// Some browsers do not support dynamically changing type for <input> // so they need to be treated as different nodes / 判斷當標簽是<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 }</code></pre>

當兩個VNode的tag、key、isComment都相同,并且同時定義或未定義data的時候,且如果標簽為input則type必須相同。這時候這兩個VNode則算sameVnode,可以直接進行patchVnode操作。

patchVnode的規則是這樣的:

1.如果新舊VNode都是靜態的,同時它們的key相同(代表同一節點),并且新的VNode是clone或者是標記了once(標記v-once屬性,只渲染一次),那么只需要替換elm以及componentInstance即可。

2.新老節點均有children子節點,則對子節點進行diff操作,調用updateChildren,這個updateChildren也是diff的核心。

3.如果老節點沒有子節點而新節點存在子節點,先清空老節點DOM的文本內容,然后為當前DOM節點加入子節點。

4.當新節點沒有子節點而老節點有子節點的時候,則移除該DOM節點的所有子節點。

5.當新老節點都無子節點的時候,只是文本的替換。

updateChildren

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, elmToMove, 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

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)) {
    /*前四種情況其實是指定key的時候,判定為同一個VNode,則直接patchVnode即可,分別比較oldCh以及newCh的兩頭節點2*2=4種情況*/
    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 {
    /*
      生成一個key與舊VNode的key對應的哈希表(只有第一次進來undefined的時候會生成,也為后面檢測重復的key值做鋪墊)
      比如childre是這樣的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}]  beginIdx = 0   endIdx = 2  
      結果生成{key0: 0, key1: 1, key2: 2}
    */
    if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
    /*如果newStartVnode新的VNode節點存在key并且這個key在oldVnode中能找到則返回這個節點的idxInOld(即第幾個節點,下標)*/
    idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null
    if (isUndef(idxInOld)) { // New element
      /*newStartVnode沒有key或者是該key沒有在老節點中找到則創建一個新的節點*/
      createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
      newStartVnode = newCh[++newStartIdx]
    } else {
      /*獲取同key的老節點*/
      elmToMove = oldCh[idxInOld]
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && !elmToMove) {
        /*如果elmToMove不存在說明之前已經有新節點放入過這個key的DOM中,提示可能存在重復的key,確保v-for的時候item有唯一的key值*/
        warn(
          'It seems there are duplicate keys that is causing an update error. ' +
          'Make sure each v-for item has a unique key.'
        )
      }
      if (sameVnode(elmToMove, newStartVnode)) {
        /*Github:https://github.com/answershuto*/
        /*如果新VNode與得到的有相同key的節點是同一個VNode則進行patchVnode*/
        patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
        /*因為已經patchVnode進去了,所以將這個老節點賦值undefined,之后如果還有新節點與該節點key相同可以檢測出來提示已有重復的key*/
        oldCh[idxInOld] = undefined
        /*當有標識位canMove實可以直接插入oldStartVnode對應的真實DOM節點前面*/
        canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm)
        newStartVnode = newCh[++newStartIdx]
      } else {
        // same key but different element. treat as new element
        /*當新的VNode與找到的同樣key的VNode不是sameVNode的時候(比如說tag不一樣或者是有不一樣type的input標簽),創建一個新的節點*/
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
        newStartVnode = newCh[++newStartIdx]
      }
    }
  }
}
if (oldStartIdx > oldEndIdx) {
  /*全部比較完成以后,發現oldStartIdx > oldEndIdx的話,說明老節點已經遍歷完了,新節點比老節點多,所以這時候多出來的新節點需要一個一個創建出來加入到真實DOM中*/
  refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
  addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
  /*如果全部比較完成以后發現newStartIdx > newEndIdx,則說明新節點已經遍歷完了,老節點多余新節點,這個時候需要將多余的老節點從真實DOM中移除*/
  removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}

}</code></pre>

直接看源碼可能比較難以捋清其中的關系,我們通過圖來看一下。

首先,在新老兩個VNode節點的左右頭尾兩側都有一個變量標記,在遍歷過程中這幾個變量都會向中間靠攏。當oldStartIdx <= oldEndIdx或者newStartIdx <= newEndIdx時結束循環。

索引與VNode節點的對應關系: oldStartIdx => oldStartVnode

oldEndIdx => oldEndVnode

newStartIdx => newStartVnode

newEndIdx => newEndVnode

在遍歷中,如果存在key,并且滿足sameVnode,會將該DOM節點進行復用,否則則會創建一個新的DOM節點。

首先,oldStartVnode、oldEndVnode與newStartVnode、newEndVnode兩兩比較一共有2*2=4種比較方法。

當新老VNode節點的start或者end滿足sameVnode時,也就是sameVnode(oldStartVnode, newStartVnode)或者sameVnode(oldEndVnode, newEndVnode),直接將該VNode節點進行patchVnode即可。

如果oldStartVnode與newEndVnode滿足sameVnode,即sameVnode(oldStartVnode, newEndVnode)。

這時候說明oldStartVnode已經跑到了oldEndVnode后面去了,進行patchVnode的同時還需要將真實DOM節點移動到oldEndVnode的后面。

如果oldEndVnode與newStartVnode滿足sameVnode,即sameVnode(oldEndVnode, newStartVnode)。

這說明oldEndVnode跑到了oldStartVnode的前面,進行patchVnode的同時真實的DOM節點移動到了oldStartVnode的前面。

如果以上情況均不符合,則通過createKeyToOldIdx會得到一個oldKeyToIdx,里面存放了一個key為舊的VNode,value為對應index序列的哈希表。從這個哈希表中可以找到是否有與newStartVnode一致key的舊的VNode節點,如果同時滿足sameVnode,patchVnode的同時會將這個真實DOM(elmToMove)移動到oldStartVnode對應的真實DOM的前面。

當然也有可能newStartVnode在舊的VNode節點找不到一致的key,或者是即便key相同卻不是sameVnode,這個時候會調用createElm創建一個新的DOM節點。

到這里循環已經結束了,那么剩下我們還需要處理多余或者不夠的真實DOM節點。

1.當結束時oldStartIdx > oldEndIdx,這個時候老的VNode節點已經遍歷完了,但是新的節點還沒有。說明了新的VNode節點實際上比老的VNode節點多,也就是比真實DOM多,需要將剩下的(也就是新增的)VNode節點插入到真實DOM節點中去,此時調用addVnodes(批量調用createElm的接口將這些節點加入到真實DOM中去)。

2。同理,當newStartIdx > newEndIdx時,新的VNode節點已經遍歷完了,但是老的節點還有剩余,說明真實DOM節點多余了,需要從文檔中刪除,這時候調用removeVnodes將這些多余的真實DOM刪除。

更詳細的diff實現參考筆者的文章 VirtualDOM與diff(Vue.js實現)

映射到真實DOM

由于Vue使用了虛擬DOM,所以虛擬DOM可以在任何支持JavaScript語言的平臺上操作,譬如說目前Vue支持的瀏覽器平臺或是weex,在虛擬DOM的實現上是一致的。那么最后虛擬DOM如何映射到真實的DOM節點上呢?

Vue為平臺做了一層適配層,瀏覽器平臺見 /platforms/web/runtime/node-ops.js 以及weex平臺見 /platforms/weex/runtime/node-ops.js 。不同平臺之間通過適配層對外提供相同的接口,虛擬DOM進行操作真實DOM節點的時候,只需要調用這些適配層的接口即可,而內部實現則不需要關心,它會根據平臺的改變而改變。

現在又出現了一個問題,我們只是將虛擬DOM映射成了真實的DOM。那如何給這些DOM加入attr、class、style等DOM屬性呢?

這要依賴于虛擬DOM的生命鉤子。虛擬DOM提供了如下的鉤子函數,分別在不同的時期會進行調用。

const hooks = ['create', 'activate', 'update', 'remove', 'destroy']

/構建cbs回調函數,web平臺上見/platforms/web/runtime/modules/ 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]]) } } }</code></pre>

同理,也會根據不同平臺有自己不同的實現,我們這里以Web平臺為例。Web平臺的鉤子函數見 /platforms/web/runtime/modules 。里面有對attr、class、props、events、style以及transition(過渡狀態)的DOM屬性進行操作。

以attr為例,代碼很簡單。

/ @flow /

import { isIE9 } from 'core/util/env'

import {
extend, isDef, isUndef } from 'shared/util'

import {
isXlink, xlinkNS, getXlinkProp, isBooleanAttr, isEnumeratedAttr, isFalsyAttrValue } from 'web/util/index'

/更新attr/ function updateAttrs (oldVnode: VNodeWithData, vnode: VNodeWithData) {
/如果舊的以及新的VNode節點均沒有attr屬性,則直接返回/ if (isUndef(oldVnode.data.attrs) && isUndef(vnode.data.attrs)) { return } let key, cur, old /VNode節點對應的Dom實例/ const elm = vnode.elm /舊VNode節點的attr/ const oldAttrs = oldVnode.data.attrs || {} /新VNode節點的attr/ let attrs: any = vnode.data.attrs || {} // clone observed objects, as the user probably wants to mutate it /如果新的VNode的attr已經有ob(代表已經被Observe處理過了), 進行深拷貝/ if (isDef(attrs.ob)) { attrs = vnode.data.attrs = extend({}, attrs) }

/遍歷attr,不一致則替換/ for (key in attrs) { cur = attrs[key] old = oldAttrs[key] if (old !== cur) { setAttr(elm, key, cur) } } // #4391: in IE9, setting type can reset value for input[type=radio] / istanbul ignore if / if (isIE9 && attrs.value !== oldAttrs.value) { setAttr(elm, 'value', attrs.value) } for (key in oldAttrs) { if (isUndef(attrs[key])) { if (isXlink(key)) { elm.removeAttributeNS(xlinkNS, getXlinkProp(key)) } else if (!isEnumeratedAttr(key)) { elm.removeAttribute(key) } } } }

/設置attr/ function setAttr (el: Element, key: string, value: any) {
if (isBooleanAttr(key)) { // set attribute for blank value // e.g. <option disabled>Select one</option> if (isFalsyAttrValue(value)) { el.removeAttribute(key) } else { el.setAttribute(key, key) } } else if (isEnumeratedAttr(key)) { el.setAttribute(key, isFalsyAttrValue(value) || value === 'false' ? 'false' : 'true') } else if (isXlink(key)) { if (isFalsyAttrValue(value)) { el.removeAttributeNS(xlinkNS, getXlinkProp(key)) } else { el.setAttributeNS(xlinkNS, key, value) } } else { if (isFalsyAttrValue(value)) { el.removeAttribute(key) } else { el.setAttribute(key, value) } } }

export default {
create: updateAttrs, update: updateAttrs }</code></pre>

attr只需要在create以及update鉤子被調用時更新DOM的attr屬性即可。

最后

至此,我們已經從template到真實DOM的整個過程梳理完了。現在再去看這張圖,是不是更清晰了呢?

 

來自: https://github.com/answershuto/learnVue

 

 本文由用戶 SauMarko 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!