在上一篇文章中,梳理了 Vue 實例化和渲染的基本邏輯,並且介紹了訂閲者模式這種設計模式,Vue 的「響應式」實現本質上也是一個訂閲者模式,但是由於 Vue 需要考慮更加複雜的情況,並且需要在其中作出大量優化操作,因此具體實現也會複雜很多。通過上面對訂閲者模式的介紹,觀察目標類,觀察者管理類,觀察者是訂閲者模式中的三個基本要素,Vue 內部也會有對應的實現,下面通過更詳細地説明 Vue「響應式」的實現,同時發掘在 Vue 中訂閲者三要素分別是什麼。
Vue 響應式實現
正如上文開頭所述,本文會從「實例化」、「渲染」、「數據更新」三條線講述「響應式」的工作過程,首先可以總結出三條線的作用:
- 實例化 Vue —— 負責定義好響應式的相關邏輯。
- 渲染 —— 負責執行響應式的邏輯
- 數據更新 —— 負責響應式邏輯的二次執行
上面梳理了的是三條線的主線邏輯,下面開始聚焦到「響應式」的部分。
實例化 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 方法,這是其中核心的部分,它的基本邏輯是這樣的:
- 判斷如果有
__ob__則直接使用。 - 沒有
__ob__會走一系列的判斷,然後把數據傳入到new Observer創建響應式數據。
首先要分析的就是這一系列的判斷,這些實際上都是對需要做響應式封裝的數據進行檢查的判斷,shouldObserve 是默認為 true 的全局靜態變量,isServerRendering 和 Array.isArray 顧名思義判斷是否為服務端渲染和判斷是否為數組,value._isVue 是判斷是否為最根的 Vue 實例,根實例只是一個殼,是不需要處理響應式的,因此比較特別的是 isPlainObject 和 Object.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 劫持 get 和 set 操作,這樣數據讀取和賦值時就會調用響應式的邏輯。由於基於 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 已經賦值了,接下來會執行以下操作:
- 調用
dep.depend()進行依賴收集,在Dep類源碼中可以發現,這個方法實際上是把當前 target,即當前渲染的Watcher加入到dep實例的一個數組中,保存下來。 - 如果數據中有子值也是對象,則對子值進行依賴收集。
也就是 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 的操作:
- 獲取當前最新的數據值。
- 判斷新值如果等於舊值則直接跳過下面的操作。
newVal !== newVal && value !== value是為了避免一些特殊情況,例如newVal是NaN,由於NaN === NaN為false,所以需要這樣一個特殊的判斷。 - 然後跳過沒有原生
setter但有原生getter的情況。 - 接着調用
setter賦新值。 - 最後是調用
dep.notify(),根據上面Dep類的源碼可以知道,這實際上就是遍歷之前收集的Watcher,然後逐個調用它們的update方法,Watcher會去執行更新邏輯。
到這裏,實例化中「響應式」相關邏輯已經完整分析清楚了,訂閲者模式的相關要素也很清晰:
Dep是觀察目標,Watcher是觀察者,每個數據對應一個Dep實例dep,get數據時會觸發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,接下來看看 Watcher 的 constructor。
// 精簡了非 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 的邏輯裏,大部分都是定義變量,需要重點關注的主要是:
- 真正要處理的邏輯在
get()方法裏。 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() 的邏輯主要是分成兩塊:
- 把
newDepIds裏面不存在的dep實例找出來,然後把當前的Watcher從這個dep實例中移除,也就是後續dep對應的數據更新不用再通知當前Watcher。 - 清空當前的
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>
在這個例子中,a、b 兩個 data,a 在模板中直接用到,而 b 僅在 HellowWorld2 中作為 props 傳遞,當 a 為奇數時 a 和 b 改變都會觸發 App 更新渲染。
可以試想一下這樣一個過程:
- 初始化時,
a和b都為 1,在初始化的時候經常observe的處理,形成了兩個Dep實例,dep(id=1,綁定a)和dep(id=2,綁定b) - 渲染時
new Watcher綁定了 App 這個 Vue 實例,然後Dep.target賦值成當前Watcher,經常Watcher的getter->updateComponent->render()這樣一個過程,觸發了a和b的get,從而進行依賴收集,把當前Watcher同時放入兩個dep中。 - 然後把
a改為2,觸發了a的set從而通知Watcher更新,重新觸發updateComponent走到render(),這個時候假如沒有cleanupDeps(),則這次render()觸發依賴收集完成後,只是更新了a的值為2,而後續如果b修改值時,仍會通知Watcher更新,造成一次浪費的訂閲更新。對於 Vue 這樣的基礎框架來説,如果每次依賴收集都重新進行,拋棄內存緩存記錄,又會導致性能很差,無法適配各種常見,因此最終 Vue 的做法就是通過Watcher和Dep同時互相記錄,來實現渲染優化,即訂閲者也可以通知訂閲目標拋棄掉一些無用的通知對象,減少浪費。
為了更好地説明這個過程,這裏特意做了一張流程圖完整表述整個過程:
數據更新 —— 負責響應式邏輯的二次執行
相對來説,數據 set 後的更新邏輯比較好理解,上面大概提到了,但其中的內部邏輯卻是三條主線裏最複雜的。上面稍微提到過,當數據 set 後,會觸發 dep.notify(),即遍歷之前收集的 Watcher,然後逐個調用它們的 update 方法,因此首先來看看 Watcher 的 update 方法:
update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
上面 Watcher 的 constructor 的代碼裏展示過,lazy 和 sync 這兩個變量默認都是 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 調用 flushSchedulerQueue,nextTick 大家都比較熟悉,作用是把方法按週期調用,因此組件的實際渲染更新都不是即時的,而是每隔一個週期中集中處理,接下來看看 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)
}
這裏主要處理了三件事情:
- 按 id 從小到大排序
Watcher,Watchwe的id是創建時自增的,渲染是從外層遞歸的,也就是父元素會排在隊列的前面。為什麼要這樣排呢?實際上也是為了性能優化,把父元素放在隊列的前面,就會優先處理父元素,因此如果父元素銷燬了,就可以直接跳過後面子元素的渲染更新。 - 遍歷隊列調用每個
Watcher的run()方法。 queue.length是動態的,Vue 沒有把隊列長度緩存起來,是因為queue在調用過程中可能會增刪Watcher,例如上面的例子中,a的改變可以導致HelloWorld1的Watcher加入到隊列中,而HellowWorld2的Watch則不再需要被渲染,因此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「響應式」邏輯流程圖供參考。