Vue 資料響應式原理

luobotang發表於2018-09-06

前言

Vue.js 的核心包括一套“響應式系統”。

“響應式”,是指當資料改變後,Vue 會通知到使用該資料的程式碼。例如,檢視渲染中使用了資料,資料改變後,檢視也會自動更新。

舉個簡單的例子,對於模板:

<div id="root">{{ name }}</div>
複製程式碼

建立一個 Vue 元件:

var vm = new Vue({
  el: '#root',
  data: {
    name: 'luobo'
  }
})
複製程式碼

程式碼執行後,頁面上對應位置會顯示:luobo。

如果想改變顯示的名字,只需要執行:

vm.name = 'tang'
複製程式碼

這樣頁面上就會顯示修改後的名字了,並不需要去手動修改 DOM 更新資料。

接下來,我們就一起深入瞭解 Vue 的資料響應式原理,搞清楚響應式的實現機制。

基本概念

Vue 的響應式,核心機制是 觀察者模式

資料是被觀察的一方,發生改變時,通知所有的觀察者,這樣觀察者可以做出響應,比如,重新渲染然後更新檢視。

我們把依賴資料的觀察者稱為 watcher,那麼這種關係可以表示為:

data -> watcher
複製程式碼

資料可以有多個觀察者,怎麼記錄這種依賴關係呢?

Vue 通過在 data 和 watcher 間建立一個 dep 物件,來記錄這種依賴關係:

data - dep -> watcher
複製程式碼

dep 的結構很簡單,除了唯一標識屬性 id,另一個屬性就是用於記錄所有觀察者的 subs:

  • id - number
  • subs - [Watcher]

再來看 watcher。

Vue 中 watcher 的觀察物件,確切來說是一個求值表示式,或者函式。這個表示式或者函式,在一個 Vue 例項的上下文中求值或執行。這個過程中,使用到資料,也就是 watcher 所依賴的資料。用於記錄依賴關係的屬性是 deps,對應的是由 dep 物件組成的陣列,對應所有依賴的資料。而表示式或函式,最終會作為求值函式記錄到 getter 屬性,每次求值得到的結果記錄在 value 屬性:

  • vm - VueComponent
  • deps - [Dep]
  • getter - function
  • value - *

另外,還有一個重要的屬性 cb,記錄回撥函式,當 getter 返回的值與當前 value 不同時被呼叫:

  • cb - function

我們通過示例來整理下 data、dep、watcher 的關係:

var vm = new Vue({
  data: {
    name: 'luobo',
    age: 18
  }
})

var userInfo = function () {
  return this.name + ' - ' + this.age
}

var onUserInfoChange = function (userInfo) {
  console.log(userInfo)
}

vm.$watch(userInfo, onUserInfoChange)
複製程式碼

上面程式碼首先建立了一個新的 Vue 例項物件 vm,包含兩個資料欄位:name、age。對於這兩個欄位,Vue 會分別建立對應的 dep 物件,用於記錄依賴該資料的 watcher。

然後定義了一個求值函式 userInfo,注意,這個函式會在對應的 Vue 示例上下文中執行,也就是說,執行時的 this 對應的就是 vm。

回撥函式 onUserInfoChange 只是列印出新的 watcher 得到的新的值,由 userInfo 執行後生成。

通過 vm.$watch(userInfo, onUserInfoChange),將 vm、getter、cb 整合在一起建立了新的 watcher。建立成功後,watcher 在內部已經記錄了依賴關係,watcher.deps 中記錄了 vm 的 name、age 對應的 dep 物件(因為 userInfo 中使用了這兩個資料)。

接下來,我們修改資料:

vm.name = 'tang'
複製程式碼

執行後,控制檯會輸出:

tang - 18
複製程式碼

同樣,如果修改 age 的值,也會最終觸發 onUserInfoChange 列印出新的結果。

用個簡單的圖來整理下上面的關係:

vm.name -- dep1
vm.age  -- dep2
watcher.deps --> [dep1, dep2]
複製程式碼

修改 vm.name 後,dep1 通知相關的 watcher,然後 watcher 執行 getter,得到新的 value,再將新的 value 傳給 cb:

vm.name -> dep1 -> watcher -> getter -> value -> cb
複製程式碼

可能你也注意到了,上面例子中的 userInfo,貌似就是計算屬性的作用嘛:

var vm = new Vue({
  data: {
    name: 'luobo',
    age: 18
  },
  computed: {
    userInfo() {
      return this.name + ' - ' + this.age
    }
  }
})
複製程式碼

其實,計算屬性在內部也是基於 watcher 實現的,每個計算屬性對應一個 watcher,其 getter 也就是計算屬性的宣告函式。 不過,計算屬性對應的 watcher 與直接通過 vm.$watch() 建立的 watcher 略有不同,畢竟如果沒有地方使用到這個計算屬性,資料改變時都重新進行計算會有點浪費,這個在本文後面會講到。

上面描述了 data、dep、watcher 的關係,但是問題來了,這種依賴關係是如何建立的呢?資料改變後,又是如何通知 watcher 的呢?

接下來我們深入 Vue 原始碼,搞清楚這兩個問題。

建立依賴關係

Vue 原始碼版本 v2.5.13,文中摘錄的部分程式碼為便於分析進行了簡化或改寫。

響應式的核心邏輯,都在 Vue 專案的 “vue/src/core/observer” 目錄下面。

我們還是先順著前面示例程式碼來捋一遍,首先是 Vue 例項化過程:

var vm = new Vue(/* ... */)
複製程式碼

跟將傳入的 data 進行響應式初始化相關的程式碼,在 “vue/src/core/instance/state.js” 檔案中:

observer/state.js#L149

// new Vue() -> ... -> initState() -> initData()
observe(data)
複製程式碼

函式 observe() 的目的是讓傳入的整個物件成為響應式的,它會遍歷物件的所有屬性,然後執行:

observer/index.js#L64

// observe() -> new Observer() -> observer.walk()
defineReactive(obj, key, value)
複製程式碼

defineReactive() 就是用於定義響應式資料的核心函式。它主要做的事情包括:

  • 新建一個 dep 物件,與當前資料對應
  • 通過 Object.defineProperty() 重新定義物件屬性,配置屬性的 set、get,從而資料被獲取、設定時可以執行 Vue 的程式碼

OK,先到這裡,關於 Vue 例項化告一段落。

需要要注意的是,傳入 Vue 的 data 的所有屬性,會被代理到新建立的 Vue 例項物件上,這樣通過 vm.name 進行操作的其實就是 data.name,這也是藉助 Object.defineProperty() 實現的。

再來看 watcher 的建立過程:

vm.$watch(userInfo, onUserInfoChange)
複製程式碼

上述程式碼執行後,會呼叫:

instance/state.js#L346

// Vue.prototype.$watch()
new Watcher(vm, expOrFn, cb, options)
複製程式碼

也就是:

new Watcher(vm, userInfo, onUserInfoChange, {/* 略 */})
複製程式碼

在 watcher 物件建立過程中,除了記錄 vm、getter、cb 以及初始化各種屬性外,最重要的就是呼叫了傳入的 getter 函式:

observer/watcher.js#L103

// new Watcher() -> watcher.get()
value = this.getter.call(vm, vm)
複製程式碼

在 getter 函式的執行過程中,獲取讀取需要的資料,於是觸發了前面通過 defineReactive() 配置的 get 方法:

if (Dep.target) {
  dep.depend()
}
複製程式碼

這是做什麼呢?

回到 watcher.get() 方法,在執行 getter 函式的前後,分別有如下程式碼:

pushTarget(this)
// ... 
value = this.getter.call(vm, vm)
// ...
popTarget()
複製程式碼

pushTarget() 將當前 watcher 設定為 Dep.target,這樣在執行到 vm.name 進一步執行對應的 get 方法時,Dep.target 的值就是這裡的 watcher,然後通過 dep.depend() 就建立了依賴關係。

dep.depend() 執行的邏輯就比較好推測了,將 watcher(通過 Dep.target 引用到)記錄到 dep.subs 中,將 dep 記錄到 watcher.deps 中 —— 依賴關係建立了!

然後來看建立的依賴關係是如何使用的。

資料變更同步

繼續前面的例子,執行如下程式碼時:

vm.name = 'tang'
複製程式碼

會觸發通過 defineReactive() 配置的 set 方法,如果資料改變,那麼:

// defineReactive() -> set()
dep.notify()
複製程式碼

通過 dep 物件來通知所有的依賴方法,於是 dep 遍歷內部的 subs 執行:

// dep.notify()
watcher.update()
複製程式碼

這樣 watcher 就被通知到了,知道了資料改變,從而繼續後續的處理。這裡先不展開。

到這裡,基本就搞清楚響應式的基本機制了,整理一下:

  • 通過 Object.defineProperty() 替換配置物件屬性的 set、get 方法,實現“攔截”
  • watcher 在執行 getter 函式時觸發資料的 get 方法,從而建立依賴關係
  • 寫入資料時觸發 set 方法,從而藉助 dep 釋出通知,進而 watcher 進行更新

這樣再看 Vue 官方的圖就比較好理解了:

Vue 響應式原理

圖片來源:vuejs.org/v2/guide/re… 上圖中左側是以元件渲染(render)作為 getter 函式來演示響應式過程的,這其實就是 RenderWatcher 這種特殊型別 watcher 的作用機制,後面還會再講。

計算屬性

本文前面提到過計算屬性,在 Vue 中也是作為 watcher 進行處理的。計算屬性(ComputedWatcher)特殊的地方在於,它其實沒有 cb(空函式),只有 getter,並且它的值只在被使用時才計算並快取。

什麼意思呢?

首先,ComputedWatcher 在建立時,不會立即執行 getter(lazy 選項值為 false),這樣一開始 ComputedWatcher 並沒有和使用到的資料建立依賴關係。

計算屬性在被“get”時,首先執行預先定義的 ComputedGetter 函式,這裡有一段特殊邏輯:

instance/state.js#L238

function computedGetter () {
  if (watcher.dirty) {
    watcher.evaluate()
  }
  if (Dep.target) {
    watcher.depend()
  }
  return watcher.value
}
複製程式碼

首先判斷 watcher 是不是 dirty 狀態,什麼意思呢?

計算屬性對應的 watcher 初始建立的時候,並沒有執行 getter,這個時候就會設定 dirty 為 true,這樣當前獲取計算屬性的值的時候,會執行 getter 得到 value,然後標記 dirty 為 false。這樣後續再獲取計算屬性的值,不需要再計算(執行 getter),直接就能返回快取的 value。

另外,計算屬性的 watcher 在執行 watcher.evaluate() 是,進一步呼叫 watcher.get(),從而進行依賴收集。而依賴的資料在改變後,會通知計算屬性的 watcher,但是 watcher 只是標記自身為 dirty,而不計算。這樣的好處是可以減小開銷,只在有地方需要計算屬性的值時才執行計算。

如果依賴的資料發生變更,計算屬性只是標記 dirty 為 true,會不會有問題呢?

解決這個問題的是上面程式碼的這一部分:

if (Dep.target) {
  watcher.depend()
}
複製程式碼

也就是說,如果當前有在收集依賴的 watcher,那麼當前計算屬性的 watcher 會間接地通過 watcher.depend() 將依賴關係“繼承”給這個 watcher(watcher.depend() 內部是對每個 watcher.deps 記錄的 dep 執行 dep.depend() 從而讓依賴資料與當前的 watcher 建立依賴關係)。

所以,依賴資料改變,依賴計算屬性的 watcher 會直接得到通知,再來獲取計算屬性的值的時候,計算屬性才進行計算求值。

所以,依賴計算屬性的 watcher 可以視為依賴 watcher 的 watcher。這樣的 watcher 在 Vue 中最常見不過,那就是 RenderWatcher。

RenderWatcher 及非同步更新

相信讀過前文,你應該對 Vue 響應式原理有基本的認識。那麼 Vue 是如何將其運用到檢視更新中的呢?答案就是這裡要講的 RenderWatcher。

RenderWatcher 首先是 watcher,只不過和計算屬性對應的 ComputedWatcher 類似,它也有些特殊的行為。

RenderWatcher 的建立,在函式 mountComponent 中:

// Vue.prototype.$mount() -> mountComponent()
let updateComponent = () => {
  vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)
複製程式碼

核心程式碼就在這裡了。這個 watcher 就是 Vue 例項物件唯一的 RenderWatcher,在 watcher 建構函式中,會記錄到 vm._watcher 上(普通 watcher 只會記錄到 vm._watchers 陣列中)。

這個 watcher 也會在建立的最後執行 watcher.get(),也就是執行 getter 收集依賴的過程。而在這裡,getter 就是 updateComponent,也就是說,執行了渲染+更新 DOM!並且,這個過程中使用到的資料也被收集了依賴關係。

那麼,理所當然地,在 render() 中使用到資料,發生改變,自然會通知到 RenderWatcher,從而最終更新檢視!

不過,這裡會有個疑問:如果進行多次資料修改,那麼豈不是要頻繁執行 DOM 更新?

這裡就涉及到 RenderWatcher 的特殊功能了:非同步更新

結合前面內容,我們知道資料更新後,依賴該資料的 watcher 會執行 watcher.update(),這個在前文中沒有展開,現在我們來看下這個方法:

observer/watcher.js#L161

if (this.lazy) {
  this.dirty = true
} else if (this.sync) {
  this.run()
} else {
  queueWatcher(this)
}
複製程式碼

第一種情況,lazy 為 true,也就是計算屬性,上一節已經提到過,只是標記 dirty 為 true,並不立即計算,不再贅述。sync 為 true 的情況,這裡也不管,不過看起來也很簡單,就是立即執行計算嘛。

最後的情況,就是這裡 RenderWatcher 的場景,並不立即執行,也不是像計算屬性那樣標記為 dirty 就完了,而是放到了一個佇列中。

這個佇列是幹什麼的呢?

相關程式碼在 observer/scheduler.js 中,簡單來說,就是實現了非同步更新。

理解其實現,首先要對瀏覽器的事件迴圈(Event Loop)機制有一定了解。如果你對事件迴圈機制不是很瞭解,可以看下面這篇文章:

JavaScript 執行機制詳解:再談Event Loop - 阮一峰

事件迴圈機制其實有點複雜,但只有理解事件迴圈,才能對這裡 Vue 非同步更新的方案有深入的認識。

基於事件迴圈機制,RenderWatcher 將其 getter,也就是 updateComponent 函式非同步執行,並且,多次觸發 RenderWatcher 的 update(),最終也只會執行一次 updateComponent,這樣也就解決了效能問題。

不過,隨之而來的新問題是,修改完資料,不能直接反應到 DOM 上,而是要等非同步更新執行過後才可以,這也是為什麼 Vue 提供了 nextTick() 介面,並且要求開發者將對 DOM 的操作放到 nextTick() 回撥中執行的原因。

Vuex、Vue-Router

再來看 Vue 套裝中的 Vuex、Vue-Router,它們也是基於 Vue 的響應式機制實現功能。

先來看 Vuex,程式碼版本 v3.0.1

Vuex

在應用了 Vuex 的應用中,所有元件都可以通過 this.$store 來引用到全域性的 store,並且在使用了 store 的資料後,還能在資料改變後得到同步,這其實就是響應式的應用了。

首先看 this.$store 的實現,這個其實是通過全域性 mixin 實現,程式碼在:

src/mixin.js#L26

this.$store = options.store || options.parent.$store
複製程式碼

這樣在每個元件的 beforeCreate 時,會執行 $store 屬性的初始化。

而 store 資料的響應式處理,則是通過例項化一個 Vue 物件實現:

src/store.js#L251

// new Store() -> resetStoreVM()
store._vm = new Vue({
  data: {
    $$state: state
  },
  computed // 對應 store.getters
})
複製程式碼

結合前文的介紹,這裡就很好理解了。因為 state 以及處理為響應式資料,而 getters 也建立為計算屬性,所以對這些資料的使用,就建立依賴關係,從而可以響應資料改變了。

Vue-Router

Vue-Router 中,比較重要的資料是 $route,即當前的頁面路由資料,在路由改變的時候,需要替換展示不同元件(router-view 元件實現)。

vm.$route 實踐上是來自 Vue.prototype,但其對應的值,最終對應到的是 router.history.current

結合前面的分析,這裡的 history.current 肯定得是響應式資料,所以,來找下對其進行初始化的地方,其實是在全域性 mixin 的 beforeCreate 這裡:

v2.8.1/src/install.js#L27

// beforeCreate
Vue.util.defineReactive(this, '_route', this._router.history.current)
複製程式碼

這樣 this._route 就是響應式的了,那麼如果頁面路由改變,又是如何修改這裡的 _route 的呢?

答案在 VueRouter 的 init() 這裡:

history.listen(route => {
  this.apps.forEach((app) => {
    app._route = route
  })
})
複製程式碼

一個 router 物件可能和多個 vue 例項物件(這裡叫作 app)關聯,每次路由改變會通知所有的例項物件。

再來看使用 vm.$route 的地方,也就是 VueRouter 的兩個元件:

兩個元件都是在 render() 中,與 $route 建立了依賴關係,根據 route 的值進行渲染。這裡具體過程就不展開了,感興趣可以看下相關原始碼(v2.8.1/src/components),原理方面在 RenderWatcher 一節已經介紹過。

實踐:watch-it

瞭解了以上這麼多,也想自己試試,把 Vue 響應式相關的核心邏輯剝離出來,做一個單純的資料響應式的庫。由於只關注資料,所以在剝離過程中,將與 Vue 元件/例項物件相關的部分都移除了,包括 watcher.vm 也不再需要,這樣 watcher.getter 計算時不再指定上下文物件。

感興趣,想直接看程式碼的,可以前往 luobotang/watch-it

watch-it 只包括資料響應式相關的功能,暴露了4個介面:

  • defineReactive(obj, key, val):為物件配置一個響應式資料屬性
  • observe(obj):將一個資料物件配置為響應式,內部對所有的屬性執行 defineReactive
  • defineComputed(target, key, userDef):為物件配置一個計算屬性,內部建立了 watcher
  • watch(fn, cb, options):監聽求值函式中資料改變,變化時呼叫 cb,內部建立了 watcher

來看一個使用示例:

const { observe, watch } = require('@luobotang/watch-it')

const data = {
  name: 'luobo',
  age: 18
}

observe(data)

const userInfo = function() {
  return data.name + ' - ' + data.age
}

watch(userInfo, (value) => console.log(value))
複製程式碼

這樣,當資料修改時,通過會列印出新的 userInfo 的值。

去除虛擬 DOM,只通過響應式機制,我還構建了一個簡單的 Vue,並實現了一個 DEMO:

watch-it/example/

原始碼在這裡:

luobotang/watch-it/example/vue.js

總結

OK,以上就是有關 Vue 響應式原理的全部了,當然,只是我的理解和實踐。

在梳理和寫下這些內容的過程中,我收穫很多,也希望內容能夠對你有所幫助。

水平有限,錯漏難免,歡迎指出。

最後,感謝閱讀!

相關文章