深入淺出 - vue變化偵測原理
關(guān)于vue的內(nèi)部原理其實(shí)有很多個重要的部分,變化偵測,模板編譯,virtualDOM,整體運(yùn)行流程等。
今天主要把變化偵測這部分單獨(dú)拿出來講一講。
如何偵測變化?
關(guān)于變化偵測首先要問一個問題,在 js 中,如何偵測一個對象的變化,其實(shí)這個問題還是比較簡單的,學(xué)過js的都能知道,js中有兩種方法可以偵測到變化,Object.defineProperty
和 ES6 的proxy
。
到目前為止vue還是用的 Object.defineProperty
,所以我們拿 Object.defineProperty
來舉例子說明這個原理。
這里我想說的是,不管以后vue是否會用 proxy
重寫這部分,我講的是原理,并不是api,所以不論以后vue會怎樣改,這個原理是不會變的,哪怕vue用了其他完全不同的原理實(shí)現(xiàn)了變化偵測,但是本篇文章講的原理一樣可以實(shí)現(xiàn)變化偵測,原理這個東西是不會過時的。
之前我寫文章有一個毛病就是喜歡對著源碼翻譯,結(jié)果過了半年一年人家源碼改了,我寫的文章就一毛錢都不值了,而且對著源碼翻譯還有一個缺點(diǎn)是對讀者的要求有點(diǎn)偏高,讀者如果沒看過源碼或者看的和我不是一個版本,那根本就不知道我在說什么。
好了不說廢話了,繼續(xù)講剛才的內(nèi)容。
知道 Object.defineProperty
可以偵測到對象的變化,那么我們瞬間可以寫出這樣的代碼:
function defineReactive (data, key, val) {
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
return val
},
set: function (newVal) {
if(val === newVal){
return
}
val = newVal
}
})
}
寫一個函數(shù)封裝一下 Object.defineProperty
,畢竟 Object.defineProperty
的用法這么復(fù)雜,封裝一下我只需要傳遞一個 data,和 key,val 就行了。
現(xiàn)在封裝好了之后每當(dāng) data
的 key
讀取數(shù)據(jù) get
這個函數(shù)可以被觸發(fā),設(shè)置數(shù)據(jù)的時候 set
這個函數(shù)可以被觸發(fā),但是,,,,,,,,,,,,,,,,,,發(fā)現(xiàn)好像并沒什么鳥用?
怎么觀察?
現(xiàn)在我要問第二個問題,“怎么觀察?”
思考一下,我們之所以要觀察一個數(shù)據(jù),目的是為了當(dāng)數(shù)據(jù)的屬性發(fā)生變化時,可以通知那些使用了這個 key
的地方。
舉個例子
<template>
<div>{{ key }}</div>
<p>{{ key }}</p>
</template>
模板中有兩處使用了 key
,所以當(dāng)數(shù)據(jù)發(fā)生變化時,要把這兩處都通知到。
所以上面的問題,我的回答是,先收集依賴,把這些使用到 key
的地方先收集起來,然后等屬性發(fā)生變化時,把收集好的依賴循環(huán)觸發(fā)一遍就好了~
總結(jié)起來其實(shí)就一句話,getter中,收集依賴,setter中,觸發(fā)依賴。
依賴收集在哪?
現(xiàn)在我們已經(jīng)有了很明確的目標(biāo),就是要在getter中收集依賴,那么我們的依賴收集到哪里去呢??
思考一下,首先想到的是每個 key
都有一個數(shù)組,用來存儲當(dāng)前 key
的依賴,假設(shè)依賴是一個函數(shù)存在 window.target
上,先把 defineReactive
稍微改造一下:
function defineReactive (data, key, val) {
let dep = [] // 新增
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
dep.push(window.target) // 新增
return val
},
set: function (newVal) {
if(val === newVal){
return
}
// 新增
for (let i = 0; i < dep.length; i++) {
dep[i](newVal, val)
}
val = newVal
}
})
}
在 defineReactive
中新增了數(shù)組 dep,用來存儲被收集的依賴。
然后在觸發(fā) set 觸發(fā)時,循環(huán)dep把收集到的依賴觸發(fā)。
但是這樣寫有點(diǎn)耦合,我們把依賴收集這部分代碼封裝起來,寫成下面的樣子:
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)
}
removeSub (sub: Watcher) {
remove(this.subs, sub)
}
depend () {
if (Dep.target) {
this.addSub(Dep.target)
}
}
notify () {
// stabilize the subscriber list first
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
然后在改造一下 defineReactive
:
function defineReactive (data, key, val) {
let dep = new Dep() // 修改
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
dep.depend() // 修改
return val
},
set: function (newVal) {
if(val === newVal){
return
}
dep.notify() // 新增
val = newVal
}
})
}
這一次代碼看起來清晰多了,順便回答一下上面問的問題,依賴收集到哪?收集到Dep中,Dep是專門用來存儲依賴的。
收集誰?
上面我們假裝 window.target
是需要被收集的依賴,細(xì)心的同學(xué)可能已經(jīng)看到,上面的代碼 window.target
已經(jīng)改成了 Dep.target
,那 Dep.target
是什么?我們究竟要收集誰呢??
收集誰,換句話說是當(dāng)屬性發(fā)生變化后,通知誰。
我們要通知那個使用到數(shù)據(jù)的地方,而使用這個數(shù)據(jù)的地方有很多,而且類型還不一樣,有可能是模板,有可能是用戶寫的一個 watch,所以這個時候我們需要抽象出一個能集中處理這些不同情況的類,然后我們在依賴收集的階段只收集這個封裝好的類的實(shí)例進(jìn)來,通知也只通知它一個,然后它在負(fù)責(zé)通知其他地方,所以我們要抽象的這個東西需要先起一個好聽的名字,嗯,就叫它watcher吧~
所以現(xiàn)在可以回答上面的問題,收集誰??收集 Watcher。
什么是Watcher?
watcher 是一個中介的角色,數(shù)據(jù)發(fā)生變化通知給 watcher,然后watcher在通知給其他地方。
關(guān)于watcher我們先看一個經(jīng)典的使用方式:
// keypath
vm.$watch('a.b.c', function (newVal, oldVal) {
// do something
})
這段代碼表示當(dāng) data.a.b.c
這個屬性發(fā)生變化時,觸發(fā)第二個參數(shù)這個函數(shù)。
思考一下怎么實(shí)現(xiàn)這個功能呢?
好像只要把這個 watcher 實(shí)例添加到 data.a.b.c
這個屬性的 Dep 中去就行了,然后 data.a.b.c
觸發(fā)時,會通知到watcher,然后watcher在執(zhí)行參數(shù)中的這個回調(diào)函數(shù)。
好,思考完畢,開工,寫出如下代碼:
class Watch {
constructor (expOrFn, cb) {
// 執(zhí)行 this.getter() 就可以拿到 data.a.b.c
this.getter = parsePath(expOrFn)
this.cb = cb
this.value = this.get()
}
get () {
Dep.target = this
value = this.getter.call(vm, vm)
Dep.target = undefined
}
update () {
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldValue)
}
}
這段代碼可以把自己主動 push
到 data.a.b.c
的 Dep 中去。
因?yàn)槲以?get
這個方法中,先把 Dep.traget 設(shè)置成了 this
,也就是當(dāng)前watcher實(shí)例,然后在讀一下 data.a.b.c
的值。
因?yàn)樽x了 data.a.b.c
的值,所以肯定會觸發(fā) getter
。
觸發(fā)了 getter
上面我們封裝的 defineReactive
函數(shù)中有一段邏輯就會從 Dep.target
里讀一個依賴 push
到 Dep
中。
所以就導(dǎo)致,我只要先在 Dep.target 賦一個 this
,然后我在讀一下值,去觸發(fā)一下 getter
,就可以把 this
主動 push
到 keypath
的依賴中,有沒有很神奇~
依賴注入到 Dep
中去之后,當(dāng)這個 data.a.b.c
的值發(fā)生變化,就把所有的依賴循環(huán)觸發(fā) update 方法,也就是上面代碼中 update 那個方法。
update
方法會觸發(fā)參數(shù)中的回調(diào)函數(shù),將value 和 oldValue 傳到參數(shù)中。
所以其實(shí)不管是用戶執(zhí)行的 vm.$watch('a.b.c', (value, oldValue) => {})
還是模板中用到的data,都是通過 watcher 來通知自己是否需要發(fā)生變化的。
遞歸偵測所有key
現(xiàn)在其實(shí)已經(jīng)可以實(shí)現(xiàn)變化偵測的功能了,但是我們之前寫的代碼只能偵測數(shù)據(jù)中的一個 key,所以我們要加工一下 defineReactive
這個函數(shù):
// 新增
function walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
function defineReactive (data, key, val) {
walk(val) // 新增
let dep = new Dep()
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
dep.depend()
return val
},
set: function (newVal) {
if(val === newVal){
return
}
dep.notify()
val = newVal
}
})
}
這樣我們就可以通過執(zhí)行 walk(data)
,把 data
中的所有 key
都加工成可以被偵測的,因?yàn)槭且粋€遞歸的過程,所以 key
中的 value
如果是一個對象,那這個對象的所有key也會被偵測。
Array怎么進(jìn)行變化偵測?
現(xiàn)在又發(fā)現(xiàn)了新的問題,data
中不是所有的 value
都是對象和基本類型,如果是一個數(shù)組怎么辦??數(shù)組是沒有辦法通過 Object.defineProperty
來偵測到行為的。
vue 中對這個數(shù)組問題的解決方案非常的簡單粗暴,我說說vue是如何實(shí)現(xiàn)的,大體上分三步:
第一步:先把原生 Array
的原型方法繼承下來。
第二步:對繼承后的對象使用 Object.defineProperty
做一些攔截操作。
第三步:把加工后可以被攔截的原型,賦值到需要被攔截的 Array
類型的數(shù)據(jù)的原型上。
vue的實(shí)現(xiàn)
第一步:
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
第二步:
;[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
.forEach(function (method) {
// cache original method
const original = arrayProto[method]
Object.defineProperty(arrayMethods, method, {
value: function mutator (...args) {
console.log(method) // 打印數(shù)組方法
return original.apply(this, args)
},
enumerable: false,
writable: true,
configurable: true
})
})
現(xiàn)在可以看到,每當(dāng)被偵測的 array
執(zhí)行方法操作數(shù)組時,我都可以知道他執(zhí)行的方法是什么,并且打印到 console
中。
現(xiàn)在我要對這個數(shù)組方法類型進(jìn)行判斷,如果操作數(shù)組的方法是 push unshift splice (這種可以新增數(shù)組元素的方法),需要把新增的元素用上面封裝的 walk
來進(jìn)行變化檢測。
并且不論操作數(shù)組的是什么方法,我都要觸發(fā)消息,通知依賴列表中的依賴數(shù)據(jù)發(fā)生了變化。
那現(xiàn)在怎么訪問依賴列表呢,可能我們需要把上面封裝的 walk
加工一下:
// 工具函數(shù)
function def (obj: Object, key: string, val: any, enumerable?: boolean) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
})
}
export class Observer {
value: any;
dep: Dep;
vmCount: number; // number of vms that has this object as root $data
constructor (value: any) {
this.value = value
this.dep = new Dep() // 新增
this.vmCount = 0
def(value, '__ob__', this) // 新增
// 新增
if (Array.isArray(value)) {
this.observeArray(value)
} else {
this.walk(value)
}
}
/**
* Walk through each property and convert them into
* getter/setters. This method should only be called when
* value type is Object.
*/
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
/**
* Observe a list of Array items.
*/
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
new Observer(items[i])
}
}
}
我們定義了一個 Observer
類,他的職責(zé)是將 data
轉(zhuǎn)換成可以被偵測到變化的 data
,并且新增了對類型的判斷,如果是 value
的類型是 Array
循環(huán) Array將每一個元素丟到 Observer 中。
并且在 value 上做了一個標(biāo)記 __ob__
,這樣我們就可以通過 value
的 __ob__
拿到Observer實(shí)例,然后使用 __ob__
上的 dep.notify()
就可以發(fā)送通知啦。
然后我們在改進(jìn)一下Array原型的攔截器:
;[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
.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
})
})
可以看到寫了一個 switch
對 method
進(jìn)行判斷,如果是 push
,unshift
,splice
這種可以新增數(shù)組元素的方法就使用 ob.observeArray(inserted)
把新增的元素也丟到 Observer
中去轉(zhuǎn)換成可以被偵測到變化的數(shù)據(jù)。
在最后不論操作數(shù)組的方法是什么,都會調(diào)用 ob.dep.notify()
去通知 watcher
數(shù)據(jù)發(fā)生了改變。
arrayMethods 是怎么生效的?
現(xiàn)在我們有一個 arrayMenthods
是被加工后的 Array.prototype
,那么怎么讓這個對象應(yīng)用到Array
上面呢?
思考一下,我們不能直接修改 Array.prototype
因?yàn)檫@樣會污染全局的Array,我們希望 arrayMenthods
只對 data
中的Array
生效。
所以我們只需要把 arrayMenthods
賦值給 value
的 __proto__
上就好了。
我們改造一下 Observer
:
export class Observer {
constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
if (Array.isArray(value)) {
value.__proto__ = arrayMethods // 新增
this.observeArray(value)
} else {
this.walk(value)
}
}
}
如果不能使用 __proto__
,就直接循環(huán) arrayMethods
把它身上的這些方法直接裝到 value
身上好了。
什么情況不能使用 __proto__
我也不知道,各位大佬誰知道能否給我留個言?跪謝~
所以我們的代碼又要改造一下:
// can we use __proto__?
const hasProto = '__proto__' in {} // 新增
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 {
this.walk(value)
}
}
}
function protoAugment (target, src: Object, keys: any) {
target.__proto__ = src
}
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])
}
}
關(guān)于Array的問題
關(guān)于vue對Array的攔截實(shí)現(xiàn)上面剛說完,正因?yàn)檫@種實(shí)現(xiàn)方式,其實(shí)有些數(shù)組操作vue是攔截不到的,例如:
this.list[0] = 2
修改數(shù)組第一個元素的值,無法偵測到數(shù)組的變化,所以并不會觸發(fā) re-render
或 watch
等。
在例如:
this.list.length = 0
清空數(shù)組操作,無法偵測到數(shù)組的變化,所以也不會觸發(fā) re-render
或 watch
等。
因?yàn)関ue的實(shí)現(xiàn)方式就決定了無法對上面舉得兩個例子做攔截,也就沒有辦法做到響應(yīng),ES6是有能力做到的,在ES6之前是無法做到模擬數(shù)組的原生行為的,現(xiàn)在 ES6 的 Proxy 可以模擬數(shù)組的原生行為,也可以通過 ES6 的繼承來繼承數(shù)組原生行為,從而進(jìn)行攔截。
總結(jié)
最后掏出vue官網(wǎng)上的一張圖,這張圖其實(shí)非常清晰,就是一個變化偵測的原理圖。
getter
到 watcher
有一條線,上面寫著收集依賴,意思是說 getter
里收集 watcher
,也就是說當(dāng)數(shù)據(jù)發(fā)生 get
動作時開始收集 watcher
。
setter
到 watcher
有一條線,寫著 Notify
意思是說在 setter
中觸發(fā)消息,也就是當(dāng)數(shù)據(jù)發(fā)生 set
動作時,通知 watcher
。
Watcher
到 ComponentRenderFunction 有一條線,寫著 Trigger re-render
意思很明顯了。