宣告
本文是對於Build a Reactivity System的翻譯
目標讀者
使用過vue,並且對於vue實現響應式的原理感興趣的前端童鞋。
正文
本教程我們將使用一些簡單的技術(這些技術你在vue原始碼中也能看到)來建立一個簡單的響應式系統。這可以幫助你更好地理解Vue以及Vue的設計模式,同時可以讓你更加熟悉watchers和Dep class.
響應式系統
當你第一次看到vue的響應式系統工作起來的時候可能會覺得不可思議。
舉個例子:
<div id="app">
<div>Price: ${{ price }}</div>
<div>Total: ${{ price * quantity }}</div>
<div>Taxes: ${{ totalPriceWithTax }}</div>
</div>
複製程式碼
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script>
var vm = new Vue({
el: '#app',
data: {
price: 5.00,
quantity: 2
},
computed: {
totalPriceWithTax() {
return this.price * this.quantity * 1.03
}
}
})
</script>
複製程式碼
上述例子中,當price改變的時候,vue會做下面三件事情:
- 更新頁面上price的值。
- 重新計算表示式price * quatity,並且將計算後的值更新到頁面上。
- 呼叫totalPriceWithTax函式並更新頁面。
但是等一下,當price改變的時候vue是怎麼知道要更新哪些東西的?vue是怎麼跟蹤所有東西的?
這不是JavaScript程式設計通常的工作方式
如果這對於你來說不是很明顯的話,我們必須解決的一個大問題是程式設計通常不會以這種方式工作。舉個例子,如果執行下面的程式碼:
let price = 5
let quantity = 2
let total = price * quantity // 10 right?
price = 20
console.log(`total is ${total}`)
複製程式碼
你覺得這段程式碼最終列印的結果是多少?因為我們沒有使用Vue,所以最終列印的值是10
>> total is 10
複製程式碼
在Vue中我們想要total的值可以隨著price或者quantity值的改變而改變,我們想要:
>> total is 40
複製程式碼
不幸的是,JavaScript本身是非響應式的。為了讓total具備響應式,我們需要使用JavaScript來讓事情表現的有所不同。
問題
我們需要先儲存total的計算過程,這樣我們才能在price或者quantity改變的時候重新執行total的計算過程。
解決方案
首先,我們需要告訴我們的應用,“這段我將要執行的程式碼,儲存起來,後面我可能需要你再次執行這段程式碼。”這樣當price或者quantity改變的時候,我們可以再次執行之前儲存起來的程式碼(來更新total)。
我們可以通過將total的計算過程儲存成函式的形式來做,這樣後面我們能夠再次執行它。
let price = 5
let quantity = 2
let total = 0
let target = null
target = function () {
total = price * quantity
})
record() // Remember this in case we want to run it later
target() // Also go ahead and run it
複製程式碼
請注意我們需要將匿名函式賦值給target變數,然後呼叫record函式。使用es6的箭頭函式也可以寫成下面這樣子:
target = () => { total = price * quantity }
複製程式碼
record函式的定義挺簡單的:
let storage = [] // We'll store our target functions in here
function record () { // target = () => { total = price * quantity }
storage.push(target)
}
複製程式碼
這裡我們將target(這裡指的就是:{ total = price * quantity })存起來以便後面可以執行它,或許我們可以弄個replay函式來執行所有存起來的計算過程。
function replay (){
storage.forEach(run => run())
}
複製程式碼
replay函式會遍歷所有我們儲存在storage中的匿名函式然後挨個執行這些匿名函式。 緊接著,我們可以這樣用replay函式:
price = 20
console.log(total) // => 10
replay()
console.log(total) // => 40
複製程式碼
很簡單吧?以下是完整的程式碼。
let price = 5
let quantity = 2
let total = 0
let target = null
let storage = []
function record () {
storage.push(target)
}
function replay () {
storage.forEach(run => run())
}
target = () => { total = price * quantity }
record()
target()
price = 20
console.log(total) // => 10
replay()
console.log(total) // => 40
複製程式碼
問題
上面的程式碼雖然也能工作,但是可能並不好。或許可以抽取個class,這個class負責維護targets列表,然後在我們需要重新執行targets列表的時候接收通知(並執行targets列表中的所有匿名函式)。
解決方案:A Dependency Class
一種解決方案是我們可以把這種行為封裝進一個類裡面,一個實現了普通觀察者模式的Dependency Class
所以,如果我們建立個JavaScript類來管理我們的依賴的話,程式碼可能長成下面這樣:
class Dep { // Stands for dependency
constructor () {
this.subscribers = [] // The targets that are dependent, and should be
// run when notify() is called.
}
depend() { // This replaces our record function
if (target && !this.subscribers.includes(target)) {
// Only if there is a target & it's not already subscribed
this.subscribers.push(target)
}
}
notify() { // Replaces our replay function
this.subscribers.forEach(sub => sub()) // Run our targets, or observers.
}
}
複製程式碼
請注意,我們這裡不用storage,而是用subscribers來儲存匿名函式,同時,我們不用record而是通過呼叫depend來收集依賴,並且我們使用notify替代了原來的replay。以下是Dep類的用法:
const dep = new Dep()
let price = 5
let quantity = 2
let total = 0
let target = () => { total = price * quantity }
dep.depend() // Add this target to our subscribers
target() // Run it to get the total
console.log(total) // => 10 .. The right number
price = 20
console.log(total) // => 10 .. No longer the right number
dep.notify() // Run the subscribers
console.log(total) // => 40 .. Now the right number
複製程式碼
上面的程式碼和之前的程式碼功能上是一致的,但是程式碼看起來更具有複用性(Dep類可以複用)。唯一看起來有點奇怪的地方就是設定和執行target的地方。
問題
後面我們會為每個變數建立個Dep例項,同時如果可以將建立匿名函式的邏輯封裝起來的話就更好了,或許我們可以用個watcher函式來做這件事情。
所以我們不用通過呼叫以下程式碼來收集依賴
target = () => { total = price * quantity }
dep.depend()
target()
複製程式碼
而是通過呼叫watcher函式來收集依賴(是不是趕腳程式碼清晰很多?):
watcher(() => {
total = price * quantity
})
複製程式碼
解決方案:A Watcher Function
Watcher fucntion的定義如下:
function watcher(myFunc) {
target = myFunc // Set as the active target
dep.depend() // Add the active target as a dependency
target() // Call the target
target = null // Reset the target
}
複製程式碼
watcher函式接收一個myFunc,把它賦值給全域性的target變數,然後通過呼叫dep.depend()將target加到subscribers列表中,緊接著呼叫target函式,然後重置target變數。
現在如果我們執行以下程式碼:
price = 20
console.log(total)
dep.notify()
console.log(total)
複製程式碼
>> 10
>> 40
複製程式碼
你可能會想為什麼要把target作為一個全域性變數,而不是在需要的時候傳入函式。別捉急,這麼做自然有這麼做的道理,看到本教程結尾就闊以一目瞭然啦。
問題
現在我們有了個簡單的Dep class,但是我們真正想要的是每個變數都擁有自己的dep例項,在繼續後面的教程之前讓我們先把變數變成某個物件的屬性:
let data = { price: 5, quantity: 2 }
複製程式碼
讓我們先假設下data上的每個屬性(price和quantity)都擁有自己的dep例項。
這樣當我們執行:
watcher(() => {
total = data.price * data.quantity
})
複製程式碼
因為data.price的值被訪問了,我想要price的dep例項可以將上面的匿名函式收集到自己的subscribers列表裡面。data.quantity也是如此。
如果這時候有個另外的匿名函式裡面用到了data.price,我也想這個匿名函式被加到price自帶的dep類裡面。
問題來了,我們什麼時候呼叫price的dep.notify()呢?當price被賦值的時候。在這篇文章的結尾我希望能夠直接進入console做以下的事情:
>> total
10
>> price = 20 // When this gets run it will need to call notify() on the price
>> total
40
複製程式碼
要實現以上意圖,我們需要能夠在data的所有屬性被訪問或者被賦值的時候執行某些操作。當data下的屬性被訪問的時候我們就把target加入到subscribers列表裡面,當data下的屬性被重新賦值的時候我們就觸發notify()執行所有儲存在subscribes列表裡面的匿名函式。
解決方案:Object.defineProperty()
我們需要學習下Object.defineProperty()函式是怎麼用的。defineProperty函式允許我們為屬性定義getter和setter函式,在我使用defineProperty函式之前先舉個非常簡單的例子:
let data = { price: 5, quantity: 2 }
Object.defineProperty(data, 'price', { // For just the price property
get() { // Create a get method
console.log(`I was accessed`)
},
set(newVal) { // Create a set method
console.log(`I was changed`)
}
})
data.price // This calls get()
data.price = 20 // This calls set()
複製程式碼
>> I was accessed
>> I was changed
複製程式碼
正如你所看到的,上面的程式碼僅僅列印兩個log。然而,上面的程式碼並不真的get或者set任何值,因為我們並沒有實現,下面我們加上。
let data = { price: 5, quantity: 2 }
let internalValue = data.price // Our initial value.
Object.defineProperty(data, 'price', { // For just the price property
get() { // Create a get method
console.log(`Getting price: ${internalValue}`)
return internalValue
},
set(newVal) { // Create a set method
console.log(`Setting price to: ${newVal}` )
internalValue = newVal
}
})
total = data.price * data.quantity // This calls get()
data.price = 20 // This calls set()
複製程式碼
Getting price: 5
Setting price to: 20
複製程式碼
所以通過defineProperty函式我們可以在get和set值的時候收到通知(就是我們可以知道什麼時候屬性被訪問了,什麼時候屬性被賦值了),我們可以用Object.keys來遍歷data上所有的屬性然後為它們新增getter和setter屬性。
let data = { price: 5, quantity: 2 }
Object.keys(data).forEach(key => { // We're running this for each item in data now
let internalValue = data[key]
Object.defineProperty(data, key, {
get() {
console.log(`Getting ${key}: ${internalValue}`)
return internalValue
},
set(newVal) {
console.log(`Setting ${key} to: ${newVal}` )
internalValue = newVal
}
})
})
total = data.price * data.quantity
data.price = 20
複製程式碼
現在data上的每個屬性都有getter和setter了。
把這兩種想法放在一起
total = data.price * data.quantity
複製程式碼
當上面的程式碼執行並且getprice的值的時候,我們想要price記住這個匿名函式(target)。這樣當price變動的時候,可以觸發執行這個匿名函式。
- Get => Remember this anonymous function, we’ll run it again when our value changes.
- Set => Run the saved anonymous function, our value just changed.
或者:
- Price accessed (get) => call dep.depend() to save the current target
- Price set => call dep.notify() on price, re-running all the targets
下面讓我們把這兩種想法組合起來:
let data = { price: 5, quantity: 2 }
let target = null
// This is exactly the same Dep class
class Dep {
constructor () {
this.subscribers = []
}
depend() {
if (target && !this.subscribers.includes(target)) {
// Only if there is a target & it's not already subscribed
this.subscribers.push(target)
}
}
notify() {
this.subscribers.forEach(sub => sub())
}
}
// Go through each of our data properties
Object.keys(data).forEach(key => {
let internalValue = data[key]
// Each property gets a dependency instance
const dep = new Dep()
Object.defineProperty(data, key, {
get() {
dep.depend() // <-- Remember the target we're running
return internalValue
},
set(newVal) {
internalValue = newVal
dep.notify() // <-- Re-run stored functions
}
})
})
// My watcher no longer calls dep.depend,
// since that gets called from inside our get method.
function watcher(myFunc) {
target = myFunc
target()
target = null
}
watcher(() => {
data.total = data.price * data.quantity
})
複製程式碼
在控制檯看下:
就像我們想的一樣!這時候price和quantity都是響應式的!當price和quantity更新的時候我們的total都會及時地更新。看下Vue
下面的圖現在應該看起來有點感覺了:
你看見紫色的帶有getter和setter的Data圓圈沒?是不是看起來很熟悉!每個component例項都擁有一個watcher例項用於通過getters和setters來收集依賴。當某個setter後面被呼叫的時候,它會通知相應地watcher從而導致元件重新渲染。下面是我新增了一些標註的圖: Yeah!現在你對Vue的響應式有所瞭解了沒? 很顯然,Vue內部實現會比這個更加複雜,但是通過這篇文章你知道了一些基礎知識。在下個教程裡面我們會深入Vue內部,看看原始碼裡面的實現是否和我們的類似。我們學到了什麼?
- 如何建立一個可以同來收集依賴(depend)並執行所有依賴(notify)的Dep class
- 如何建立watcher來管理我們當前正在執行的程式碼(target)
- 如何使用Object.defineProperty()來建立getters和setters。