Vue 最獨特的特性之一,是其非侵入性的響應(yīng)性系統(tǒng)(Vue 3 Reactivity System)。我們有必要研究一下它的底層原理。從它是如何構(gòu)建的、Vue 內(nèi)部使用的設(shè)計模式、以及利用它提高 Vue 調(diào)試技能,來確保掌握新的 Vue 3 模塊化響應(yīng)式庫。
理解響應(yīng)式(Understanding Reactivity)
當(dāng)你第一次看到 Vue 的響應(yīng)式式系統(tǒng)時,它看起來很神奇。
就拿這個簡單的 app 來說:
<div id="app">
<div>Price: ${{ product.price }}</div>
<div>Total: ${{ product.price * product.quantity }}</div>
<div>Taxes: ${{ totalPriceWithTax }}</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script>
var vm = new Vue({
el: '#app',
data: {
product: {
price: 5.00,
quantity: 2
}
},
computed: {
totalPriceWithTax() {
return this.product.price * this.product.quantity * 1.03
}
}
})
</script>
不知何故,Vue 的 Reactivity 系統(tǒng)知道一旦price發(fā)生變化,它就應(yīng)該做三件事:
● 更新我們網(wǎng)頁上的price值。
● 重新計算乘以price*quantity的表達(dá)式,并更新頁面。
● 再次調(diào)用totalPriceWithTax函數(shù)并更新頁面。
但是 Vue 的 Reactivity 系統(tǒng)如何知道price變化時要更新什么,以及它如何跟蹤一切?
這并不是 JavaScript 編程通常的工作方式。
例如,如果運(yùn)行下面??的代碼:
let product = { price: 5, quantity: 2 }
let total = product.price * product.quantity // 10?
product.price = 20
console.log(`total is ${total}`)
你認(rèn)為它會打印什么?由于我們沒有使用 Vue,它將打印 10。
>> total is 10
在 Vue 中,我們希望在price或quantity更新時更新total。我們想要:
>> total is 40
可惜的是,JavaScript 是過程式的,而不是響應(yīng)式的,所以這在現(xiàn)實生活中是行不通的。為了使total變成響應(yīng)式,我們必須使用 JavaScript 使事情的行為模式變得不同。
我們將使用與 Vue 3 相同的方法 (與 Vue 2 極其不同) 從頭開始構(gòu)建一個簡單的響應(yīng)式系統(tǒng)。然后再查看 Vue 3 源碼以發(fā)現(xiàn)我們編寫的這些模式。
構(gòu)建簡易版響應(yīng)式系統(tǒng)
首先,我們需要通過某種方式來告訴我們的應(yīng)用程序,“存儲我即將運(yùn)行的代碼(effect),我可能需要你在其他時間運(yùn)行它?!比缓笪覀冞\(yùn)行代碼,后面如果price或quantity變量更新了,再次運(yùn)行存儲的代碼。

我們可以通過記錄函數(shù)(effect)來做到這一點,以便我們可以再次運(yùn)行它。
let product = { price: 5, quantity: 2 }
let total = 0
let effect = function () {
total = product.price * product.quantity
})
track() // 記住這段 effect 代碼
effect() // 同時運(yùn)行它
我們在effect變量中存儲了一個匿名函數(shù),然后調(diào)用了一個track函數(shù)。還可以使用 ES6 箭頭語法將其改為??,使得代碼更為簡潔:
let effect = () => { total = product.price * product.quantity }
為了存儲我們的effects,我們將創(chuàng)建一個dep變量,它是一個 new Set 集,代表依賴關(guān)系。通常在觀察者設(shè)計模式中,依賴項會有訂閱者(在本例中是effects),當(dāng)對象(本例是product object)改變狀態(tài)時訂閱者會得到通知。
let dep = new Set() // 我們的 object 追蹤的 effects 列表
為了跟蹤我們的依賴,我們通過track函數(shù)將effects添加到這個集合中:
function track () {
dep.add(effect) // 存儲當(dāng)前的 effect
}
兩點通過對比 Vue 2 對存儲依賴模式的展開
(可以先把整體看完,回過頭再來看)
1.和 Vue 2 用
depend和notify記錄和執(zhí)行effects,為什么 Vue 3 更改成了track和trigger?
depend和notify是動詞,和它們的所有者(Dep 類的實例)相關(guān),可以說成一個 Dep 實例正在被依賴、或者正在通知它的訂閱者。
Vue 3從技術(shù)上來講已經(jīng)沒有 Dep 類(Class)了。Dep 類里的depend和notify現(xiàn)在被抽離到兩個獨立的函數(shù)(track和trigger)里。所以當(dāng)調(diào)用track和trigger更像是跟蹤 sth,而不是 sth 正在被依賴。只是一種形式上的轉(zhuǎn)變,就像從a.b=> b.call(a)
2.為什么在 Vue 2 中 Dep 是一個有
subscribers(訂閱者)的類,而 Vue 3 只是一個簡單的 Set 集?
因為兩個方法都被抽出了,Dep 類本身只剩下一個訂閱者集合,這樣依賴用一個類僅僅去封裝一個Set是沒有意義的,因此直接聲明一個Set,而不是創(chuàng)建一個對象去做這件事。
JavaScript Array 和 Set 之間的區(qū)別在于,Set 不能有重復(fù)的值,并且不像數(shù)組那樣使用索引。
我們正在存儲effect(在我們的例子中是 { total = price *quantity }),以便我們可以稍后運(yùn)行它。這是這個 dep Set 的可視化:

讓我們編寫一個觸發(fā)器函數(shù)(trigger)來運(yùn)行我們記錄的所有內(nèi)容。
function trigger() {
dep.forEach(effect => effect())
}
它會遍歷我們存儲在 dep Set 中的所有匿名函數(shù)并執(zhí)行它們中的每一個。然后我們只需在product的狀態(tài)改變后觸發(fā)trigger():
product.price = 20
console.log(total) // => 10
trigger()
console.log(total) // => 40
是不是很簡單?這里是完整的代碼:
let product = { price: 5, quantity: 2 }
let total = 0
let dep = new Set()
function track() {
dep.add(effect)
}
function trigger() {
dep.forEach(effect => effect())
}
let effect = () => {
total = product.price * product.quantity
}
track()
effect()
product.price = 20
console.log(total) // => 10
trigger() // 調(diào)用 trigger 才會再次運(yùn)行 dep 的 effect
console.log(total) // => 40
存在問題一:多個屬性
我們可以根據(jù)需要繼續(xù)跟蹤trigger,但是我們的響應(yīng)式對象具有不同的屬性,且每個屬性都需要自己的 dep (一組effects set 集),這個 dep 里的effects會在該屬性值改變時重新運(yùn)行。
比如我們的product對象:let product = { price: 5, quantity: 2 },
price屬性需要它自己的 dep (effects set集),quantity也需要它自己的 dep (effects Set集)。讓我們構(gòu)建一個解決方案來正確記錄這些。
當(dāng)現(xiàn)在調(diào)用track或trigger時,我們需要知道對象中的哪個屬性(price或quantity)是追蹤目標(biāo)。為此,我們將創(chuàng)建一個depsMap,它的類型為 Map。以下是它的可視化:

注意 depsMap 的每一個key,是我們想要添加(或追蹤)新effect的屬性名。因此,我們需要將 key 發(fā)送到 track 函數(shù)。每個key對應(yīng)的值是一個 dep (effects set集)
const depsMap = new Map()
function track(key) { // 確保這個 effect 被追蹤了
let dep = depsMap.get(key) // 獲取 key(屬性) set 時需要運(yùn)行的 dep (effects set 集)
if (!dep) {
// 目前還沒有依賴這個 key 的 effects
depsMap.set(key, (dep = new Set())) // 創(chuàng)建一個 new Set
}
dep.add(effect) // Add effect to dep
}
}
function trigger(key) {
let dep = depsMap.get(key) // 獲取此 key 的 dep (effects set)
if (dep) { // 如果存在
dep.forEach(effect => {
// 運(yùn)行所有 effects
effect()
})
}
}
let product = { price: 5, quantity: 2 }
let total = 0
let effect = () => {
total = product.price * product.quantity
}
track('quantity') // 存儲 quantity 的 effect
effect() // 運(yùn)行 effect
console.log(total) // --> 10
product.quantity = 3
trigger('quantity') // 觸發(fā) quantity 的 dep (effects set)
console.log(total) // --> 40
存在問題二:多個響應(yīng)式對象
這個方案看起來不錯,直到我們有多個需要track effects的響應(yīng)式對象,比如新增了一個let user = { id: 213, name: 'Joe Smith' }。
現(xiàn)在我們需要一種為每個對象存儲 depsMap 的方法。我們需要另一種 Map (WeakMap 類型),key 就是我們的響應(yīng)式對象(product、user)。WeakMap 是一個 JavaScript Map,它只使用對象作為鍵。它工作起來就像這樣:
let product = { price: 5, quantity: 2 }
const targetMap = new WeakMap()
targetMap.set(product, "example code to test")
console.log(targetMap.get(product)) // ---> "example code to test"
顯然這不是我們要使用的代碼。Vue 3 將用來存儲每個響應(yīng)式對象的屬性關(guān)聯(lián)的依賴的 Map 對象稱之為targetMap,因為我們會考慮 target 我們正在跟蹤的對象。這是我們可視化的targetMap:

targetMap存儲每個響應(yīng)式對象關(guān)聯(lián)的depsMap,depsMap存儲每個屬性的dep依賴,每個dep存儲一組 effect set 集,這些 effects 會在值發(fā)生變化時重新運(yùn)行。
之所以這樣嵌套式設(shè)計是因為:
在 Vue2 中,我們使用 ES5 進(jìn)行getter和setter轉(zhuǎn)換,當(dāng)我們用 for Each 遍歷對象上的key時,自然有一個小閉包為其屬性(key)存儲關(guān)聯(lián)的 Dep。
但是在 Vue 3,我們使用了 Proxy,proxy 的handler接收target和key,你并不能得到為每個屬性存儲關(guān)聯(lián) dependency 的一個個閉包。
所以我們需要給定一個target對象和一個target對象上的key,來保證我們始終能找對對應(yīng)的 dependency 實例。唯一的方法就是把它們(target和key)分到兩個等級不同的嵌套 maps。
當(dāng)我們現(xiàn)在調(diào)用track或trigger時,需要知道目標(biāo)是哪個對象。因此,當(dāng)我們會在調(diào)用它時發(fā)送target和key。
const targetMap = new WeakMap() // targetMap 存儲每個 object 更新時應(yīng)重新運(yùn)行的 effects
function track(target, key) { // 我們需要確保這個 effect 被追蹤了
let depsMap = targetMap.get(target) // 獲取此 target (響應(yīng)式對象) 當(dāng)前的 depsMap
if (!depsMap) {
// 不存在的話,新建一個 depsMap
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key) // 獲取 key (屬性) 被 set 時需要運(yùn)行的當(dāng)前 dependencies (effects)
if (!dep) {
// 如果 dependencies 不存在,新建一個 new Set
depsMap.set(key, (dep = new Set()))
}
dep.add(effect) // 把需要的 effect 添加到 dependency
}
function trigger(target, key) {
const depsMap = targetMap.get(target) // 此對象是否有任何具有dependencies (effects) 的屬性
if (!depsMap) {
return
}
let dep = depsMap.get(key) // 獲取與此屬性關(guān)聯(lián)的 dependencies (effects)
if (dep) {
dep.forEach(effect => {
// 遍歷 dep 運(yùn)行每個 effect
effect()
})
}
}
let product = { price: 5, quantity: 2 }
let total = 0
let effect = () => {
total = product.price * product.quantity
}
track(product, 'quantity') // 添加 effect 的時候需傳入對象和屬性
effect() // 執(zhí)行 effect
console.log(total) // --> 10
product.quantity = 3
trigger(product, 'quantity') // 觸發(fā)也需傳入對象和屬性
console.log(total) // --> 15
所以現(xiàn)在我們有一種非常有效的方法來跟蹤多個對象的 dependencies,這是構(gòu)建我們的響應(yīng)式系統(tǒng)時的一大難題。戰(zhàn)斗已經(jīng)結(jié)束了一半。在下一篇,我們將了解如何使用 ES6 代理自動調(diào)用track和trigger。
Vue 3 響應(yīng)式原理一 - Vue 3 Reactivity
Vue 3 響應(yīng)式原理二 - Proxy and Reflect
Vue 3 響應(yīng)式原理三 - activeEffect & ref
Vue 3 響應(yīng)式原理四 - Computed Values & Vue 3 源碼
課程鏈接:https://www.vuemastery.com/courses/vue-3-reactivity/vue3-reactivity

