深入響應式原理 — Vue.js
https://medium.com/vue-mastery/the-best-explanation-of-javascript-reactivity-fea6112dd80d
https://www.vuemastery.com/courses/advanced-components/evan-you-on-proxies/
很多前端 JavaScript 框架,包含但不限於(Angular,React,Vue)都擁有自己的響應式引擎。通過了解響應式變成原理以及具體的實現方式,可以提成對既有響應式框架的高效應用。
響應式系統
我們看一下 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>
複製程式碼
Vue 知道每當 price
發生變換時,它做了如下三件事情:
- 在頁面中更新
price
的值。 - 在頁面中重新計算
price * quantity
表示式,並更新。 - 呼叫
totalPriceWithTax
方法,並更新。
但是等一下,這裡有些疑惑,Vue 是怎麼知道 price
更新了呢,如何去追蹤更新的具體過程呢?
響應式在原生 JavaScript 中如何實現的呢,我們從宣告變數開始吧~
let price = 5
let quantity = 2
let total = price * quantity // 10 ?
price = 20
console.log(`total is ${total}`) // 10
// 我們想要的 total 值希望是更新後的 40
複製程式碼
⚠️ 問題
我們需要將 total
的計算過程存起來,這樣我們就能夠在 price
或者 quantity
變化時執行計算過程。
✅ 方案
首先,我們需要告訴應用程式,“這裡有一個關於計算的方法,存起來,我會在資料更新的時候去執行它。“
我們建立一個記錄函式並執行它:
let price = 5
let quantity = 2
let total = 0
let target = null
target = () => { total = price * quantity }
record() // 記錄我們想要執行的例項
target() // 執行 total 計算過程
複製程式碼
簡單定義一個 record
:
let storage = [] // 將 target 函式存在這裡
function record () { // target = () => { total = price * quantity }
storage.push(target)
}
複製程式碼
我們儲存了 target
函式,我們需要執行它,需要頂一個 replay
函式來執行我們記錄的函式:
function replay () {
storage.forEach( run => run() )
}
複製程式碼
在程式碼中我們執行:
price = 20
console.log(total) // => 10
replay()
console.log(total) // => 40
複製程式碼
是不是足夠的簡單,程式碼可讀性好,並且可以執行多次。FYI,這裡用了最基礎的方式進行編碼,先掃盲。
let price = 5
let quantity = 2
let total = 0
let target = null
let storage = []
target = () => { total = price * quantity }
function record () {
storage.push(target)
}
function replay () {
storage.forEach( run => run() )
}
record()
target()
price = 20
console.log(total) // => 10
replay()
console.log(total) // => 40
複製程式碼
物件化
⚠️ 問題
我們可以不斷記錄我們需要的 target
, 並進行 record
,但是我們需要更健壯的模式去擴充套件我們的應用。也許物件導向的方式可以維護一個 targe 列表,我們用通知的方式進行回撥。
✅ 方案 Dependency Class
我們通過一個屬於自己的類進行行為的封裝,一個標準的依賴類 Dependency Class,實現觀察者模式。
如果我們想要建立一個管理依賴的類,標準模式如下:
class Dep { // Stands for dependency
constructor () {
this.subscribers = [] // 依賴陣列,當 notify() 呼叫時執行
}
depend () {
if (target && !this.subscribers.includes(target)) {
// target 存在並且不存在於依賴陣列中,進行依賴注入
this.subscribers.push(target)
}
}
notify () { // 替代之前的 replay 函式
this.subscribers.forEach(sub => sub()) // 執行我們的 targets,或者觀察者函式
}
}
複製程式碼
注意之前替換的方法,storage
替換成了建構函式中的 subscribers
。recod
函式替換為 depend
。replay
函式替換為 notify
。
現在在執行:
const dep = new Dep()
let price = 5
let quantity = 2
let total = 0
let target = () => { total = price * quantity }
dep.depend() // 依賴注入
target() // 計算 total
console.log(total) // => 10
price = 20
console.log(total) // => 10
dep.notify()
console.log(total) // => 40
複製程式碼
工作正常,到這一步感覺奇怪的地方,配置 和 執行 target
的地方。
觀察者
⚠️ 問題
之後我們希望能夠將 Dep 類應用在每一個變數中,然後優雅地通過匿名函式去觀察更新。可能需要一個觀察者 watcher
函式去滿足這樣的行為。
我們需要替換的程式碼:
target = () => { total = price * quantity }
dep.depend()
target()
複製程式碼
替換為:
watcher( () => {
total = price * quantit
})
複製程式碼
✅ 方案 A Watcher Function
我們先定義一個簡單的 watcher 函式:
function watcher (myFunc) {
target = myFunc // 動態配置 target
dep.depend() // 依賴注入
target() // 回撥 target 方法
target = null // 重置 target
}
複製程式碼
正如你所見, watcher
函式傳入一個 myFunc
的形參,配置全域性變數 target
,呼叫 dep.depend()
進行依賴注入,回撥 target
方法,最後,重置 target
。
執行一下:
price = 20
console.log(total) // => 10
dep.notify()
console.log(total) // => 40
複製程式碼
你可能會質疑,為什麼我們要對一個全域性變數的 target
進行操作,這顯得很傻,為什麼不用引數傳遞進行操作呢?文章的最後將揭曉答案,答案也是顯而易見的。
資料抽象
⚠️ 問題
我們現在有一個單一的 Dep class
,但是我們真正想要的是我們每一個變數都擁有自己的 Dep
。讓我們先將資料抽象到 properties
。
let data = { price: 5, quantity: 2 }
複製程式碼
將設每個屬性都有自己的內建 Dep 類
然我我們執行:
watcher( () => {
total = data.price * data.quantit
})
複製程式碼
當 data.price
的 value 開始存取時,我想讓關於 price
屬性的 Dep 類 push 我們的匿名函式(儲存在 target 中)進入 subscriber 陣列(通過呼叫 dep.depend())。而當 quantity
的 value 開始存取時,我們也做同樣的事情。
如果我們有其他的匿名函式,假設存取了 data.price
,同樣的在 price
的 Dep 類中 push 此匿名函式。
當我們想通過 dep.notify()
進行 price
的依賴回撥時候。我們想 在 price
set 時候讓回撥執行。在最後我們要達到的效果是:
$ total
10
$ price = 20 // 回撥 notify() 函式
$ total
40
複製程式碼
✅ 方案 Object.defineProperty()
我們需要學習一下關於 Object.defineProperty() - JavaScript | MDN。它允許我們在 property 上定義 getter 和 setter 函式。我們展示一下最基本的用法:
let data = { price: 5, quantity: 2 }
Object.defineProperty(data, 'price', { // 僅定義 price 屬性
get () { // 建立一個 get 方法
console.log(`I was accessed`)
},
set (newVal) { // 建立一個 set 方法
console.log(`I was changed`)
}
})
data.price // 回撥 get()
// => I was accessed
data.price = 20 // 回撥 set()
// => I was changed
複製程式碼
正如你所見,列印兩行 log。然而,這並不能推翻既有功能, get
或者 set
任意的 value
。get()
期望返回一個 value,set()
需要持續更新一個值,所以我們加入 internalValue
變數用於儲存 price
的值。
let data = { price: 5, quantity: 2 }
let internalValue = data.price // 初始值
Object.defineProperty(data, 'price', { // 僅定義 price 屬性
get () { // 建立一個 get 方法
console.log(`Getting price: ${internalValue}`)
return internalValue
},
set (newVal) { // 建立一個 set 方法
console.log(`Setting price: ${newVal}`)
internalValue = newVal
}
})
total = data.price * data.quantity // 回撥 get()
// => Getting price: 5
data.price = 20 // 回撥 set()
// => Setting price: 20
複製程式碼
至此,我們有了一個當 get 或者 set 值的時候的通知方法。我們也可以用某種遞迴可以將此執行在我們的資料佇列中?
FYI,Object.keys(data)
返回物件的 key 值列表。
let data = { price: 5, quantity: 2 }
Object.keys(data).forEach(key => {
let internalValue = data[key]
Object.defineProperty(data, key, {
get () {
console.log(`Getting ${key}: ${internalValue}`)
return internalValue
},
set (newVal) {
console.log(`Setting ${key}: ${newVal}`)
internalValue = newVal
}
})
})
total = data.price * data.quantity
// => Getting price: 5
// => Getting quantity: 2
data.price = 20
// => Setting price: 20
複製程式碼
CI 整合
將所有的理念整合起來
total = data.price * data.quantity
複製程式碼
當程式碼碎片比如 get 函式的執行並且 get 到 price
的值,我們需要 price
記錄在匿名函式 function(target) 中,如果 price
變化了,或者 set 了一個新的 value,會觸發這個匿名函式並且 get return,它能夠知道這裡更新了一樣。所以我們可以做如下抽象:
-
Get => 記錄這個匿名函式,如果值更新了,會執行此匿名函式
-
Set => 執行儲存的匿名函式,僅僅改變儲存的值。
在我們的 Dep 類的例項中,抽象如下:
-
Price accessed (get) => 回撥
dep.depend()
去注入當前的target
-
Price set => 回撥 price 繫結的
dep.notify()
,重新計算所有的targets
讓我們合併這兩個理念,生成最終程式碼:
let data = { price: 5, quantity: 2 }
let target = null
class Dep { // Stands for dependency
constructor () {
this.subscribers = [] // 依賴陣列,當 notify() 呼叫時執行
}
depend () {
if (target && !this.subscribers.includes(target)) {
// target 存在並且不存在於依賴陣列中,進行依賴注入
this.subscribers.push(target)
}
}
notify () { // 替代之前的 replay 函式
this.subscribers.forEach(sub => sub()) // 執行我們的 targets,或者觀察者函式
}
}
// 遍歷資料的屬性
Object.keys(data).forEach(key => {
let internalValue = data[key]
// 每個屬性都有一個依賴類的例項
const dep = new Dep()
Object.defineProperty(data, key, {
get () {
dep.depend()
return internalValue
},
set (newVal) {
internalValue = newVal
dep.notify()
}
})
})
// watcher 不再呼叫 dep.depend
// 在資料 get 方法中執行
function watcher (myFunc) {
target = myFunce
target()
target = null
}
watcher(()=> {
data.total = data.price * data.quantity
})
data.total
// => 10
data.price = 20
// => 20
data.total
// => 40
data.quantity = 3
// => 3
data.total
// => 60
複製程式碼
這已經達到了我們的期望,price
和 quantity
都成為響應式的資料了。
Vue 的資料響應圖如下:
看到紫色的 Data 資料,裡面的 getter 和 setter?是不是很熟悉了。每個元件的例項會有一個 watcher
的例項(藍色圓圈), 從 getter 中收集依賴。然後 setter 會被回撥,這裡 notifies 通知 watcher 讓元件重新渲染。下圖是本例抽象的情況:
顯然,Vue 的內部轉換相對於本例更復雜,但我們已經知道最基本的了。
知識點
- 如何建立一個 Dep class 並且注入所有的依賴執行(notify)
- 如何建立一個 watcher 進行程式碼管理,需要新增一個依賴專案
- 如何使用 Object.defineProperty() 建立一個 getters 和 setters