Vue 源碼深入解析之 nextTick、檢測變化的注意事項、計算屬性和偵聽屬性、組件更新 和 Props

一、nextTick 的理解

  1. nextTickVue 的一個核心實現,在介紹 VuenextTick 之前,爲了方便大家理解,先簡單介紹一下 JS 的運行機制。

  2. JS 運行機制,JS 執行是單線程的,它是基於事件循環的。事件循環大致分爲以下幾個步驟:

  • 所有同步任務都在主線程上執行,形成一個執行棧(execution context stack)。
  • 主線程之外,還存在一個"任務隊列"(task queue)。只要異步任務有了運行結果,就在"任務隊列"之中放置一個事件。
  • 一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務隊列",看看裏面有哪些事件。那些對應的異步任務,於是結束等待狀態,進入執行棧,開始執行。
  • 主線程不斷重複上面的第三步。
  1. 主線程的執行過程就是一個 tick,而所有的異步結果都是通過 “任務隊列” 來調度。 消息隊列中存放的是一個個的任務(task)。 規範中規定 task 分爲兩大類,分別是 macro taskmicro task,並且每個 macro task 結束後,都要清空所有的 micro task

  2. 關於 macro taskmicro task 的概念,這裏不會細講,簡單通過一段代碼演示他們的執行順序:

for (macroTask of macroTaskQueue) {
    // 1. Handle current MACRO-TASK
    handleMacroTask();
      
    // 2. Handle all MICRO-TASK
    for (microTask of microTaskQueue) {
        handleMicroTask(microTask);
    }
}

在瀏覽器環境中,常見的 macro task 有 setTimeout、MessageChannel、postMessage、setImmediate;常見的 micro task 有 MutationObsever 和 Promise.then。

  1. Vue 的實現,在 Vue 源碼 2.5+ 後,nextTick 的實現單獨有一個 JS 文件來維護它,它的源碼並不多,總共也就 100 多行。接下來我們來看一下它的實現,在 src/core/util/next-tick.js 中:
import { noop } from 'shared/util'
import { handleError } from './error'
import { isIOS, isNative } from './env'

const callbacks = []
let pending = false

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

// Here we have async deferring wrappers using both microtasks and (macro) tasks.
// In < 2.4 we used microtasks everywhere, but there are some scenarios where
// microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690) or even between bubbling of the same
// event (#6566). However, using (macro) tasks everywhere also has subtle problems
// when state is changed right before repaint (e.g. #6813, out-in transitions).
// Here we use microtask by default, but expose a way to force (macro) task when
// needed (e.g. in event handlers attached by v-on).
let microTimerFunc
let macroTimerFunc
let useMacroTask = false

// Determine (macro) task defer implementation.
// Technically setImmediate should be the ideal choice, but it's only available
// in IE. The only polyfill that consistently queues the callback after all DOM
// events triggered in the same loop is by using MessageChannel.
/* istanbul ignore if */
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// Determine microtask defer implementation.
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    // in problematic UIWebViews, Promise.then doesn't completely break, but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) setTimeout(noop)
  }
} else {
  // fallback to macro
  microTimerFunc = macroTimerFunc
}

/**
 * Wrap a function so that if any code inside triggers state change,
 * the changes are queued using a (macro) task instead of a microtask.
 */
export function withMacroTask (fn: Function): Function {
  return fn._withTask || (fn._withTask = function () {
    useMacroTask = true
    const res = fn.apply(null, arguments)
    useMacroTask = false
    return res
  })
}

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
  1. next-tick.js 申明瞭 microTimerFuncmacroTimerFunc 兩個變量,它們分別對應的是 micro task 的函數和 macro task 的函數。對於 macro task 的實現,優先檢測是否支持原生 setImmediate,這是一個高版本 IEEdge 才支持的特性,不支持的話再去檢測是否支持原生的 MessageChannel,如果也不支持的話就會降級爲 setTimeout 0;而對於 micro task 的實現,則檢測瀏覽器是否原生支持 Promise,不支持的話直接指向 macro task 的實現。

  2. next-tick.js 對外暴露了兩個函數,先來看 nextTick,這就是我們在上一節執行 nextTick(flushSchedulerQueue) 所用到的函數。它的邏輯也很簡單,把傳入的回調函數 cb 壓入 callbacks 數組,最後一次性地根據 useMacroTask 條件執行 macroTimerFunc 或者是 microTimerFunc,而它們都會在下一個 tick 執行 flushCallbacksflushCallbacks 的邏輯非常簡單,對 callbacks 遍歷,然後執行相應的回調函數。

  3. 這裏使用 callbacks 而不是直接在 nextTick 中執行回調函數的原因是保證在同一個 tick 內多次執行 nextTick,不會開啓多個異步任務,而把這些異步任務都壓成一個同步任務,在下一個 tick 執行完畢。

  4. nextTick 函數最後還有一段邏輯,如下所示:

 if (!cb && typeof Promise !== 'undefined') {
  return new Promise(resolve => {
    _resolve = resolve
  })
}

這是當 nextTick 不傳 cb 參數的時候,提供一個 Promise 化的調用,比如:

nextTick().then(() => {})

_resolve 函數執行,就會跳到 then 的邏輯中。

  1. next-tick.js 還對外暴露了 withMacroTask 函數,它是對函數做一層包裝,確保函數執行過程中對數據任意的修改,觸發變化執行 nextTick 的時候強制走 macroTimerFunc。比如對於一些 DOM 交互事件,如 v-on 綁定的事件回調函數的處理,會強制走 macro task

  2. 總結:通過對 nextTick 的分析,並結合 setter 分析,我們瞭解到數據的變化到 DOM 的重新渲染是一個異步過程,發生在下一個 tick。這就是我們平時在開發的過程中,比如從服務端接口去獲取數據的時候,數據做了修改,如果我們的某些方法去依賴了數據修改後的 DOM 變化,我們就必須在 nextTick 後執行。比如下面的僞代碼:

getData(res).then(()=>{
  this.xxx = res.data
  this.$nextTick(() => {
    // 這裏我們可以獲取變化後的 DOM
  })
})

Vue.js 提供了 2 種調用 nextTick 的方式,一種是全局 API Vue.nextTick,一種是實例上的方法 vm.$nextTick,無論我們使用哪一種,最後都是調用 next-tick.js 中實現的 nextTick 方法。

二、檢測變化的注意事項

  1. 對響應式數據對象以及它的 gettersetter 部分做了瞭解,但是對於一些特殊情況是需要注意的,接下來我們就從源碼的角度來看 Vue 是如何處理這些特殊情況的。

  2. 對象添加屬性,對於使用 Object.defineProperty 實現響應式的對象,當我們去給這個對象添加一個新的屬性的時候,是不能夠觸發它的 setter 的,比如:

var vm = new Vue({
  data:{
    a:1
  }
})
// vm.b 是非響應的
vm.b = 2

但是添加新屬性的場景我們在平時開發中會經常遇到,那麼 Vue 爲了解決這個問題,定義了一個全局 API Vue.set 方法,它在 src/core/global-api/index.js 中初始化:

Vue.set = set

這個 set 方法的定義在 src/core/observer/index.js 中:

/**
 * Set a property on an object. Adds the new property and
 * triggers change notification if the property doesn't
 * already exist.
 */
export function set (target: Array<any> | Object, key: any, val: any): any {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  if (!ob) {
    target[key] = val
    return val
  }
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}
  1. set 方法接收三個參數,target 可能是數組或者是普通對象,key 代表的是數組的下標或者是對象的鍵值,val 代表添加的值。首先判斷如果 target 是數組且 key 是一個合法的下標,則之前通過 splice 去添加進數組然後返回,這裏的 splice 其實已經不僅僅是原生數組的 splice 了,後面詳細介紹數組的邏輯。接着又判斷 key 已經存在於 target 中,則直接賦值返回,因爲這樣的變化是可以觀測到了。接着再獲取到 target.__ob__ 並賦值給 ob,之前分析過它是在 Observer 的構造函數執行的時候初始化的,表示 Observer 的一個實例,如果它不存在,則說明 target 不是一個響應式的對象,則直接賦值並返回。最後通過 defineReactive(ob.value, key, val) 把新添加的屬性變成響應式對象,然後再通過 ob.dep.notify() 手動的觸發依賴通知,還記得我們在給對象添加 getter 的時候有這麼一段邏輯:
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // ...
  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    // ...
  })
}
  1. getter 過程中判斷了 childOb,並調用了 childOb.dep.depend() 收集了依賴,這就是爲什麼執行 Vue.set 的時候通過 ob.dep.notify() 能夠通知到 watcher,從而讓添加新的屬性到對象也可以檢測到變化。這裏如果 value 是個數組,那麼就通過 dependArray 把數組每個元素也去做依賴收集。

  2. 數組,Vue 也是不能檢測到以下變動的數組,如下所示:

  • 當你利用索引直接設置一個項時,例如:vm.items[indexOfItem] = newValue
  • 當你修改數組的長度時,例如:vm.items.length = newLength
  • 對於第一種情況,可以使用:Vue.set(example1.items, indexOfItem, newValue);而對於第二種情況,可以使用 vm.items.splice(newLength)
  1. 對於 Vue.set 的實現,當 target 是數組的時候,也是通過 target.splice(key, 1, val) 來添加的,那麼這裏的 splice 到底有什麼辦法能讓添加的對象變成響應式的呢。其實之前我們也分析過,在通過 observe 方法去觀察對象的時候會實例化 Observer,在它的構造函數中是專門對數組做了處理,它的定義在 src/core/observer/index.js 中,如下所示:
export class Observer {
  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)
    } else {
      // ...
    }
  }
}
  1. 這裏我們只需要關注 valueArray 的情況,首先獲取 augment,這裏的 hasProto 實際上就是判斷對象中是否存在 __proto__,如果存在則 augment 指向 protoAugment, 否則指向 copyAugment,來看一下這兩個函數的定義:
/**
 * Augment an target Object or Array by intercepting
 * the prototype chain using __proto__
 */
function protoAugment (target, src: Object, keys: any) {
  /* 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])
  }
}
  1. protoAugment 方法是直接把 target.__proto__ 原型直接修改爲 src,而 copyAugment 方法是遍歷 keys,通過 def,也就是 Object.defineProperty 去定義它自身的屬性值。對於大部分現代瀏覽器都會走到 protoAugment,那麼它實際上就把 value 的原型指向了 arrayMethodsarrayMethods 的定義在 src/core/observer/array.js 中:
import { def } from '../util/index'

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    return result
  })
})
  1. 可以看到,arrayMethods 首先繼承了 Array,然後對數組中所有能改變數組自身的方法,如 push、pop 等這些方法進行重寫。重寫後的方法會先執行它們本身原有的邏輯,並對能增加數組長度的三個方法 push、unshift、splice 方法做了判斷,獲取到插入的值,然後把新添加的值變成一個響應式對象,並且再調用 ob.dep.notify() 手動觸發依賴通知,這就很好地解釋了之前的示例中調用 vm.items.splice(newLength) 方法可以檢測到變化。

  2. 總結:對響應式對象又有了更全面的認識,如果在實際工作中遇到了這些特殊情況,我們就可以知道如何把它們也變成響應式的對象。其實對於對象屬性的刪除也會用同樣的問題,Vue 同樣提供了 Vue.del 的全局 API,它的實現和 Vue.set 大同小異,甚至還要更簡單一些。

三、計算屬性和偵聽屬性

  1. Vue 的組件對象支持了計算屬性 computed 和偵聽屬性 watch 兩個選項,但是不瞭解什麼時候該用 computed 什麼時候該用 watch。我們接下來從源碼實現的角度來分析它們兩者有什麼區別。

  2. computed,計算屬性的初始化是發生在 Vue 實例初始化階段的 initState 函數中,執行了 if (opts.computed) initComputed(vm, opts.computed)initComputed 的定義在 src/core/instance/state.js 中:

const computedWatcherOptions = { computed: true }
function initComputed (vm: Component, computed: Object) {
  // $flow-disable-line
  const watchers = vm._computedWatchers = Object.create(null)
  // computed properties are just getters during SSR
  const isSSR = isServerRendering()

  for (const key in computed) {
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    if (process.env.NODE_ENV !== 'production' && getter == null) {
      warn(
        `Getter is missing for computed property "${key}".`,
        vm
      )
    }

    if (!isSSR) {
      // create internal watcher for the computed property.
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }

    // component-defined computed properties are already defined on the
    // component prototype. We only need to define computed properties defined
    // at instantiation here.
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      if (key in vm.$data) {
        warn(`The computed property "${key}" is already defined in data.`, vm)
      } else if (vm.$options.props && key in vm.$options.props) {
        warn(`The computed property "${key}" is already defined as a prop.`, vm)
      }
    }
  }
}
  1. 函數首先創建 vm._computedWatchers 爲一個空對象,接着對 computed 對象做遍歷,拿到計算屬性的每一個 userDef,然後嘗試獲取這個 userDef 對應的 getter 函數,拿不到則在開發環境下報警告。接下來爲每一個 getter 創建一個 watcher,這個 watcher 和渲染 watcher 有一點很大的不同,它是一個 computed watcher,因爲 const computedWatcherOptions = { computed: true }computed watcher 和普通 watcher 的差別我稍後會介紹。最後對判斷如果 key 不是 vm 的屬性,則調用 defineComputed(vm, key, userDef),否則判斷計算屬性對於的 key 是否已經被 data 或者 prop 所佔用,如果是的話則在開發環境報相應的警告。

  2. 那麼接下來需要重點關注 defineComputed 的實現,如下所示:

export function defineComputed (
  target: any,
  key: string,
  userDef: Object | Function
) {
  const shouldCache = !isServerRendering()
  if (typeof userDef === 'function') {
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : userDef
    sharedPropertyDefinition.set = noop
  } else {
    sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : userDef.get
      : noop
    sharedPropertyDefinition.set = userDef.set
      ? userDef.set
      : noop
  }
  if (process.env.NODE_ENV !== 'production' &&
      sharedPropertyDefinition.set === noop) {
    sharedPropertyDefinition.set = function () {
      warn(
        `Computed property "${key}" was assigned to but it has no setter.`,
        this
      )
    }
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}
  1. 這段邏輯很簡單,其實就是利用 Object.defineProperty 給計算屬性對應的 key 值添加 gettersettersetter 通常是計算屬性是一個對象,並且擁有 set 方法的時候纔有,否則是一個空函數。在平時的開發場景中,計算屬性有 setter 的情況比較少,我們重點關注一下 getter 部分,緩存的配置也先忽略,最終 getter 對應的是 createComputedGetter(key) 的返回值,來看一下它的定義:
function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      watcher.depend()
      return watcher.evaluate()
    }
  }
}

createComputedGetter 返回一個函數 computedGetter,它就是計算屬性對應的 getter。

  1. 整個計算屬性的初始化過程到此結束,我們知道計算屬性是一個 computed watcher,它和普通的 watcher 有什麼區別呢,爲了更加直觀,接下來來我們來通過一個例子來分析 computed watcher 的實現,如下所示:
var vm = new Vue({
  data: {
    firstName: 'Foo',
    lastName: 'Bar'
  },
  computed: {
    fullName: function () {
      return this.firstName + ' ' + this.lastName
    }
  }
})

當初始化這個 computed watcher 實例的時候,構造函數部分邏輯稍有不同:

constructor (
  vm: Component,
  expOrFn: string | Function,
  cb: Function,
  options?: ?Object,
  isRenderWatcher?: boolean
) {
  // ...
  if (this.computed) {
    this.value = undefined
    this.dep = new Dep()
  } else {
    this.value = this.get()
  }
}  

可以發現 computed watcher 會並不會立刻求值,同時持有一個 dep 實例。然後當我們的 render 函數執行訪問到 this.fullName 的時候,就觸發了計算屬性的 getter,它會拿到計算屬性對應的 watcher,然後執行 watcher.depend(),來看一下它的定義:

/**
  * Depend on this watcher. Only for computed property watchers.
  */
depend () {
  if (this.dep && Dep.target) {
    this.dep.depend()
  }
}

注意,這時候的 Dep.target 是渲染 watcher,所以 this.dep.depend() 相當於渲染 watcher 訂閱了這個 computed watcher 的變化。然後再執行 watcher.evaluate() 去求值,來看一下它的定義:

/**
  * Evaluate and return the value of the watcher.
  * This only gets called for computed property watchers.
  */
evaluate () {
  if (this.dirty) {
    this.value = this.get()
    this.dirty = false
  }
  return this.value
}
  1. evaluate 的邏輯非常簡單,判斷 this.dirty,如果爲 true 則通過 this.get() 求值,然後把 this.dirty 設置爲 false。在求值過程中,會執行 value = this.getter.call(vm, vm),這實際上就是執行了計算屬性定義的 getter 函數,在我們這個例子就是執行了 return this.firstName + ' ' + this.lastName

  2. 這裏需要特別注意的是,由於 this.firstNamethis.lastName 都是響應式對象,這裏會觸發它們的 getter,根據我們之前的分析,它們會把自身持有的 dep 添加到當前正在計算的 watcher 中,這個時候 Dep.target 就是這個 computed watcher。最後通過 return this.value 拿到計算屬性對應的值。我們知道了計算屬性的求值過程,那麼接下來看一下它依賴的數據變化後的邏輯。

  3. 一旦我們對計算屬性依賴的數據做修改,則會觸發 setter 過程,通知所有訂閱它變化的 watcher 更新,執行 watcher.update() 方法,如下所示:

/* istanbul ignore else */
if (this.computed) {
  // A computed property watcher has two modes: lazy and activated.
  // It initializes as lazy by default, and only becomes activated when
  // it is depended on by at least one subscriber, which is typically
  // another computed property or a component's render function.
  if (this.dep.subs.length === 0) {
    // In lazy mode, we don't want to perform computations until necessary,
    // so we simply mark the watcher as dirty. The actual computation is
    // performed just-in-time in this.evaluate() when the computed property
    // is accessed.
    this.dirty = true
  } else {
    // In activated mode, we want to proactively perform the computation
    // but only notify our subscribers when the value has indeed changed.
    this.getAndInvoke(() => {
      this.dep.notify()
    })
  }
} else if (this.sync) {
  this.run()
} else {
  queueWatcher(this)
}
  1. 那麼對於計算屬性這樣的 computed watcher,它實際上是有兩種模式,lazyactive。如果 this.dep.subs.length === 0 成立,則說明沒有人去訂閱這個 computed watcher 的變化,僅僅把 this.dirty = true,只有當下次再訪問這個計算屬性的時候纔會重新求值。在我們的場景下,渲染 watcher 訂閱了這個 computed watcher 的變化,那麼它會執行:
this.getAndInvoke(() => {
  this.dep.notify()
})

getAndInvoke (cb: Function) {
  const value = this.get()
  if (
    value !== this.value ||
    // Deep watchers and watchers on Object/Arrays should fire even
    // when the value is the same, because the value may
    // have mutated.
    isObject(value) ||
    this.deep
  ) {
    // set new value
    const oldValue = this.value
    this.value = value
    this.dirty = false
    if (this.user) {
      try {
        cb.call(this.vm, value, oldValue)
      } catch (e) {
        handleError(e, this.vm, `callback for watcher "${this.expression}"`)
      }
    } else {
      cb.call(this.vm, value, oldValue)
    }
  }
}

  1. getAndInvoke 函數會重新計算,然後對比新舊值,如果變化了則執行回調函數,那麼這裏這個回調函數是 this.dep.notify(),在我們這個場景下就是觸發了渲染 watcher 重新渲染。

  2. 通過以上的分析,我們知道計算屬性本質上就是一個 computed watcher,也瞭解了它的創建過程和被訪問觸發 getter 以及依賴更新的過程,其實這是最新的計算屬性的實現,之所以這麼設計是因爲 Vue 想確保不僅僅是計算屬性依賴的值發生變化,而是當計算屬性最終計算的值發生變化纔會觸發渲染 watcher 重新渲染,本質上是一種優化。

  3. watch,偵聽屬性的初始化也是發生在 Vue 的實例初始化階段的 initState 函數中,在 computed 初始化之後,執行了:

if (opts.watch && opts.watch !== nativeWatch) {
  initWatch(vm, opts.watch)
}

來看一下 initWatch 的實現,它的定義在 src/core/instance/state.js 中:

function initWatch (vm: Component, watch: Object) {
  for (const key in watch) {
    const handler = watch[key]
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      createWatcher(vm, key, handler)
    }
  }
}

這裏就是對 watch 對象做遍歷,拿到每一個 handler,因爲 Vue 是支持 watch 的同一個 key 對應多個 handler,所以如果 handler 是一個數組,則遍歷這個數組,調用 createWatcher 方法,否則直接調用 createWatcher

function createWatcher (
  vm: Component,
  expOrFn: string | Function,
  handler: any,
  options?: Object
) {
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  return vm.$watch(expOrFn, handler, options)
}

這裏的邏輯也很簡單,首先對 hanlder 的類型做判斷,拿到它最終的回調函數,最後調用 vm.$watch(keyOrFn, handler, options) 函數,$watch 是 Vue 原型上的方法,它是在執行 stateMixin 的時候定義的:

Vue.prototype.$watch = function (
  expOrFn: string | Function,
  cb: any,
  options?: Object
): Function {
  const vm: Component = this
  if (isPlainObject(cb)) {
    return createWatcher(vm, expOrFn, cb, options)
  }
  options = options || {}
  options.user = true
  const watcher = new Watcher(vm, expOrFn, cb, options)
  if (options.immediate) {
    cb.call(vm, watcher.value)
  }
  return function unwatchFn () {
    watcher.teardown()
  }
}
  1. 偵聽屬性 watch 最終會調用 $watch 方法,這個方法首先判斷 cb 如果是一個對象,則調用 createWatcher 方法,這是因爲 $watch 方法是用戶可以直接調用的,它可以傳遞一個對象,也可以傳遞函數。接着執行 const watcher = new Watcher(vm, expOrFn, cb, options) 實例化了一個 watcher,這裏需要注意一點這是一個 user watcher,因爲 options.user = true。通過實例化 watcher 的方式,一旦我們 watch 的數據發送變化,它最終會執行 watcherrun 方法,執行回調函數 cb,並且如果我們設置了 immediatetrue,則直接會執行回調函數 cb。最後返回了一個 unwatchFn 方法,它會調用 teardown 方法去移除這個 watcher。所以本質上偵聽屬性也是基於 Watcher 實現的,它是一個 user watcher。其實 Watcher 支持了不同的類型,下面我們看下它有哪些類型以及它們的作用。

  2. Watcher optionsWatcher 的構造函數對 options 做的了處理,代碼如下:

if (options) {
  this.deep = !!options.deep
  this.user = !!options.user
  this.computed = !!options.computed
  this.sync = !!options.sync
  // ...
} else {
  this.deep = this.user = this.computed = this.sync = false
}

所以 watcher 總共有 4 種類型,我們來一一分析它們,看看不同的類型執行的邏輯有哪些差別。

  1. deep watcher,通常,如果我們想對一下對象做深度觀測的時候,需要設置這個屬性爲 true,考慮到這種情況:
var vm = new Vue({
  data() {
    a: {
      b: 1
    }
  },
  watch: {
    a: {
      handler(newVal) {
        console.log(newVal)
      }
    }
  }
})
vm.a.b = 2

這個時候是不會 log 任何數據的,因爲我們是 watch 了 a 對象,只觸發了 a 的 getter,並沒有觸發 a.b 的 getter,所以並沒有訂閱它的變化,導致我們對 vm.a.b = 2 賦值的時候,雖然觸發了 setter,但沒有可通知的對象,所以也並不會觸發 watch 的回調函數了。而我們只需要對代碼做稍稍修改,就可以觀測到這個變化了,如下所示:

watch: {
  a: {
    deep: true,
    handler(newVal) {
      console.log(newVal)
    }
  }
}
  1. 這樣就創建了一個 deep watcher 了,在 watcher 執行 get 求值的過程中有一段邏輯:
get() {
  let value = this.getter.call(vm, vm)
  // ...
  if (this.deep) {
    traverse(value)
  }
}

在對 watch 的表達式或者函數求值後,會調用 traverse 函數,它的定義在 src/core/observer/traverse.js 中:

import { _Set as Set, isObject } from '../util/index'
import type { SimpleSet } from '../util/index'
import VNode from '../vdom/vnode'

const seenObjects = new Set()

/**
 * Recursively traverse an object to evoke all converted
 * getters, so that every nested property inside the object
 * is collected as a "deep" dependency.
 */
export function traverse (val: any) {
  _traverse(val, seenObjects)
  seenObjects.clear()
}

function _traverse (val: any, seen: SimpleSet) {
  let i, keys
  const isA = Array.isArray(val)
  if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
    return
  }
  if (val.__ob__) {
    const depId = val.__ob__.dep.id
    if (seen.has(depId)) {
      return
    }
    seen.add(depId)
  }
  if (isA) {
    i = val.length
    while (i--) _traverse(val[i], seen)
  } else {
    keys = Object.keys(val)
    i = keys.length
    while (i--) _traverse(val[keys[i]], seen)
  }
}
  1. traverse 的邏輯也很簡單,它實際上就是對一個對象做深層遞歸遍歷,因爲遍歷過程中就是對一個子對象的訪問,會觸發它們的 getter 過程,這樣就可以收集到依賴,也就是訂閱它們變化的 watcher,這個函數實現還有一個小的優化,遍歷過程中會把子響應式對象通過它們的 dep id 記錄到 seenObjects,避免以後重複訪問。

  2. 那麼在執行了 traverse 後,我們再對 watch 的對象內部任何一個值做修改,也會調用 watcher 的回調函數了。對 deep watcher 的理解非常重要,今後工作中如果大家觀測了一個複雜對象,並且會改變對象內部深層某個值的時候也希望觸發回調,一定要設置 deeptrue,但是因爲設置了 deep 後會執行 traverse 函數,會有一定的性能開銷,所以一定要根據應用場景權衡是否要開啓這個配置。

  3. user watcher,通過 vm.$watch 創建的 watcher 是一個 user watcher,其實它的功能很簡單,在對 watcher 求值以及在執行回調函數的時候,會處理一下錯誤,如下:

get() {
  if (this.user) {
    handleError(e, vm, `getter for watcher "${this.expression}"`)
  } else {
    throw e
  }
},
getAndInvoke() {
  // ...
  if (this.user) {
    try {
      this.cb.call(this.vm, value, oldValue)
    } catch (e) {
      handleError(e, this.vm, `callback for watcher "${this.expression}"`)
    }
  } else {
    this.cb.call(this.vm, value, oldValue)
  }
}

handleError 在 Vue 中是一個錯誤捕獲並且暴露給用戶的一個利器。

  1. computed watchercomputed watcher 幾乎就是爲計算屬性量身定製的,我們剛纔已經對它做了詳細的分析,這裏不再贅述了。

  2. sync watcher,在我們之前對 setter 的分析過程知道,當響應式數據發送變化後,觸發了 watcher.update(),只是把這個 watcher 推送到一個隊列中,在 nextTick 後纔會真正執行 watcher 的回調函數。而一旦我們設置了 sync,就可以在當前 Tick 中同步執行 watcher 的回調函數,如下所示:

update () {
  if (this.computed) {
    // ...
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

只有當我們需要 watch 的值的變化到執行 watcher 的回調函數是一個同步過程的時候纔會去設置該屬性爲 true。

  1. 總結:對計算屬性和偵聽屬性的實現有了深入的瞭解,計算屬性本質上是 computed watcher,而偵聽屬性本質上是 user watcher。就應用場景而言,計算屬性適合用在模板渲染中,某個值是依賴了其它的響應式對象甚至是計算屬性計算而來;而偵聽屬性適用於觀測某個值的變化去完成一段複雜的業務邏輯。同時我們又瞭解了 watcher 的四個 options,通常我們會在創建 user watcher 的時候配置 deepsync,可以根據不同的場景做相應的配置。

四、組件更新

  1. 之前已經講了 Vue 的組件化實現過程,不過只有 Vue 組件的創建過程,並沒有涉及到組件數據發生變化,更新組件的過程。而現在對數據響應式原理的分析,瞭解到當數據發生變化的時候,會觸發渲染 watcher 的回調函數,進而執行組件的更新過程,接下來我們來詳細分析這一過程,如下所示:
updateComponent = () => {
  vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)
  1. 組件的更新還是調用了 vm._update 方法,我們再回顧一下這個方法,它的定義在 src/core/instance/lifecycle.js 中:
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  // ...
  const prevVnode = vm._vnode
  if (!prevVnode) {
     // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  // ...
}
  1. 組件更新的過程,會執行 vm.$el = vm.__patch__(prevVnode, vnode),它仍然會調用 patch 函數,在 src/core/vdom/patch.js 中定義:
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 {
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // patch existing root node
      patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
    } else {
      if (isRealElement) {
         // ...
      }

      // replacing existing element
      const oldElm = oldVnode.elm
      const parentElm = nodeOps.parentNode(oldElm)

      // create new node
      createElm(
        vnode,
        insertedVnodeQueue,
        // extremely rare edge case: do not insert if old element is in a
        // leaving transition. Only happens when combining transition +
        // keep-alive + HOCs. (#4590)
        oldElm._leaveCb ? null : parentElm,
        nodeOps.nextSibling(oldElm)
      )

      // update parent placeholder node element, recursively
      if (isDef(vnode.parent)) {
        let ancestor = vnode.parent
        const patchable = isPatchable(vnode)
        while (ancestor) {
          for (let i = 0; i < cbs.destroy.length; ++i) {
            cbs.destroy[i](ancestor)
          }
          ancestor.elm = vnode.elm
          if (patchable) {
            for (let i = 0; i < cbs.create.length; ++i) {
              cbs.create[i](emptyNode, ancestor)
            }
            // #6513
            // invoke insert hooks that may have been merged by create hooks.
            // e.g. for directives that uses the "inserted" hook.
            const insert = ancestor.data.hook.insert
            if (insert.merged) {
              // start at index 1 to avoid re-invoking component mounted hook
              for (let i = 1; i < insert.fns.length; i++) {
                insert.fns[i]()
              }
            }
          } else {
            registerRef(ancestor)
          }
          ancestor = ancestor.parent
        }
      }

      // destroy old node
      if (isDef(parentElm)) {
        removeVnodes(parentElm, [oldVnode], 0, 0)
      } else if (isDef(oldVnode.tag)) {
        invokeDestroyHook(oldVnode)
      }
    }
  }

  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}
  1. 這裏執行 patch 的邏輯和首次渲染是不一樣的,因爲 oldVnode 不爲空,並且它和 vnode 都是 VNode 類型,接下來會通過 sameVNode(oldVnode, vnode) 判斷它們是否是相同的 VNode 來決定走不同的更新邏輯:
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)
      )
    )
  )
}
  1. sameVnode 的邏輯非常簡單,如果兩個 vnodekey 不相等,則是不同的;否則繼續判斷對於同步組件,則判斷 isCommentdatainput 類型等是否相同,對於異步組件,則判斷 asyncFactory 是否相同。所以根據新舊 vnode 是否爲 sameVnode,會走到不同的更新邏輯,我們先來說一下不同的情況。

  2. 新舊節點不同,如果新舊 vnode 不同,那麼更新的邏輯非常簡單,它本質上是要替換已存在的節點,大致分爲三步,如下所示:

  • 創建新節點,如下所示:
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// create new node
createElm(
  vnode,
  insertedVnodeQueue,
  // extremely rare edge case: do not insert if old element is in a
  // leaving transition. Only happens when combining  transition +
  // keep-alive + HOCs. (#4590)
  oldElm._leaveCb ? null : parentElm,
  nodeOps.nextSibling(oldElm)
)

以當前舊節點爲參考節點,創建新的節點,並插入到 DOM 中,createElm 的邏輯我們之前分析過。

  • 更新父的佔位符節點,如下所示:
// update parent placeholder node element, recursively
if (isDef(vnode.parent)) {
  let ancestor = vnode.parent
  const patchable = isPatchable(vnode)
  while (ancestor) {
    for (let i = 0; i < cbs.destroy.length; ++i) {
      cbs.destroy[i](ancestor)
    }
    ancestor.elm = vnode.elm
    if (patchable) {
      for (let i = 0; i < cbs.create.length; ++i) {
        cbs.create[i](emptyNode, ancestor)
      }
      // #6513
      // invoke insert hooks that may have been merged by create hooks.
      // e.g. for directives that uses the "inserted" hook.
      const insert = ancestor.data.hook.insert
      if (insert.merged) {
        // start at index 1 to avoid re-invoking component mounted hook
        for (let i = 1; i < insert.fns.length; i++) {
          insert.fns[i]()
        }
      }
    } else {
      registerRef(ancestor)
    }
    ancestor = ancestor.parent
  }
}

我們只關注主要邏輯即可,找到當前 vnode 的父的佔位符節點,先執行各個 moduledestroy 的鉤子函數,如果當前佔位符是一個可掛載的節點,則執行 modulecreate 鉤子函數。

  • 刪除舊節點,如下所示:
// destroy old node
if (isDef(parentElm)) {
  removeVnodes(parentElm, [oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
  invokeDestroyHook(oldVnode)
}

oldVnode 從當前 DOM 樹中刪除,如果父節點存在,則執行 removeVnodes 方法:

function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (isDef(ch)) {
      if (isDef(ch.tag)) {
        removeAndInvokeRemoveHook(ch)
        invokeDestroyHook(ch)
      } else { // Text node
        removeNode(ch.elm)
      }
    }
  }
}

function removeAndInvokeRemoveHook (vnode, rm) {
  if (isDef(rm) || isDef(vnode.data)) {
    let i
    const listeners = cbs.remove.length + 1
    if (isDef(rm)) {
      // we have a recursively passed down rm callback
      // increase the listeners count
      rm.listeners += listeners
    } else {
      // directly removing
      rm = createRmCb(vnode.elm, listeners)
    }
    // recursively invoke hooks on child component root node
    if (isDef(i = vnode.componentInstance) && isDef(i = i._vnode) && isDef(i.data)) {
      removeAndInvokeRemoveHook(i, rm)
    }
    for (i = 0; i < cbs.remove.length; ++i) {
      cbs.remove[i](vnode, rm)
    }
    if (isDef(i = vnode.data.hook) && isDef(i = i.remove)) {
      i(vnode, rm)
    } else {
      rm()
    }
  } else {
    removeNode(vnode.elm)
  }
}

function invokeDestroyHook (vnode) {
  let i, j
  const data = vnode.data
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode)
    for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
  }
  if (isDef(i = vnode.children)) {
    for (j = 0; j < vnode.children.length; ++j) {
      invokeDestroyHook(vnode.children[j])
    }
  }
}
  1. 刪除節點邏輯很簡單,就是遍歷待刪除的 vnodes 做刪除,其中 removeAndInvokeRemoveHook 的作用是從 DOM 中移除節點並執行 moduleremove 鉤子函數,並對它的子節點遞歸調用 removeAndInvokeRemoveHook 函數;invokeDestroyHook 是執行 moduledestory 鉤子函數以及 vnodedestory 鉤子函數,並對它的子 vnode 遞歸調用 invokeDestroyHook 函數;removeNode 就是調用平臺的 DOM API 去把真正的 DOM 節點移除。

  2. 在之前組件生命週期的時候提到 beforeDestroy & destroyed 這兩個生命週期鉤子函數,它們就是在執行 invokeDestroyHook 過程中,執行了 vnodedestory 鉤子函數,它的定義在 src/core/vdom/create-component.js 中:

const componentVNodeHooks = {
  destroy (vnode: MountedComponentVNode) {
    const { componentInstance } = vnode
    if (!componentInstance._isDestroyed) {
      if (!vnode.data.keepAlive) {
        componentInstance.$destroy()
      } else {
        deactivateChildComponent(componentInstance, true /* direct */)
      }
    }
  }
}

當組件並不是 keepAlive 的時候,會執行 componentInstance.$destroy() 方法,然後就會執行 beforeDestroy & destroyed 兩個鉤子函數。

  1. 新舊節點相同,對於新舊節點不同的情況,這種創建新節點 -> 更新佔位符節點 -> 刪除舊節點的邏輯是很容易理解的。還有一種組件 vnode 的更新情況是新舊節點相同,它會調用 patchVNode 方法,它的定義在 src/core/vdom/patch.js 中:
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
  if (oldVnode === vnode) {
    return
  }

  const elm = vnode.elm = oldVnode.elm

  if (isTrue(oldVnode.isAsyncPlaceholder)) {
    if (isDef(vnode.asyncFactory.resolved)) {
      hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
    } else {
      vnode.isAsyncPlaceholder = true
    }
    return
  }

  // reuse element for static trees.
  // note we only do this if the vnode is cloned -
  // if the new node is not cloned it means the render functions have been
  // reset by the hot-reload-api and we need to do a proper re-render.
  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(oldVnode, vnode)
  }

  const oldCh = oldVnode.children
  const ch = vnode.children
  if (isDef(data) && isPatchable(vnode)) {
    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)
  }
  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)
  }
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
  }
}

patchVnode 的作用就是把新的 vnode patch 到舊的 vnode 上,這裏我們只關注關鍵的核心邏輯,我把它拆成四步驟:

  1. 執行 prepatch 鉤子函數,如下所示:
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
  i(oldVnode, vnode)
}

當更新的 vnode 是一個組件 vnode 的時候,會執行 prepatch 的方法,它的定義在 src/core/vdom/create-component.js 中:

const componentVNodeHooks = {
  prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
    const options = vnode.componentOptions
    const child = vnode.componentInstance = oldVnode.componentInstance
    updateChildComponent(
      child,
      options.propsData, // updated props
      options.listeners, // updated listeners
      vnode, // new parent vnode
      options.children // new children
    )
  }
}

prepatch 方法就是拿到新的 vnode 的組件配置以及組件實例,去執行 updateChildComponent 方法,它的定義在 src/core/instance/lifecycle.js 中:

export function updateChildComponent (
  vm: Component,
  propsData: ?Object,
  listeners: ?Object,
  parentVnode: MountedComponentVNode,
  renderChildren: ?Array<VNode>
) {
  if (process.env.NODE_ENV !== 'production') {
    isUpdatingChildComponent = true
  }

  // determine whether component has slot children
  // we need to do this before overwriting $options._renderChildren
  const hasChildren = !!(
    renderChildren ||               // has new static slots
    vm.$options._renderChildren ||  // has old static slots
    parentVnode.data.scopedSlots || // has new scoped slots
    vm.$scopedSlots !== emptyObject // has old scoped slots
  )

  vm.$options._parentVnode = parentVnode
  vm.$vnode = parentVnode // update vm's placeholder node without re-render

  if (vm._vnode) { // update child tree's parent
    vm._vnode.parent = parentVnode
  }
  vm.$options._renderChildren = renderChildren

  // update $attrs and $listeners hash
  // these are also reactive so they may trigger child update if the child
  // used them during render
  vm.$attrs = parentVnode.data.attrs || emptyObject
  vm.$listeners = listeners || emptyObject

  // update props
  if (propsData && vm.$options.props) {
    toggleObserving(false)
    const props = vm._props
    const propKeys = vm.$options._propKeys || []
    for (let i = 0; i < propKeys.length; i++) {
      const key = propKeys[i]
      const propOptions: any = vm.$options.props // wtf flow?
      props[key] = validateProp(key, propOptions, propsData, vm)
    }
    toggleObserving(true)
    // keep a copy of raw propsData
    vm.$options.propsData = propsData
  }

  // update listeners
  listeners = listeners || emptyObject
  const oldListeners = vm.$options._parentListeners
  vm.$options._parentListeners = listeners
  updateComponentListeners(vm, listeners, oldListeners)

  // resolve slots + force update if has children
  if (hasChildren) {
    vm.$slots = resolveSlots(renderChildren, parentVnode.context)
    vm.$forceUpdate()
  }

  if (process.env.NODE_ENV !== 'production') {
    isUpdatingChildComponent = false
  }
}
  1. updateChildComponent 的邏輯也非常簡單,由於更新了 vnode,那麼 vnode 對應的實例 vm 的一系列屬性也會發生變化,包括佔位符 vm.$vnode 的更新、slot 的更新,listeners 的更新,props 的更新等等。

  2. 執行 update 鉤子函數,如下所示:

if (isDef(data) && isPatchable(vnode)) {
  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)
}

回到 patchVNode 函數,在執行完新的 vnodeprepatch 鉤子函數,會執行所有 moduleupdate 鉤子函數以及用戶自定義 update 鉤子函數,對於 module 的鉤子函數,之後我們會有具體的章節針對一些具體的 case 分析。

  1. 完成 patch 過程,如下所示:
const oldCh = oldVnode.children
const ch = vnode.children
if (isDef(data) && isPatchable(vnode)) {
  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)
}
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)
}
  1. 如果 vnode 是個文本節點且新舊文本不相同,則直接替換文本內容。如果不是文本節點,則判斷它們的子節點,並分了幾種情況處理:
  • oldChch 都存在且不相同時,使用 updateChildren 函數來更新子節點。
  • 如果只有 ch 存在,表示舊節點不需要了。如果舊的節點是文本節點則先將節點的文本清除,然後通過 addVnodesch 批量插入到新節點 elm 下。
  • 如果只有 oldCh 存在,表示更新的是空節點,則需要將舊的節點通過 removeVnodes 全部清除。
  • 當只有舊節點是文本節點的時候,則清除其節點文本內容。
  1. 執行 postpatch 鉤子函數,如下所示:
if (isDef(data)) {
  if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}

再執行完 patch 過程後,會執行 postpatch 鉤子函數,它是組件自定義的鉤子函數,有則執行。那麼在整個 pathVnode 過程中,最複雜的就是 updateChildren 方法了。

  1. 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, 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]
    } 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)) { // 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 {
      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)
          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]
    }
  }
  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)
  }
}

updateChildren 的邏輯比較複雜,直接讀源碼比較晦澀,我們可以通過一個具體的示例來分析它。

<template>
  <div id="app">
    <div>
      <ul>
        <li v-for="item in items" :key="item.id">{{ item.val }}</li>
      </ul>
    </div>
    <button @click="change">change</button>
  </div>
</template>

<script>
  export default {
    name: 'App',
    data() {
      return {
        items: [
          {id: 0, val: 'A'},
          {id: 1, val: 'B'},
          {id: 2, val: 'C'},
          {id: 3, val: 'D'}
        ]
      }
    },
    methods: {
      change() {
        this.items.reverse().push({id: 4, val: 'E'})
      }
    }
  }
</script>
  1. 總結:組件更新的過程核心就是新舊 vnode diff,對新舊節點相同以及不同的情況分別做不同的處理。新舊節點不同的更新流程是創建新節點->更新父佔位符節點->刪除舊節點;而新舊節點相同的更新流程是去獲取它們的 children,根據不同情況做不同的更新邏輯。最複雜的情況是新舊節點相同且它們都存在子節點,那麼會執行 updateChildren 邏輯。

五、Props 的理解

  1. Props 作爲組件的核心特性之一,也是我們平時開發 Vue 項目中接觸最多的特性之一,它可以讓組件的功能變得豐富,也是父子組件通訊的一個渠道。那麼它的實現原理是怎樣的,我們來看一下。

  2. 規範化,在初始化 props 之前,首先會對 props 做一次 normalize,它發生在 mergeOptions 的時候,在 src/core/util/options.js 中:

export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {
  // ...
  normalizeProps(child, vm)
  // ...
}

function normalizeProps (options: Object, vm: ?Component) {
  const props = options.props
  if (!props) return
  const res = {}
  let i, val, name
  if (Array.isArray(props)) {
    i = props.length
    while (i--) {
      val = props[i]
      if (typeof val === 'string') {
        name = camelize(val)
        res[name] = { type: null }
      } else if (process.env.NODE_ENV !== 'production') {
        warn('props must be strings when using array syntax.')
      }
    }
  } else if (isPlainObject(props)) {
    for (const key in props) {
      val = props[key]
      name = camelize(key)
      res[name] = isPlainObject(val)
        ? val
        : { type: val }
    }
  } else if (process.env.NODE_ENV !== 'production') {
    warn(
      `Invalid value for option "props": expected an Array or an Object, ` +
      `but got ${toRawType(props)}.`,
      vm
    )
  }
  options.props = res
}

合併配置,它主要就是處理我們定義組件的對象 option,然後掛載到組件的實例 this.$options 中。

  1. 我們接下來重點看 normalizeProps 的實現,其實這個函數的主要目的就是把我們編寫的 props 轉成對象格式,因爲實際上 props 除了對象格式,還允許寫成數組格式,如下所示:
  • props 是一個數組,每一個數組元素 prop 只能是一個 string,表示 propkey,轉成駝峯格式,prop 的類型爲空。

  • props 是一個對象,對於 props 中每個 propkey,我們會轉駝峯格式,而它的 value,如果不是一個對象,我們就把它規範成一個對象。

  • 如果 props 既不是數組也不是對象,就拋出一個警告。

  1. 如下所示,舉個例子:
export default {
  props: ['name', 'nick-name']
}

經過 normalizeProps 後,會被規範成:

options.props = {
  name: { type: null },
  nickName: { type: null }
}
export default {
  props: {
    name: String,
    nickName: {
      type: Boolean
    }
  }
}

經過 normalizeProps 後,會被規範成:

options.props = {
  name: { type: String },
  nickName: { type: Boolean }
}

由於對象形式的 props 可以指定每個 prop 的類型和定義其它的一些屬性,推薦用對象形式定義 props

  1. 初始化,Props 的初始化主要發生在 new Vue 中的 initState 階段,在 src/core/instance/state.js 中:
export function initState (vm: Component) {
  // ....
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  // ...
}


function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  // cache prop keys so that future props updates can iterate using Array
  // instead of dynamic object key enumeration.
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent
  // root instance props should be converted
  if (!isRoot) {
    toggleObserving(false)
  }
  for (const key in propsOptions) {
    keys.push(key)
    const value = validateProp(key, propsOptions, propsData, vm)
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      const hyphenatedKey = hyphenate(key)
      if (isReservedAttribute(hyphenatedKey) ||
          config.isReservedAttr(hyphenatedKey)) {
        warn(
          `"${hyphenatedKey}" is a reserved attribute and cannot be used as component prop.`,
          vm
        )
      }
      defineReactive(props, key, value, () => {
        if (!isRoot && !isUpdatingChildComponent) {
          warn(
            `Avoid mutating a prop directly since the value will be ` +
            `overwritten whenever the parent component re-renders. ` +
            `Instead, use a data or computed property based on the prop's ` +
            `value. Prop being mutated: "${key}"`,
            vm
          )
        }
      })
    } else {
      defineReactive(props, key, value)
    }
    // static props are already proxied on the component's prototype
    // during Vue.extend(). We only need to proxy props defined at
    // instantiation here.
    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }
  toggleObserving(true)
}
  1. initProps 主要做三件事情:校驗、響應式和代理。

  2. 校驗,校驗的邏輯很簡單,遍歷 propsOptions,執行 validateProp(key, propsOptions, propsData, vm) 方法。這裏的 propsOptions 就是我們定義的 props 在規範後生成的 options.props 對象,propsData 是從父組件傳遞的 prop 數據。所謂校驗的目的就是檢查一下我們傳遞的數據是否滿足 prop的定義規範。再來看一下 validateProp 方法,它定義在 src/core/util/options.js 中:

export function validateProp (
  key: string,
  propOptions: Object,
  propsData: Object,
  vm?: Component
): any {
  const prop = propOptions[key]
  const absent = !hasOwn(propsData, key)
  let value = propsData[key]
  // boolean casting
  const booleanIndex = getTypeIndex(Boolean, prop.type)
  if (booleanIndex > -1) {
    if (absent && !hasOwn(prop, 'default')) {
      value = false
    } else if (value === '' || value === hyphenate(key)) {
      // only cast empty string / same name to boolean if
      // boolean has higher priority
      const stringIndex = getTypeIndex(String, prop.type)
      if (stringIndex < 0 || booleanIndex < stringIndex) {
        value = true
      }
    }
  }
  // check default value
  if (value === undefined) {
    value = getPropDefaultValue(vm, prop, key)
    // since the default value is a fresh copy,
    // make sure to observe it.
    const prevShouldObserve = shouldObserve
    toggleObserving(true)
    observe(value)
    toggleObserving(prevShouldObserve)
  }
  if (
    process.env.NODE_ENV !== 'production' &&
    // skip validation for weex recycle-list child component props
    !(__WEEX__ && isObject(value) && ('@binding' in value))
  ) {
    assertProp(prop, key, value, vm, absent)
  }
  return value
}
  1. validateProp 主要就做三件事情:處理 Boolean 類型的數據,處理默認數據,prop 斷言,並最終返回 prop 的值。

先來看 Boolean 類型數據的處理邏輯,如下所示:

const prop = propOptions[key]
const absent = !hasOwn(propsData, key)
let value = propsData[key]
// boolean casting
const booleanIndex = getTypeIndex(Boolean, prop.type)
if (booleanIndex > -1) {
  if (absent && !hasOwn(prop, 'default')) {
    value = false
  } else if (value === '' || value === hyphenate(key)) {
    // only cast empty string / same name to boolean if
    // boolean has higher priority
    const stringIndex = getTypeIndex(String, prop.type)
    if (stringIndex < 0 || booleanIndex < stringIndex) {
      value = true
    }
  }
}

先通過 const booleanIndex = getTypeIndex(Boolean, prop.type) 來判斷 prop 的定義是否是 Boolean 類型的,如下所示:

function getType (fn) {
  const match = fn && fn.toString().match(/^\s*function (\w+)/)
  return match ? match[1] : ''
}

function isSameType (a, b) {
  return getType(a) === getType(b)
}

function getTypeIndex (type, expectedTypes): number {
  if (!Array.isArray(expectedTypes)) {
    return isSameType(expectedTypes, type) ? 0 : -1
  }
  for (let i = 0, len = expectedTypes.length; i < len; i++) {
    if (isSameType(expectedTypes[i], type)) {
      return i
    }
  }
  return -1
}

getTypeIndex 函數就是找到 typeexpectedTypes 匹配的索引並返回。

  1. prop 類型定義的時候可以是某個原生構造函數,也可以是原生構造函數的數組,比如:
export default {
  props: {
    name: String,
    value: [String, Boolean]
  }
}
  1. 如果 expectedTypes 是單個構造函數,就執行 isSameType 去判斷是否是同一個類型;如果是數組,那麼就遍歷這個數組,找到第一個同類型的,返回它的索引。

  2. 回到 validateProp 函數,通過 const booleanIndex = getTypeIndex(Boolean, prop.type) 得到 booleanIndex,如果 prop.type 是一個 Boolean 類型,則通過 absent && !hasOwn(prop, 'default') 來判斷如果父組件沒有傳遞這個 prop 數據並且沒有設置 default 的情況,則 value 爲 false。

  3. 接着判斷value === '' || value === hyphenate(key) 的情況,如果滿足則先通過 const stringIndex = getTypeIndex(String, prop.type) 獲取匹配 String 類型的索引,然後判斷 stringIndex < 0 || booleanIndex < stringIndex 的值來決定 value 的值是否爲 true。這塊邏輯稍微有點繞,我們舉兩個例子來說明:

  4. 例如你定義一個組件 Student:

export default {
  name: String,
  nickName: [Boolean, String]
}

然後在父組件中引入這個組件:

<template>
  <div>
    <student name="Kate" nick-name></student>
  </div>
</template>

或者是:

<template>
  <div>
    <student name="Kate" nick-name="nick-name"></student>
  </div>
</template>

第一種情況沒有寫屬性的值,滿足 value === '',第二種滿足 value === hyphenate(key) 的情況,另外 nickName 這個 prop 的類型是 Boolean 或者是 String,並且滿足 booleanIndex < stringIndex,所以對 nickName 這個 propvaluetrue。接下來看一下默認數據處理邏輯:

// check default value
if (value === undefined) {
  value = getPropDefaultValue(vm, prop, key)
  // since the default value is a fresh copy,
  // make sure to observe it.
  const prevShouldObserve = shouldObserve
  toggleObserving(true)
  observe(value)
  toggleObserving(prevShouldObserve)
}
  1. value 的值爲 undefined 的時候,說明父組件根本就沒有傳這個 prop,那麼我們就需要通過 getPropDefaultValue(vm, prop, key) 獲取這個 prop 的默認值。我們這裏只關注 getPropDefaultValue 的實現,toggleObservingobserve 的作用我們之後會說,如下所示:
function getPropDefaultValue (vm: ?Component, prop: PropOptions, key: string): any {
  // no default, return undefined
  if (!hasOwn(prop, 'default')) {
    return undefined
  }
  const def = prop.default
  // warn against non-factory defaults for Object & Array
  if (process.env.NODE_ENV !== 'production' && isObject(def)) {
    warn(
      'Invalid default value for prop "' + key + '": ' +
      'Props with type Object/Array must use a factory function ' +
      'to return the default value.',
      vm
    )
  }
  // the raw prop value was also undefined from previous render,
  // return previous default value to avoid unnecessary watcher trigger
  if (vm && vm.$options.propsData &&
    vm.$options.propsData[key] === undefined &&
    vm._props[key] !== undefined
  ) {
    return vm._props[key]
  }
  // call factory function for non-Function types
  // a value is Function if its prototype is function even across different execution context
  return typeof def === 'function' && getType(prop.type) !== 'Function'
    ? def.call(vm)
    : def
}
  1. 檢測如果 prop 沒有定義 default 屬性,那麼返回 undefined,通過這塊邏輯我們知道除了 Boolean 類型的數據,其餘沒有設置 default 屬性的 prop 默認值都是 undefined

  2. 接着是開發環境下對 prop 的默認值是否爲對象或者數組類型的判斷,如果是的話會報警告,因爲對象和數組類型的 prop,他們的默認值必須要返回一個工廠函數。

  3. 接下來的判斷是如果上一次組件渲染父組件傳遞的 prop 的值是 undefined,則直接返回 上一次的默認值 vm._props[key],這樣可以避免觸發不必要的 watcher 的更新。

  4. 最後就是判斷 def 如果是工廠函數且 prop 的類型不是 Function 的時候,返回工廠函數的返回值,否則直接返回 def

  5. 至此,我們講完了 validateProp 函數的 Boolean 類型數據的處理邏輯和默認數據處理邏輯,最後來看一下 prop 斷言邏輯,如下所示:

if (
process.env.NODE_ENV !== 'production' &&
// skip validation for weex recycle-list child component props
!(__WEEX__ && isObject(value) && ('@binding' in value))
) {
  assertProp(prop, key, value, vm, absent)
}

在開發環境且非 weex 的某種環境下,執行 assertProp 做屬性斷言,如下所示:

function assertProp (
  prop: PropOptions,
  name: string,
  value: any,
  vm: ?Component,
  absent: boolean
) {
  if (prop.required && absent) {
    warn(
      'Missing required prop: "' + name + '"',
      vm
    )
    return
  }
  if (value == null && !prop.required) {
    return
  }
  let type = prop.type
  let valid = !type || type === true
  const expectedTypes = []
  if (type) {
    if (!Array.isArray(type)) {
      type = [type]
    }
    for (let i = 0; i < type.length && !valid; i++) {
      const assertedType = assertType(value, type[i])
      expectedTypes.push(assertedType.expectedType || '')
      valid = assertedType.valid
    }
  }

  if (!valid) {
    warn(
      getInvalidTypeMessage(name, value, expectedTypes),
      vm
    )
    return
  }
  const validator = prop.validator
  if (validator) {
    if (!validator(value)) {
      warn(
        'Invalid prop: custom validator check failed for prop "' + name + '".',
        vm
      )
    }
  }
}
  1. assertProp 函數的目的是斷言這個 prop 是否合法,如下所示:
  • 首先判斷如果 prop 定義了 required 屬性但父組件沒有傳遞這個 prop 數據的話會報一個警告。

  • 接着判斷如果 value 爲空且 prop 沒有定義 required 屬性則直接返回。

  • 然後再去對 prop 的類型做校驗,先是拿到 prop 中定義的類型 type,並嘗試把它轉成一個類型數組,然後依次遍歷這個數組,執行 assertType(value, type[i]) 去獲取斷言的結果,直到遍歷完成或者是 validtrue 的時候跳出循環:

const simpleCheckRE = /^(String|Number|Boolean|Function|Symbol)$/
function assertType (value: any, type: Function): {
  valid: boolean;
  expectedType: string;
} {
  let valid
  const expectedType = getType(type)
  if (simpleCheckRE.test(expectedType)) {
    const t = typeof value
    valid = t === expectedType.toLowerCase()
    // for primitive wrapper objects
    if (!valid && t === 'object') {
      valid = value instanceof type
    }
  } else if (expectedType === 'Object') {
    valid = isPlainObject(value)
  } else if (expectedType === 'Array') {
    valid = Array.isArray(value)
  } else {
    valid = value instanceof type
  }
  return {
    valid,
    expectedType
  }
}
  • assertType 的邏輯很簡單,先通過 getType(type) 獲取 prop 期望的類型 expectedType,然後再去根據幾種不同的情況對比 prop 的值 value 是否和 expectedType 匹配,最後返回匹配的結果。

  • 如果循環結束後 valid 仍然爲 false,那麼說明 prop 的值 valueprop 定義的類型都不匹配,那麼就會輸出一段通過 getInvalidTypeMessage(name, value, expectedTypes) 生成的警告信息,就不細說了。

  • 最後判斷當 prop 自己定義了 validator 自定義校驗器,則執行 validator 校驗器方法,如果校驗不通過則輸出警告信息。

  1. 響應式,回到 initProps 方法,當我們通過 const value = validateProp(key, propsOptions, propsData, vm)prop 做驗證並且獲取到 prop 的值後,接下來需要通過 defineReactiveprop 變成響應式。

  2. defineReactive 我們之前已經介紹過,這裏要注意的是,在開發環境中我們會校驗 propkey 是否是 HTML 的保留屬性,並且在 defineReactive 的時候會添加一個自定義 setter,當我們直接對 prop 賦值的時候會輸出警告:

if (process.env.NODE_ENV !== 'production') {
  const hyphenatedKey = hyphenate(key)
  if (isReservedAttribute(hyphenatedKey) ||
      config.isReservedAttr(hyphenatedKey)) {
    warn(
      `"${hyphenatedKey}" is a reserved attribute and cannot be used as component prop.`,
      vm
    )
  }
  defineReactive(props, key, value, () => {
    if (!isRoot && !isUpdatingChildComponent) {
      warn(
        `Avoid mutating a prop directly since the value will be ` +
        `overwritten whenever the parent component re-renders. ` +
        `Instead, use a data or computed property based on the prop's ` +
        `value. Prop being mutated: "${key}"`,
        vm
      )
    }
  })
} 

關於 prop 的響應式有一點不同的是當 vm 是非根實例的時候,會先執行 toggleObserving(false),它的目的是爲了響應式的優化。

  1. 代理,在經過響應式處理後,我們會把 prop 的值添加到 vm._props 中,比如 keynameprop,它的值保存在 vm._props.name 中,但是我們在組件中可以通過 this.name 訪問到這個 prop,這就是代理做的事情,如下所示:
// static props are already proxied on the component's prototype
// during Vue.extend(). We only need to proxy props defined at
// instantiation here.
if (!(key in vm)) {
  proxy(vm, `_props`, key)
}

通過 proxy 函數實現了上述需求。

const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}
  1. 當訪問 this.name 的時候就相當於訪問 this._props.name。其實對於非根實例的子組件而言,prop 的代理髮生在 Vue.extend 階段,在 src/core/global-api/extend.js 中:
Vue.extend = function (extendOptions: Object): Function {
  // ...
  const Sub = function VueComponent (options) {
    this._init(options)
  }
  // ...

  // For props and computed properties, we define the proxy getters on
  // the Vue instances at extension time, on the extended prototype. This
  // avoids Object.defineProperty calls for each instance created.
  if (Sub.options.props) {
    initProps(Sub)
  }
  if (Sub.options.computed) {
    initComputed(Sub)
  }

  // ...
  return Sub
}

function initProps (Comp) {
  const props = Comp.options.props
  for (const key in props) {
    proxy(Comp.prototype, `_props`, key)
  }
}

這麼做的好處是不用爲每個組件實例都做一層 proxy,是一種優化手段。

  1. Props 更新,當父組件傳遞給子組件的 props 值變化,子組件對應的值也會改變,同時會觸發子組件的重新渲染。那麼接下來我們就從源碼角度來分析這兩個過程。

  2. 子組件 props 更新,如下所示:

  • 首先,prop 數據的值變化在父組件,我們知道在父組件的 render 過程中會訪問到這個 prop 數據,所以當 prop 數據變化一定會觸發父組件的重新渲染,那麼重新渲染是如何更新子組件對應的 prop 的值呢?

  • 在父組件重新渲染的最後,會執行 patch 過程,進而執行 patchVnode 函數,patchVnode 通常是一個遞歸過程,當它遇到組件 vnode 的時候,會執行組件更新過程的 prepatch 鉤子函數,在 src/core/vdom/patch.js 中:

function patchVnode (
  oldVnode,
  vnode,
  insertedVnodeQueue,
  ownerArray,
  index,
  removeOnly
) {
  // ...

  let i
  const data = vnode.data
  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
    i(oldVnode, vnode)
  }
  // ...
}

prepatch 函數定義在 src/core/vdom/create-component.js 中:

prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
  const options = vnode.componentOptions
  const child = vnode.componentInstance = oldVnode.componentInstance
  updateChildComponent(
    child,
    options.propsData, // updated props
    options.listeners, // updated listeners
    vnode, // new parent vnode
    options.children // new children
  )
}
  1. 內部會調用 updateChildComponent 方法來更新 props,注意第二個參數就是父組件的 propData,那麼爲什麼 vnode.componentOptions.propsData 就是父組件傳遞給子組件的 prop 數據呢(這個也同樣解釋了第一次渲染的 propsData 來源)?原來在組件的 render 過程中,對於組件節點會通過 createComponent 方法來創建組件 vnode
export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  // ...

  // extract props
  const propsData = extractPropsFromVNodeData(data, Ctor, tag)

  // ...
  
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )

  // ...
  
  return vnode
}
  1. 在創建組件 vnode 的過程中,首先從 data 中提取出 propData,然後在 new VNode 的時候,作爲第七個參數 VNodeComponentOptions 中的一個屬性傳入,所以我們可以通過 vnode.componentOptions.propsData 拿到 prop 數據。接着看 updateChildComponent 函數,它的定義在 src/core/instance/lifecycle.js 中:
export function updateChildComponent (
  vm: Component,
  propsData: ?Object,
  listeners: ?Object,
  parentVnode: MountedComponentVNode,
  renderChildren: ?Array<VNode>
) {
  // ...

  // update props
  if (propsData && vm.$options.props) {
    toggleObserving(false)
    const props = vm._props
    const propKeys = vm.$options._propKeys || []
    for (let i = 0; i < propKeys.length; i++) {
      const key = propKeys[i]
      const propOptions: any = vm.$options.props // wtf flow?
      props[key] = validateProp(key, propOptions, propsData, vm)
    }
    toggleObserving(true)
    // keep a copy of raw propsData
    vm.$options.propsData = propsData
  }

  // ...
}
  1. 我們重點來看更新 props 的相關邏輯,這裏的 propsData 是父組件傳遞的 props 數據,vm 是子組件的實例。vm._props 指向的就是子組件的 props 值,propKeys 就是在之前 initProps 過程中,緩存的子組件中定義的所有 propkey。主要邏輯就是遍歷 propKeys,然後執行 props[key] = validateProp(key, propOptions, propsData, vm) 重新驗證和計算新的 prop 數據,更新 vm._props,也就是子組件的 props,這個就是子組件 props 的更新過程。

  2. 子組件重新渲染,其實子組件的重新渲染有兩種情況,一個是 prop 值被修改,另一個是對象類型的 prop 內部屬性的變化。

  • 先來看一下 prop 值被修改的情況,當執行 props[key] = validateProp(key, propOptions, propsData, vm) 更新子組件 prop 的時候,會觸發 propsetter 過程,只要在渲染子組件的時候訪問過這個 prop 值,那麼根據響應式原理,就會觸發子組件的重新渲染。

  • 再來看一下當對象類型的 prop 的內部屬性發生變化的時候,這個時候其實並沒有觸發子組件 prop 的更新。但是在子組件的渲染過程中,訪問過這個對象 prop,所以這個對象 prop 在觸發 getter 的時候會把子組件的 render watcher 收集到依賴中,然後當我們在父組件更新這個對象 prop 的某個屬性的時候,會觸發 setter 過程,也就會通知子組件 render watcherupdate,進而觸發子組件的重新渲染。

  • 以上就是當父組件 props 更新,觸發子組件重新渲染的兩種情況。

  1. toggleObserving,最後 toggleObserving,它的定義在 src/core/observer/index.js 中:
export let shouldObserve: boolean = true

export function toggleObserving (value: boolean) {
  shouldObserve = value
}

它在當前模塊中定義了 shouldObserve 變量,用來控制在 observe 的過程中是否需要把當前值變成一個 Observer 對象。那麼爲什麼在 props 的初始化和更新過程中,多次執行 toggleObserving(false) 呢,接下來我們就來分析這幾種情況。

  1. initProps 的過程中,如下所示:
const isRoot = !vm.$parent
// root instance props should be converted
if (!isRoot) {
  toggleObserving(false)
}
for (const key in propsOptions) {
  // ...
  const value = validateProp(key, propsOptions, propsData, vm)
  defineReactive(props, key, value)
  // ...
}
toggleObserving(true)

對於非根實例的情況,我們會執行 toggleObserving(false),然後對於每一個 prop 值,去執行 defineReactive(props, key, value) 去把它變成響應式。回顧一下 defineReactive 的定義:

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // ...
  
  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      // ...
    },
    set: function reactiveSetter (newVal) {
      // ...
    }
  })
}
  1. 通常對於值 val 會執行 observe 函數,然後遇到 val 是對象或者數組的情況會遞歸執行 defineReactive 把它們的子屬性都變成響應式的,但是由於 shouldObserve 的值變成了 false,這個遞歸過程被省略了。爲什麼會這樣呢?

  2. 因爲正如我們前面分析的,對於對象的 prop 值,子組件的 prop 值始終指向父組件的 prop 值,只要父組件的 prop 值變化,就會觸發子組件的重新渲染,所以這個 observe 過程是可以省略的。最後再執行 toggleObserving(true) 恢復 shouldObservetrue。在 validateProp 的過程中:

// check default value
if (value === undefined) {
  value = getPropDefaultValue(vm, prop, key)
  // since the default value is a fresh copy,
  // make sure to observe it.
  const prevShouldObserve = shouldObserve
  toggleObserving(true)
  observe(value)
  toggleObserving(prevShouldObserve)
}
  1. 這種是父組件沒有傳遞 prop 值對默認值的處理邏輯,因爲這個值是一個拷貝,所以我們需要 toggleObserving(true),然後執行 observe(value) 把值變成響應式。在 updateChildComponent 過程中:
// update props
if (propsData && vm.$options.props) {
  toggleObserving(false)
  const props = vm._props
  const propKeys = vm.$options._propKeys || []
  for (let i = 0; i < propKeys.length; i++) {
    const key = propKeys[i]
    const propOptions: any = vm.$options.props // wtf flow?
    props[key] = validateProp(key, propOptions, propsData, vm)
  }
  toggleObserving(true)
  // keep a copy of raw propsData
  vm.$options.propsData = propsData
}

其實和 initProps 的邏輯一樣,不需要對引用類型 props 遞歸做響應式處理,所以也需要 toggleObserving(false)

  1. 總結:瞭解了 props 的規範化、初始化、更新等過程的實現原理;也瞭解了 Vue 內部對 props 如何做響應式的優化;同時還了解到 props 的變化是如何觸發子組件的更新。瞭解這些對我們平時對 props 的應用,遇到問題時的定位追蹤會有很大的幫助。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章