原文:https://zhuanlan.zhihu.com/p/29318017
每當(dāng)問到VueJS響應(yīng)式原理,大家可能都會(huì)脫口而出 Vue通過 Object.defineProperty 方法把data對象的全部屬性轉(zhuǎn)化成 getter/setter,當(dāng)屬性被訪問或修改時(shí)通知變化 。然而,其內(nèi)部深層的響應(yīng)式原理可能很多人都沒有完全理解,網(wǎng)絡(luò)上關(guān)于其響應(yīng)式原理的文章質(zhì)量也是參差不齊,大多是貼個(gè)代碼加段注釋了事。本文將會(huì)從一個(gè)非常簡單的例子出發(fā),一步一步分析響應(yīng)式原理的具體實(shí)現(xiàn)思路。
一、使數(shù)據(jù)對象變得“可觀測”
首先,我們定義一個(gè)數(shù)據(jù)對象,就以王者榮耀里面的其中一個(gè)英雄為例子:
const hero = {
health: 3000,
IQ: 150
}
我們定義了這個(gè)英雄的生命值為3000,IQ為150。但是現(xiàn)在還不知道他是誰,不過這不重要,只需要知道這個(gè)英雄將會(huì)貫穿我們整篇文章,而我們的目的就是通過這個(gè)英雄的屬性,知道這個(gè)英雄是誰。
現(xiàn)在我們可以通過hero.health和hero.IQ直接讀寫這個(gè)英雄對應(yīng)的屬性值。但是,當(dāng)這個(gè)英雄的屬性被讀取或修改時(shí),我們并不知情。那么應(yīng)該如何做才能夠讓英雄主動(dòng)告訴我們,他的屬性被修改了呢?這時(shí)候就需要借助Object.defineProperty的力量了。
關(guān)于Object.defineProperty的介紹,MDN上是這么說的:
Object.defineProperty() 方法會(huì)直接在一個(gè)對象上定義一個(gè)新屬性,或者修改一個(gè)對象的現(xiàn)有屬性, 并返回這個(gè)對象。
在本文中,我們只使用這個(gè)方法使對象變得“可觀測”,更多關(guān)于這個(gè)方法的具體內(nèi)容,請參考MDN Object.defineProperty(),就不再贅述了。
那么如何讓這個(gè)英雄主動(dòng)通知我們其屬性的讀寫情況呢?首先改寫一下上面的例子:
let hero = {}
let val = 3000
Object.defineProperty(hero, 'health', {
get () {
console.log('我的health屬性被讀取了!')
return val
},
set (newVal) {
console.log('我的health屬性被修改了!')
val = newVal
}
})
我們通過Object.defineProperty方法,給hero定義了一個(gè)health屬性,這個(gè)屬性在被讀寫的時(shí)候都會(huì)觸發(fā)一段console.log。現(xiàn)在來嘗試一下:
console.log(hero.health)
// -> 3000
// -> 我的health屬性被讀取了!
hero.health = 5000
// -> 我的health屬性被修改了
可以看到,英雄已經(jīng)可以主動(dòng)告訴我們其屬性的讀寫情況了,這也意味著,這個(gè)英雄的數(shù)據(jù)對象已經(jīng)是“可觀測”的了。為了把英雄的所有屬性都變得可觀測,我們可以想一個(gè)辦法:
/**
* 使一個(gè)對象轉(zhuǎn)化成可觀測對象
* @param { Object } obj 對象
* @param { String } key 對象的key
* @param { Any } val 對象的某個(gè)key的值
*/
function defineReactive (obj, key, val) {
Object.defineProperty(obj, key, {
get () {
// 觸發(fā)getter
console.log(`我的${key}屬性被讀取了!`)
return val
},
set (newVal) {
// 觸發(fā)setter
console.log(`我的${key}屬性被修改了!`)
val = newVal
}
})
}
/**
* 把一個(gè)對象的每一項(xiàng)都轉(zhuǎn)化成可觀測對象
* @param { Object } obj 對象
*/
function observable (obj) {
const keys = Object.keys(obj)
keys.forEach((key) => {
defineReactive(obj, key, obj[key])
})
return obj
}
現(xiàn)在我們可以把英雄這么定義:
const hero = observable({
health: 3000,
IQ: 150
})
讀者們可以在控制臺(tái)自行嘗試讀寫英雄的屬性,看看它是不是已經(jīng)變得可觀測的。
二、計(jì)算屬性
現(xiàn)在,英雄已經(jīng)變得可觀測,任何的讀寫操作他都會(huì)主動(dòng)告訴我們,但也僅此而已,我們?nèi)匀徊恢浪钦l。如果我們希望在修改英雄的生命值和IQ之后,他能夠主動(dòng)告訴他的其他信息,這應(yīng)該怎樣才能辦到呢?假設(shè)可以這樣:
watcher(hero, 'type', () => {
return hero.health > 4000 ? '坦克' : '脆皮'
})
我們定義了一個(gè)watcher作為“監(jiān)聽器”,它監(jiān)聽了hero的type屬性。這個(gè)type屬性的值取決于hero.health,換句話來說,當(dāng)hero.health發(fā)生變化時(shí),hero.type也應(yīng)該發(fā)生變化,前者是后者的依賴。我們可以把這個(gè)hero.type稱為“計(jì)算屬性”。
那么,我們應(yīng)該怎樣才能正確構(gòu)造這個(gè)監(jiān)聽器呢?可以看到,在設(shè)想當(dāng)中,監(jiān)聽器接收三個(gè)參數(shù),分別是被監(jiān)聽的對象、被監(jiān)聽的屬性以及回調(diào)函數(shù),回調(diào)函數(shù)返回一個(gè)該被監(jiān)聽屬性的值。順著這個(gè)思路,我們嘗試著編寫一段代碼:
/**
* 當(dāng)計(jì)算屬性的值被更新時(shí)調(diào)用
* @param { Any } val 計(jì)算屬性的值
*/
function onComputedUpdate (val) {
console.log(`我的類型是:${val}`);
}
/**
* 觀測者
* @param { Object } obj 被觀測對象
* @param { String } key 被觀測對象的key
* @param { Function } cb 回調(diào)函數(shù),返回“計(jì)算屬性”的值
*/
function watcher (obj, key, cb) {
Object.defineProperty(obj, key, {
get () {
const val = cb()
onComputedUpdate(val)
return val
},
set () {
console.error('計(jì)算屬性無法被賦值!')
}
})
}
現(xiàn)在我們可以把英雄放在監(jiān)聽器里面,嘗試跑一下上面的代碼:
watcher(hero, 'type', () => {
return hero.health > 4000 ? '坦克' : '脆皮'
})
hero.type
hero.health = 5000
hero.type
// -> 我的health屬性被讀取了!
// -> 我的類型是:脆皮
// -> 我的health屬性被修改了!
// -> 我的health屬性被讀取了!
// -> 我的類型是:坦克
現(xiàn)在看起來沒毛病,一切都運(yùn)行良好,是不是就這樣結(jié)束了呢?別忘了,我們現(xiàn)在是通過手動(dòng)讀取hero.type來獲取這個(gè)英雄的類型,并不是他主動(dòng)告訴我們的。如果我們希望讓英雄能夠在health屬性被修改后,第一時(shí)間主動(dòng)發(fā)起通知,又該怎么做呢?這就涉及到本文的核心知識點(diǎn)——依賴收集。
三、依賴收集
我們知道,當(dāng)一個(gè)可觀測對象的屬性被讀寫時(shí),會(huì)觸發(fā)它的getter/setter方法。換個(gè)思路,如果我們可以在可觀測對象的getter/setter里面,去執(zhí)行監(jiān)聽器里面的onComputedUpdate()方法,是不是就能夠?qū)崿F(xiàn)讓對象主動(dòng)發(fā)出通知的功能呢?
由于監(jiān)聽器內(nèi)的onComputedUpdate()方法需要接收回調(diào)函數(shù)的值作為參數(shù),而可觀測對象內(nèi)并沒有這個(gè)回調(diào)函數(shù),所以我們需要借助一個(gè)第三方來幫助我們把監(jiān)聽器和可觀測對象連接起來。
這個(gè)第三方就做一件事情——收集監(jiān)聽器內(nèi)的回調(diào)函數(shù)的值以及onComputedUpdate()方法。
現(xiàn)在我們把這個(gè)第三方命名為“依賴收集器”,一起來看看應(yīng)該怎么寫:
const Dep = {
target: null
}
就是這么簡單。依賴收集器的target就是用來存放監(jiān)聽器里面的onComputedUpdate()方法的。
定義完依賴收集器,我們回到監(jiān)聽器里,看看應(yīng)該在什么地方把onComputedUpdate()方法賦值給Dep.target:
function watcher (obj, key, cb) {
// 定義一個(gè)被動(dòng)觸發(fā)函數(shù),當(dāng)這個(gè)“被觀測對象”的依賴更新時(shí)調(diào)用
const onDepUpdated = () => {
const val = cb()
onComputedUpdate(val)
}
Object.defineProperty(obj, key, {
get () {
Dep.target = onDepUpdated
// 執(zhí)行cb()的過程中會(huì)用到Dep.target,
// 當(dāng)cb()執(zhí)行完了就重置Dep.target為null
const val = cb()
Dep.target = null
return val
},
set () {
console.error('計(jì)算屬性無法被賦值!')
}
})
}
我們在監(jiān)聽器內(nèi)部定義了一個(gè)新的onDepUpdated()方法,這個(gè)方法很簡單,就是把監(jiān)聽器回調(diào)函數(shù)的值以及onComputedUpdate()給打包到一塊,然后賦值給Dep.target。這一步非常關(guān)鍵,通過這樣的操作,依賴收集器就獲得了監(jiān)聽器的回調(diào)值以及onComputedUpdate()方法。作為全局變量,Dep.target理所當(dāng)然的能夠被可觀測對象的getter/setter所使用。
重新看一下我們的watcher實(shí)例:
watcher(hero, 'type', () => {
return hero.health > 4000 ? '坦克' : '脆皮'
})
在它的回調(diào)函數(shù)中,調(diào)用了英雄的health屬性,也就是觸發(fā)了對應(yīng)的getter函數(shù)。理清楚這一點(diǎn)很重要,因?yàn)榻酉聛砦覀冃枰氐蕉x可觀測對象的defineReactive()方法當(dāng)中,對它進(jìn)行改寫:
function defineReactive (obj, key, val) {
const deps = []
Object.defineProperty(obj, key, {
get () {
if (Dep.target && deps.indexOf(Dep.target) === -1) {
deps.push(Dep.target)
}
return val
},
set (newVal) {
val = newVal
deps.forEach((dep) => {
dep()
})
}
})
}
可以看到,在這個(gè)方法里面我們定義了一個(gè)空數(shù)組deps,當(dāng)getter被觸發(fā)的時(shí)候,就會(huì)往里面添加一個(gè)Dep.target?;氐疥P(guān)鍵知識點(diǎn)Dep.target等于監(jiān)聽器的onComputedUpdate()方法,這個(gè)時(shí)候可觀測對象已經(jīng)和監(jiān)聽器捆綁到一塊。任何時(shí)候當(dāng)可觀測對象的setter被觸發(fā)時(shí),就會(huì)調(diào)用數(shù)組中所保存的Dep.target方法,也就是自動(dòng)觸發(fā)監(jiān)聽器內(nèi)部的onComputedUpdate()方法。
至于為什么這里的deps是一個(gè)數(shù)組而不是一個(gè)變量,是因?yàn)榭赡芡粋€(gè)屬性會(huì)被多個(gè)計(jì)算屬性所依賴,也就是存在多個(gè)Dep.target。定義deps為數(shù)組,若當(dāng)前屬性的setter被觸發(fā),就可以批量調(diào)用多個(gè)計(jì)算屬性的onComputedUpdate()方法了。
完成了這些步驟,基本上我們整個(gè)響應(yīng)式系統(tǒng)就已經(jīng)搭建完成,下面貼上完整的代碼:
/**
* 定義一個(gè)“依賴收集器”
*/
const Dep = {
target: null
}
/**
* 使一個(gè)對象轉(zhuǎn)化成可觀測對象
* @param { Object } obj 對象
* @param { String } key 對象的key
* @param { Any } val 對象的某個(gè)key的值
*/
function defineReactive (obj, key, val) {
const deps = []
Object.defineProperty(obj, key, {
get () {
console.log(`我的${key}屬性被讀取了!`)
if (Dep.target && deps.indexOf(Dep.target) === -1) {
deps.push(Dep.target)
}
return val
},
set (newVal) {
console.log(`我的${key}屬性被修改了!`)
val = newVal
deps.forEach((dep) => {
dep()
})
}
})
}
/**
* 把一個(gè)對象的每一項(xiàng)都轉(zhuǎn)化成可觀測對象
* @param { Object } obj 對象
*/
function observable (obj) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
return obj
}
/**
* 當(dāng)計(jì)算屬性的值被更新時(shí)調(diào)用
* @param { Any } val 計(jì)算屬性的值
*/
function onComputedUpdate (val) {
console.log(`我的類型是:${val}`)
}
/**
* 觀測者
* @param { Object } obj 被觀測對象
* @param { String } key 被觀測對象的key
* @param { Function } cb 回調(diào)函數(shù),返回“計(jì)算屬性”的值
*/
function watcher (obj, key, cb) {
// 定義一個(gè)被動(dòng)觸發(fā)函數(shù),當(dāng)這個(gè)“被觀測對象”的依賴更新時(shí)調(diào)用
const onDepUpdated = () => {
const val = cb()
onComputedUpdate(val)
}
Object.defineProperty(obj, key, {
get () {
Dep.target = onDepUpdated
// 執(zhí)行cb()的過程中會(huì)用到Dep.target,
// 當(dāng)cb()執(zhí)行完了就重置Dep.target為null
const val = cb()
Dep.target = null
return val
},
set () {
console.error('計(jì)算屬性無法被賦值!')
}
})
}
const hero = observable({
health: 3000,
IQ: 150
})
watcher(hero, 'type', () => {
return hero.health > 4000 ? '坦克' : '脆皮'
})
console.log(`英雄初始類型:${hero.type}`)
hero.health = 5000
// -> 我的health屬性被讀取了!
// -> 英雄初始類型:脆皮
// -> 我的health屬性被修改了!
// -> 我的health屬性被讀取了!
// -> 我的類型是:坦克
四、代碼優(yōu)化
在上面的例子中,依賴收集器只是一個(gè)簡單的對象,其實(shí)在defineReactive()內(nèi)部的deps數(shù)組等和依賴收集有關(guān)的功能,都應(yīng)該集成在Dep實(shí)例當(dāng)中,所以我們可以把依賴收集器改寫一下:
class Dep {
constructor () {
this.deps = []
}
depend () {
if (Dep.target && this.deps.indexOf(Dep.target) === -1) {
this.deps.push(Dep.target)
}
}
notify () {
this.deps.forEach((dep) => {
dep()
})
}
}
Dep.target = null
同樣的道理,我們對observable和watcher都進(jìn)行一定的封裝與優(yōu)化,使這個(gè)響應(yīng)式系統(tǒng)變得模塊化:
class Observable {
constructor (obj) {
return this.walk(obj)
}
walk (obj) {
const keys = Object.keys(obj)
keys.forEach((key) => {
this.defineReactive(obj, key, obj[key])
})
return obj
}
defineReactive (obj, key, val) {
const dep = new Dep()
Object.defineProperty(obj, key, {
get () {
dep.depend()
return val
},
set (newVal) {
val = newVal
dep.notify()
}
})
}
}
class Watcher {
constructor (obj, key, cb, onComputedUpdate) {
this.obj = obj
this.key = key
this.cb = cb
this.onComputedUpdate = onComputedUpdate
return this.defineComputed()
}
defineComputed () {
const self = this
const onDepUpdated = () => {
const val = self.cb()
this.onComputedUpdate(val)
}
Object.defineProperty(self.obj, self.key, {
get () {
Dep.target = onDepUpdated
const val = self.cb()
Dep.target = null
return val
},
set () {
console.error('計(jì)算屬性無法被賦值!')
}
})
}
}
然后我們來跑一下:
const hero = new Observable({
health: 3000,
IQ: 150
})
new Watcher(hero, 'type', () => {
return hero.health > 4000 ? '坦克' : '脆皮'
}, (val) => {
console.log(`我的類型是:${val}`)
})
console.log(`英雄初始類型:${hero.type}`)
hero.health = 5000
// -> 英雄初始類型:脆皮
// -> 我的類型是:坦克
五、尾聲
看到上述的代碼,是不是發(fā)現(xiàn)和VueJS源碼里面的很像?其實(shí)VueJS的思路和原理也是類似的,只不過它做了更多的事情,但核心還是在這里邊。