博客 / 詳情

返回

Vue 響應式原理剖析 —— 從實例化、渲染到數據更新(下)

在上一篇文章中,梳理了 Vue 實例化和渲染的基本邏輯,並且介紹了訂閲者模式這種設計模式,Vue 的「響應式」實現本質上也是一個訂閲者模式,但是由於 Vue 需要考慮更加複雜的情況,並且需要在其中作出大量優化操作,因此具體實現也會複雜很多。通過上面對訂閲者模式的介紹,觀察目標類觀察者管理類觀察者是訂閲者模式中的三個基本要素,Vue 內部也會有對應的實現,下面通過更詳細地説明 Vue「響應式」的實現,同時發掘在 Vue 中訂閲者三要素分別是什麼。

Vue 響應式實現

正如上文開頭所述,本文會從「實例化」、「渲染」、「數據更新」三條線講述「響應式」的工作過程,首先可以總結出三條線的作用:

  1. 實例化 Vue —— 負責定義好響應式的相關邏輯。
  2. 渲染 —— 負責執行響應式的邏輯
  3. 數據更新 —— 負責響應式邏輯的二次執行

上面梳理了的是三條線的主線邏輯,下面開始聚焦到「響應式」的部分。

實例化 Vue —— 負責定義好響應式的邏輯

if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
} else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
) {
    ob = new Observer(value)
}

回顧前面提到的 observer 方法,這是其中核心的部分,它的基本邏輯是這樣的:

  1. 判斷如果有 __ob__ 則直接使用。
  2. 沒有 __ob__ 會走一系列的判斷,然後把數據傳入到 new Observer 創建響應式數據。

首先要分析的就是這一系列的判斷,這些實際上都是對需要做響應式封裝的數據進行檢查的判斷,shouldObserve 是默認為 true 的全局靜態變量,isServerRenderingArray.isArray 顧名思義判斷是否為服務端渲染和判斷是否為數組,value._isVue 是判斷是否為最根的 Vue 實例,根實例只是一個殼,是不需要處理響應式的,因此比較特別的是 isPlainObjectObject.isExtensible,這是兩個含義不是很直觀的判斷。

isPlainObject

“Plain Object — 通過 {} 或者 new Object 創建的純粹的對象”,這是對於 Plain Object 的定義。在 JavaScript 中,Function,Array 都繼承於 Object,也擁有 Object 的特性,但為了避免產生額外的問題,框架在數據上通常都會使用 Plain Object。要區分 Plain Object 也很簡單,很多框架裏都有關於 Plain Object 的判斷實踐,而 Vue 則是使用原型判斷,例如以下這段代碼:

// Plain object
var plainObj1 = {};
var plainObj2 = { name : 'myName' };
var plainObj3 = new Object();
// Non Plain object
var Person = function(){};

console.log(plainObj1.__proto__); // {}
console.log(plainObj2.__proto__); // {}
console.log(plainObj3.__proto__); // {}
console.log(Person.__proto__); // [Function]

打印結果中,原型的值是不一樣的,Vue 的 isPlainObject 具體實現如下:

var _toString = Object.prototype.toString;

function isPlainObject (obj) {
  return _toString.call(obj) === '[object Object]'
}

Object.isExtensible

“Object.isExtensible() 判斷一個對象是否是可擴展的,即是否可以在它上面添加新的屬性”,這是 Object.isExtensible() 的説明,看以下的例子:

// 新對象默認可擴展
var empty = {};
console.log(Object.isExtensible(empty)); // true

// 通過 Object.preventExtensions 使變得不可擴展
Object.preventExtensions(empty);
console.log(Object.isExtensible(empty)); // false

// 密封對象不可擴展
var sealed = Object.seal({});
console.log(Object.isExtensible(sealed)); // false

// 凍結對象也不可擴展
var frozen = Object.freeze({});
console.log(Object.isExtensible(frozen)); // false

// 嘗試給不可擴展的對象添加屬性
empty.a = 1;
console.log('modified empty: ', empty); // modified empty:  {}

一個直接創建的 Plain Object 默認是可擴展的,也可以通過一些原生方法把對象變為不可擴展,另外密封和凍結對象都是不可擴展的,不可擴展的元素添加屬性不會報錯,但是會添加無效。那為什麼 Vue 要求響應式數據對象必須要可擴展呢?原因很簡單,在上面介紹 observer 方法中,核心的步驟就是要給數據對象添加 __ob__ 屬性,用於緩存響應式數據的封裝結果。

定義響應式數據

回到實例化 Vue 的流程,在判斷傳入的數據對象如果沒有 __ob__ 屬性後,會調用 new Observer,這是響應式處理的真正入口類。

constructor (value: any) {
  this.value = value
  this.dep = new Dep()
  this.vmCount = 0
  def(value, '__ob__', this)
  if (Array.isArray(value)) {
    if (hasProto) {
      protoAugment(value, arrayMethods)
    } else {
      copyAugment(value, arrayMethods, arrayKeys)
    }
    this.observeArray(value)
  } else {
    this.walk(value)
  }
}

首先是把當前的 Observer 實例賦值給當前對象的 __ob__ 屬性,然後判斷如果是數組則遍歷每個 item 調用 observer,由於之前調用 observer 時就進行了判斷,傳入的數據類型只能是數組或者對象,因此這裏 else 就按對象處理,調用 walk 方法。

walk (obj: Object) {
  const keys = Object.keys(obj)
  for (let i = 0; i < keys.length; i++) {
    defineReactive(obj, keys[i])
  }
}

walk 主要的作用是為數據對象的每個 key 調用 defineReactive 方法,defineReactive 的主要邏輯是為傳入數據的某個 key,基於 Object.defineProperty 劫持 getset 操作,這樣數據讀取和賦值時就會調用響應式的邏輯。由於基於 Object.defineProperty 實現了這個核心邏輯,因此 Vue 不支持 IE8 下運行。

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
}

首先看看 get 操作的劫持,首先是通過原生的 getter 獲取數據的值,然後判斷 Dep.target 是否存在,這裏可能會有疑問,沒有看到它的賦值時機,所以 Dep.target 究竟是什麼呢?實際上現在不用關注它的賦值,因為正如前面強調的,當前這些實例化的操作,只是把「響應式」的數據先定義好,也就是還不用運行,到了渲染過程的時候,才會對 Dep.target 進行賦值。

// 精簡了部分邏輯
export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

假如 Dep.target 已經賦值了,接下來會執行以下操作:

  1. 調用 dep.depend() 進行依賴收集,在 Dep 類源碼中可以發現,這個方法實際上是把當前 target,即當前渲染的 Watcher 加入到 dep 實例的一個數組中,保存下來。
  2. 如果數據中有子值也是對象,則對子值進行依賴收集。

也就是 get 調用後數據的 dep 會持有關聯的 Watcher

// 精簡非正式環境邏輯
set: function reactiveSetter (newVal) {
  const value = getter ? getter.call(obj) : val
  if (newVal === value || (newVal !== newVal && value !== value)) {
    return
  }
  // #7981: for accessor properties without setter
  if (getter && !setter) return
  if (setter) {
    setter.call(obj, newVal)
  } else {
    val = newVal
  }
  childOb = !shallow && observe(newVal)
  dep.notify()
}

然後看看 set 的操作:

  1. 獲取當前最新的數據值。
  2. 判斷新值如果等於舊值則直接跳過下面的操作。newVal !== newVal && value !== value 是為了避免一些特殊情況,例如 newValNaN,由於 NaN === NaNfalse,所以需要這樣一個特殊的判斷。
  3. 然後跳過沒有原生 setter 但有原生 getter 的情況。
  4. 接着調用 setter 賦新值。
  5. 最後是調用 dep.notify(),根據上面 Dep 類的源碼可以知道,這實際上就是遍歷之前收集的 Watcher,然後逐個調用它們的 update 方法,Watcher 會去執行更新邏輯。

到這裏,實例化中「響應式」相關邏輯已經完整分析清楚了,訂閲者模式的相關要素也很清晰:

  • Dep觀察目標Watcher觀察者,每個數據對應一個 Dep 實例 depget 數據時會觸發 dep 收集了數據相關的 Watcher,相當於觀察目標收集了觀察者。
  • Watcher 也記錄了相關的 dep,方便後續更新時做優化。這是與普通訂閲者模式最大的區別,後續會展開説明。
  • set 數據時會觸發 dep 通知相關的 Watcher 更新,而具體的更新邏輯,等第三個小章節“數據更新”再詳細説明。

如前面所説的,實例化中的響應式處理實際上是負責定義響應式的邏輯。接下來看看渲染的邏輯。

渲染 —— 負責執行響應式的邏輯

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)

回顧調用 $mount,掛載實例的這塊代碼,重新聚焦幾個點:

  • Watcher 綁定的是 Vue 的實例 vm,傳入的第二個參數是 vm 的更新方法,裏面會先調用 vm_render() 方法。
  • Watcher 的作用包括在需要時觸發 _render(),即重新計算 vnode,然後 _update 調用 _patch,即重新渲染 DOM,從而實現整個 Vue 實例的更新。

因此對於這個流程,響應式相關的邏輯重點在 new Watcher,接下來看看 Watcherconstructor

// 精簡了非 production 的邏輯
constructor (vm: Component, expOrFn: string | Function, cb: Function, options?: ?Object, isRenderWatcher?: boolean) {
  this.vm = vm
  if (isRenderWatcher) { vm._watcher = this }
  vm._watchers.push(this)
  if (options) {
    this.deep = !!options.deep
    this.user = !!options.user
    this.lazy = !!options.lazy
    this.sync = !!options.sync
    this.before = options.before
  } else {
    this.deep = this.user = this.lazy = this.sync = false
  }
  this.cb = cb
  this.id = ++uid // uid for batching
  this.active = true
  this.dirty = this.lazy // for lazy watchers
  this.deps = []
  this.newDeps = []
  this.depIds = new Set()
  this.newDepIds = new Set()
  this.expression = process.env.NODE_ENV !== 'production' ? expOrFn.toString() : ''
  if (typeof expOrFn === 'function') {
    this.getter = expOrFn
  } else {
    this.getter = parsePath(expOrFn)
    if (!this.getter) { this.getter = noop }
  }
  this.value = this.lazy ? undefined : this.get()
}

constructor 的邏輯裏,大部分都是定義變量,需要重點關注的主要是:

  1. 真正要處理的邏輯在 get() 方法裏。
  2. Watcher 實例的 getter 就是傳入的 updateComponent 方法,getter 會被保存到 Watcher 實例變量上。

接下來分析一下 get() 方法的邏輯:

get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    if (this.user) { handleError(e, vm, `getter for watcher "${this.expression}"`) }
    else { throw e}
  } finally {
    if (this.deep) { traverse(value) }
    popTarget()
    this.cleanupDeps()
  }
  return value
}

get() 方法主要是做兩件事,調用 getter 以及進行「收集依賴」,getter 本質上就是 updateComponent,即上面介紹過的渲染更新組件的邏輯,這裏不再詳述這點,重點關注「收集依賴」的過程。

Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

上面是 pushTarget 的邏輯,它是 Dep 類下面的一個靜態方法,本質上就是把當前的 Watcher 加入到一個棧中,並且賦值給 Dep.target,這裏可以迴應上面在劫持響應式數據 get 邏輯的一個疑問,Dep.target 是在渲染過程中「收集依賴」時賦值的,因此真正執行響應式邏輯實際上是在渲染時才進行的。結合兩個特性:

  • Vue 實例渲染是遞歸的,從子到父逐個完成,同時只有一個 Watcher 被渲染。
  • JS 是單線程的,Dep.target 在同一時刻只會被賦值成一個 Watcher

Vue 就是利用這兩個特性,逐個執行 Watcher 的渲染邏輯,最終完成整個 Vue 應用的渲染,最後重點看看 this.cleanupDeps() 的邏輯。

為什麼需要 cleanupDeps?

cleanupDeps () {
  let i = this.deps.length
  while (i--) {
    const dep = this.deps[i]
    if (!this.newDepIds.has(dep.id)) { dep.removeSub(this) }
  }
  let tmp = this.depIds
  this.depIds = this.newDepIds
  this.newDepIds = tmp
  this.newDepIds.clear()
  tmp = this.deps
  this.deps = this.newDeps
  this.newDeps = tmp
  this.newDeps.length = 0
}

this.cleanupDeps() 的邏輯主要是分成兩塊:

  1. newDepIds 裏面不存在的 dep 實例找出來,然後把當前的 Watcher 從這個 dep 實例中移除,也就是後續 dep 對應的數據更新不用再通知當前 Watcher
  2. 清空當前的 newDepIds,把 deps 賦值成 newDeps

這樣看無法直觀看出來為什麼需要實現一個這樣的邏輯,舉一個具體的例子:

<template>
  <div id="app">
    <div>
      a:{{ a }}
      <button @click="chnageA">點擊修改 a 的值</button>
      <HelloWorld1 v-if="a % 2 === 0" :data="a" />
      <HelloWorld2 v-else :data="b" />
    </div>
  </div>
</template>

在這個例子中,ab 兩個 dataa 在模板中直接用到,而 b 僅在 HellowWorld2 中作為 props 傳遞,當 a 為奇數時 ab 改變都會觸發 App 更新渲染。

可以試想一下這樣一個過程:

  1. 初始化時, ab 都為 1,在初始化的時候經常 observe 的處理,形成了兩個 Dep 實例,dep(id=1,綁定 a)和 dep(id=2,綁定 b
  2. 渲染時 new Watcher 綁定了 App 這個 Vue 實例,然後 Dep.target 賦值成當前 Watcher,經常 Watchergetter -> updateComponent -> render() 這樣一個過程,觸發了 abget,從而進行依賴收集,把當前 Watcher 同時放入兩個 dep 中。
  3. 然後把 a 改為2,觸發了 aset 從而通知 Watcher 更新,重新觸發 updateComponent 走到 render(),這個時候假如沒有 cleanupDeps(),則這次 render() 觸發依賴收集完成後,只是更新了 a 的值為2,而後續如果 b 修改值時,仍會通知 Watcher 更新,造成一次浪費的訂閲更新。對於 Vue 這樣的基礎框架來説,如果每次依賴收集都重新進行,拋棄內存緩存記錄,又會導致性能很差,無法適配各種常見,因此最終 Vue 的做法就是通過 WatcherDep 同時互相記錄,來實現渲染優化,即訂閲者也可以通知訂閲目標拋棄掉一些無用的通知對象,減少浪費。

為了更好地説明這個過程,這裏特意做了一張流程圖完整表述整個過程:

cleanupDeps

數據更新 —— 負責響應式邏輯的二次執行

相對來説,數據 set 後的更新邏輯比較好理解,上面大概提到了,但其中的內部邏輯卻是三條主線裏最複雜的。上面稍微提到過,當數據 set 後,會觸發 dep.notify(),即遍歷之前收集的 Watcher,然後逐個調用它們的 update 方法,因此首先來看看 Watcherupdate 方法:

update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

上面 Watcherconstructor 的代碼裏展示過,lazysync 這兩個變量默認都是 false,因此可以先不用理會,也就是説 update 的主邏輯是把當前的 Watcher 作為參數調用 queueWatcher,顧名思義是把 Watcher 放入到一個隊列中,接下來看看 queueWatcher 的具體處理。

// 精簡了非 production 的邏輯
let waiting = false
let flushing = false

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

根據這裏的邏輯,默認的情況都是直接把傳入的 Watcher 加入到一個隊列中,然後使用 nextTick 調用 flushSchedulerQueuenextTick 大家都比較熟悉,作用是把方法按週期調用,因此組件的實際渲染更新都不是即時的,而是每隔一個週期中集中處理,接下來看看 flushSchedulerQueue 的邏輯。

// 已精簡非 production 邏輯
function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id
  queue.sort((a, b) => a.id - b.id)
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    watcher.run()
  }

  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()

  resetSchedulerState()

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)
}

這裏主要處理了三件事情:

  1. 按 id 從小到大排序 WatcherWatchweid 是創建時自增的,渲染是從外層遞歸的,也就是父元素會排在隊列的前面。為什麼要這樣排呢?實際上也是為了性能優化,把父元素放在隊列的前面,就會優先處理父元素,因此如果父元素銷燬了,就可以直接跳過後面子元素的渲染更新。
  2. 遍歷隊列調用每個 Watcherrun() 方法。
  3. queue.length 是動態的,Vue 沒有把隊列長度緩存起來,是因為 queue 在調用過程中可能會增刪 Watcher,例如上面的例子中,a 的改變可以導致 HelloWorld1Watcher 加入到隊列中,而 HellowWorld2Watch 則不再需要被渲染,因此 queue 的長度無法緩存。
run () {
  if (this.active) {
    const value = this.get()
    if (
      value !== this.value ||
      isObject(value) ||
      this.deep
    ) {
      const oldValue = this.value
      this.value = value
      if (this.user) {
        const info = `callback for watcher "${this.expression}"`
        invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info)
      } else {
        this.cb.call(this.vm, value, oldValue)
      }
    }
  }
}

最後看看 run() 方法,首先會調用 get() 方法,也就是會重新調用 updateComponent 和進行依賴收集,這裏再關注一下 get() 後半部分的邏輯,之前文章內提到的 Watcher 其實都是綁定 Vue 實例的渲染 Watcher,Vue 中還有用户 Watcher,也就是平常監聽 data 或者 props 值變化用的 Watcher,對於這些 Watcher,會有有效的返回值 value,因此 run() 裏面還會對比 value 是否有變化,如果有就重新賦值,並且會執行回調。

至此,Vue「響應式」的整個邏輯以及在各個環節中分別所做的處理已經講述完成,作為 Vue 的核心部分,「響應式」的整個邏輯較為龐大,也涉及實例化、渲染、數據更新三個環節,同時內部還有很多的性能考慮,因此單純去看「響應式」的核心代碼也不大好理解,後面還會有一篇短文來解答一些數據更新的常見問題。最後製作了一張完整的 Vue「響應式」邏輯流程圖供參考。

Vue 響應式完整流程

user avatar webxejir 頭像 u_16099277 頭像 zhangguoye 頭像 _bleach 頭像 jiaozheng 頭像 ch5nftr 頭像
6 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.