從template到DOM(Vue.js原始碼角度看內部執行機制)

染陌同學發表於2019-03-04

寫在前面

這篇文章算是對最近寫的一系列Vue.js原始碼的文章(github.com/answershuto…)的總結吧,在閱讀原始碼的過程中也確實受益匪淺,希望自己的這些產出也會對同樣想要學習Vue.js原始碼的小夥伴有所幫助。之前這篇文章同樣在我司(大搜車)的技術部落格中發表過,歡迎大家關注我司的技術部落格,給個傳送門blog.souche.com/

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

文章的原地址:github.com/answershuto…

在學習過程中,為Vue加上了中文的註釋github.com/answershuto…,希望可以對其他想學習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)
    }
  }複製程式碼

_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' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }

  // 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 */)
}複製程式碼

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以及observer/index.js

export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that has this object as root $data

  constructor (value: any) {
    //.......

    if (Array.isArray(value)) {
      /*
          如果是陣列,將修改後可以截獲響應的陣列方法替換掉該陣列的原型中的原生方法,達到監聽陣列資料變化響應的效果。
          這裡如果當前瀏覽器支援__proto__屬性,則直接覆蓋當前陣列物件原型上的原生陣列方法,如果不支援該屬性,則直接覆蓋陣列物件的原型。
      */
      const augment = hasProto
        ? protoAugment  /*直接覆蓋原型的方法來修改目標物件*/
        : copyAugment   /*定義(覆蓋)目標物件或陣列的某一個方法*/
      augment(value, arrayMethods, arrayKeys)

      /*如果是陣列則需要遍歷陣列的每一個成員進行observe*/
      this.observeArray(value)
    } else {
      /*如果是物件則直接walk進行繫結*/
      this.walk(value)
    }
  }
}

/**
 * Augment an target Object or Array by intercepting
 * the prototype chain using __proto__
 */
 /*直接覆蓋原型的方法來修改目標物件或陣列*/
function protoAugment (target, src: Object) {
  /* eslint-disable no-proto */
  target.__proto__ = src
  /* eslint-enable no-proto */
}

/**
 * Augment an target Object or Array by defining
 * hidden properties.
 */
/* istanbul ignore next */
/*定義(覆蓋)目標物件或陣列的某一個方法*/
function copyAugment (target: Object, src: Object, keys: Array<string>) {
  for (let i = 0, l = keys.length; i < l; i++) {
    const key = keys[i]
    def(target, key, src[key])
  }
}複製程式碼
/*
 * 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
  })
})複製程式碼

從陣列的原型新建一個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的原始碼可以參見github.com/answershuto…

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

這是一個最基礎的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'
        }
    ]
}複製程式碼

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

<div class="test">
    <span class="demo">hello,VNode</span>
</div>複製程式碼

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

patch

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

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

img
img

img
img

這兩張圖代表舊的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
}複製程式碼

當兩個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)
    }
  }複製程式碼

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

img
img

首先,在新老兩個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即可。

img
img

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

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

img
img

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

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

img
img

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

img
img

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

img
img

到這裡迴圈已經結束了,那麼剩下我們還需要處理多餘或者不夠的真實DOM節點。

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

img
img

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

img
img

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

對映到真實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]])
      }
    }
  }複製程式碼

同理,也會根據不同平臺有自己不同的實現,我們這裡以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
}複製程式碼

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

最後

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

關於

作者:染陌

Email:answershuto@gmail.com or answershuto@126.com

Github: github.com/answershuto

Blog:answershuto.github.io/

知乎主頁:www.zhihu.com/people/cao-…

知乎專欄:zhuanlan.zhihu.com/ranmo

掘金: juejin.im/user/58f87a…

osChina:my.oschina.net/u/3161824/b…

轉載請註明出處,謝謝。

歡迎關注我的公眾號

相關文章