從零開始實現你自己的響應式庫,從零開始實現 Vue 3 響應式模組。
本文完整內容見buid-your-own-vue-next
1. 實現響應式
響應基本型別變數
首先看一下響應式預期應該是什麼樣的,新建一個 demo.js
檔案,內容如下:
// 這種寫成一行完全是為了節省空間,實際上我會一行一個變數
let a = 1, b = 2, c = a * b
console.log('c:' + c) // 2
a = 2
console.log('c:' + c) // 期望得到4
思考一下,如何才能做到當 a
變動時 c
跟著變化?
顯然,我們需要做的就是重新執行一下 let c = a * b
即可,像這樣:
let a = 1, b = 2, c = a * b
console.log('c:' + c) // 2
a = 2
c = a * b
console.log('c:' + c) // 期望得到4
那麼,現在我們把需要重新執行的程式碼寫成一個函式,程式碼如下:
let a = 1, b = 2, c = 0
let effect = () => { c = a * b }
effect() // 首次執行更新c的值
console.log('c:' + c) // 2
a = 2
console.log('c:' + c) // 期望得到4
現在仍然沒有達成預期的效果,實際上我們還需要兩個方法,一個用來儲存所有需要依賴更新的 effect
,我們假設叫 track
,一個用來觸發執行這些 effect
函式,假設叫做 trigger
。
注意: 這裡我們的函式命名和 Vue 3 中保持一致,從而可以更容易理解 Vue 3 原始碼。
程式碼類似這樣:
let a = 1, b = 2, c = 0
let effect = () => { c = a * b }
track() // 收集 effect
effect() // 首次執行更新c的值
console.log('c:' + c) // 2
a = 2
trigger() // a變化時,觸發effect的執行
console.log('c:' + c) // 期望得到4
那麼 track
和 trigger
分別做了什麼,是如何實現的呢?我們暫且可以簡單理解為一個“釋出-訂閱者模式”,track
就是不斷給一個陣列 dep
新增 effect
,trigger
用來遍歷執行 dep
的每一項 effect
。
現在來完成這兩個函式
let a = 1, b = 2, c = 0
let effect = () => { c = a * b }
let dep = new Set()
let track = () => { dep.add(effect) }
let trigger = () => { dep.forEach(effect => effect()) }
track()
effect() // 首次執行更新c的值
console.log('c:' + c) // 2
a = 2
trigger() // a變化時,觸發effect的執行
console.log('c:' + c) // 期望得到4,實際得到4
注意這裡我們使用 Set
來定義 dep
,原因就是 Set
本身不能新增重複的 key
,讀寫都非常方便。
現在程式碼的執行結果已經符合預期了。
c: 2
c: 4
響應物件的不同屬性
通常情況,我們定義的物件都有很多的屬性,每一個屬性都需要有自己的 dep
(即每個屬性都需要把那些依賴了自己的effect記錄下來放進自己的 new Set()
中),如何來實現這樣的功能呢?
有一段程式碼如下:
let obj = { a: 10, b: 20 }
let timesA = obj.a * 10
let divideA = obj.a / 10
let timesB = obj.b * 10
let divideB = obj.b / 10
// 100, 1, 200, 2
console.log(`${timesA}, ${divideA}, ${timesB}, ${divideB}`)
obj.a = 100
obj.b = 200
// 期望得到 1000, 10, 2000, 20
console.log(`${timesA}, ${divideA}, ${timesB}, ${divideB}`)
這段程式碼中,按照上文講解的,屬性a
和b
的dep
應該是如下:
let depA = [
() => { timesA = obj.a * 10 },
() => { divideA = obj.a / 10 }
]
let depB = [
() => { timesB = obj.b * 10 },
() => { divideB = obj.b / 10 }
]
如果程式碼還是按照前文的方式來寫顯然是不科學的,這裡就要開始做一點點抽象了,收集依賴我們可以假想用track('a')
track('b')
這種形式分別記錄物件不同key
的依賴項,那麼顯然我們還需要一個東西來存放這些 key
及相應的dep
。
現在我們來實現這樣的 track
函式及對應的 trigger
函式,程式碼如下:
const depsMap = new Map() // 每一項都是一個 Set 物件
function track(key) {
let dep = depsMap.get(key)
if(!dep) {
depsMap.set(key, dep = new Set());
}
dep.add(effect)
}
function trigger(key) {
let dep = depsMap.get(key)
if(dep) {
dep.forEach(effect => effect())
}
}
這樣就實現了對一個物件不同屬性的依賴收集,那麼現在這個程式碼最簡單的使用方法將是下面這樣:
const depsMap = new Map() // 每一項都是一個 Set 物件
function track(key) {
...
// only for usage demo
if(key === 'a'){
dep.add(effectTimesA)
dep.add(effectDivideA)
}else if(key === 'b'){
dep.add(effectTimesB)
dep.add(effectDivideB)
}
}
function trigger(key) {
...
}
let obj = { a: 10, b: 20 }
let timesA = 0
let divideA = 0
let timesB = 0
let divideB = 0
let effectTimesA = () => { timesA = obj.a * 10 }
let effectDivideA = () => { divideA = obj.a / 10 }
let effectTimesB = () => { timesB = obj.b * 10 }
let effectDivideB = () => { divideB = obj.b / 10 }
track('a')
track('b')
// 為了省事直接改成呼叫trigger,後文同樣
trigger('a')
trigger('b')
// 100, 1, 200, 2
console.log(`${timesA}, ${divideA}, ${timesB}, ${divideB}`)
obj.a = 100
obj.b = 200
trigger('a')
trigger('b')
// 期望得到:1000, 10, 2000, 20 實際得到:1000, 10, 2000, 20
console.log(`${timesA}, ${divideA}, ${timesB}, ${divideB}`)
程式碼看起來仍然是臃腫無比,彆著急,後面的設計會優化這個問題。
響應多個物件
我們已經實現了對一個物件的響應程式設計,那麼要對多個物件實現響應式程式設計該怎麼做呢?
腦袋一拍,繼續往外巢狀一層物件不就可以了嗎?沒錯,你可以用 ES6 中的 WeakMap
輕鬆實現,WeakMap
剛好可以(只能)把物件當作 key
。(題外話,Map 和 WeakMap 的區別)
我們假想實現後是這樣的效果:
let obj1 = { a: 10, b: 20 }
let obj2 = { c: 30, d: 40 }
const targetMap = new WeakMap()
// 省略程式碼
// 獲取 obj1 的 depsMap
// 獲取 obj2 的 depsMap
targetMap.set(obj1, "obj1's depsMap")
targetMap.set(obj2, "obj2's depsMap")
這裡暫且不糾結為什麼叫 targetMap
,現在整體依賴關係如下:
名稱 | 型別 | key | 值 |
---|---|---|---|
targetMap | WeakMap | object | depsMap |
depsMap | Map | property | dep |
dep | Set | effect |
- targetMap: 存放每個響應式物件(所有屬性)的依賴項
- targetMap: 存放響應式物件每個屬性對應的依賴項
- dep: 存放某個屬性對應的所有依賴項(當這個物件對應屬性的值發生變化時,這些依賴項函式會重新執行)
現在我們可以實現這個功能了,核心程式碼如下:
const targetMap = new WeakMap();
function track(target, key) {
let depsMap = targetMap.get(target)
if(!depsMap){
targetMap.set(target, depsMap = new Map())
}
let dep = depsMap.get(key)
if(!dep) {
depsMap.set(key, dep = new Set());
}
// 先忽略這個
dep.add(effect)
}
function trigger(target, key) {
let depsMap = targetMap.get(target)
if(depsMap){
let dep = depsMap.get(key)
if(dep) {
dep.forEach(effect => effect())
}
}
}
那麼現在這個程式碼最簡單的使用方法將是下面這樣:
const targetMap = new WeakMap();
function track(target, key) {
...
// only for usage demo
if(key === 'a'){
dep.add(effectTimesA)
dep.add(effectDivideA)
}
}
function trigger(target, key) {
...
}
let obj = { a: 10, b: 20 }
let timesA = 0
let divideA = 0
let effectTimesA = () => { timesA = obj.a * 10 }
let effectDivideA = () => { divideA = obj.a / 10 }
track(obj, 'a')
trigger(obj, 'a')
console.log(`${timesA}, ${divideA}`) // 100, 1
obj.a = 100
trigger(obj, 'a')
console.log(`${timesA}, ${divideA}`) // 1000, 10
至此,我們對響應式的基本概念有了瞭解,我們已經做到了收集所有響應式物件的依賴項,但是現在你可以看到程式碼的使用是極其繁瑣的,主要是因為我們還沒實現自動收集依賴項、自動觸發修改。
2. Proxy 和 Reflect
上一節講到了我們實現了基本的響應功能,但是我們目前還是手動進行依賴收集和觸發更新的。
解決這個問題的方法應該是:
- 當訪問(
GET
)一個屬性時,我們就呼叫track(obj, <property>)
自動收集依賴項(儲存effect
) - 當修改(
SET
)一個屬性時,我們就呼叫trigger(obj, <property>
自動觸發更新(執行儲存的effect
)
那麼現在問題就是,我們如何在訪問或修改一個屬性時做到這樣的事情?也即是如何攔截這種 GET
和 SET
操作?
Vue 2中我們使用 ES5 中的 Object.defineProperty
來攔截 GET
和 SET
。
Vue 3中我們將使用 ES6 中的 Reflect
和 Proxy
。(注意:Vue 3不再支援IE瀏覽器,所以可以用比較多的高階特性)
我們先來看一下怎麼輸出一個物件的一個屬性值,可以用下面這三種方法:
- 使用
.
=>obj.a
- 使用
[]
=>obj['a']
- 使用 ES6 中的
Reflect
=>Reflect.get(obj, 'a')
這三種方法都是可行的,但是 Reflect
有非常強大的能力,後面會講到。
Proxy
我們先來看看 Proxy
,Proxy
是另一個物件的佔位符,預設是對這個物件的委託。你可以在這裡檢視 Proxy 更詳細的用法。
let obj = { a: 1}
let proxiedObj = new Proxy(obj, {})
console.log(proxiedObj.a) // 1
這個過程可以表述為,獲取 proxiedObj.a
時,直接去從查詢 obj.a
然後返回給 proxiedObj
,再輸出 proxiedObj.a
。
Proxy
的第二個引數被稱為 handler
,handler
就是包含捕捉器(trap)的佔位符物件,即處理器物件,捕捉器允許我們攔截一些基本的操作,如:
- 查詢屬性
- 列舉
- 函式的呼叫
現在我們的示例程式碼修改為:
let obj = { a: 1}
let proxiedObj = new Proxy(obj, {
get(target, key) {
console.log('Get')
return target[key]
}
})
console.log(proxiedObj.a) // 1
這段程式碼中,我們直接使用 target[key]
返回值,它直接返回了原始物件的值,不做任何其它操作,這對於這個簡單的示例來說沒任何問題,。
現在我們看一下下面這段稍微複雜一點的程式碼:
let obj = {
a: 1,
get b() { return this.a }
}
let proxiedObj = new Proxy(obj, {
get(target, key, receiver) {
return target[key] // 這裡的target是obj
}
})
let childObj = Object.create(proxiedObj)
childObj.a = 2
console.log(childObj.b) // 期望得到2 實際輸出1
這段程式碼的輸出結果就是錯誤的,這是什麼情況?難道是原型繼承寫錯了嗎?我們嘗試把Proxy
相關程式碼去掉,發現輸出是正常的......
這個問題其實就出在 return target[key]
這一行:
- 當讀取
childObj.b
時,childObj
上沒有屬性b
,因此會從原型鏈上查詢 - 原型鏈是
proxiedObj
- 讀取
proxiedObj.b
時,會觸發Proxy
捕捉器(trap)中的get
,這直接從原始物件中返回了target[key]
- 這裡
target[key]
中key
是一個getter
,因此這個getter
中的上下文this
即為target,這裡的target
就是obj
,因此直接返回了1
。
參考 為什麼要使用 Reflect
那麼我們怎麼解決這個 this
出錯的問題呢?
Reflect
現在我們就可以講講 Reflect
了。你可以在這裡檢視 Reflect 更詳細的用法。
捕獲器 get
有第三個引數叫做 receiver
。
Proxy
中handler.get(target, prop, receiver)
中的引數receiver
:Proxy
或者繼承Proxy
的物件。
Reflect.get(target, prop, receiver)
中的引數receiver
:如果target
物件中指定了getter
,receiver
則為getter
呼叫時的this
值。
這確保了當我們的物件從另一個物件繼承了值或函式時使用 this
值的正確性。
我們修改剛才的示例如下:
let obj = {
a: 1,
get b() { return this.a }
}
let proxiedObj = new Proxy(obj, {
// 本例中這裡的receiver為呼叫時的物件childOjb
get(target, key, receiver) {
// 這裡的target是obj
// 這意思是把receiver作為this去呼叫target[key]
return Reflect.get(target, key, receiver)
}
})
let childObj = Object.create(proxiedObj)
childObj.a = 2;
console.log(childObj.b) // 期望得到2 實際輸出1
現在我們弄清楚了為什麼要結合 Reflect
來使用 Proxy
,有了這些知識,就可以繼續完善我們的程式碼了。
實現reactive函式
現在修改我們的示例程式碼為:
let obj = { a: 1}
let proxiedObj = new Proxy(obj, {
get(target, key, receiver) {
console.log('Get')
return Reflect.get(target, key, receiver)
}
set(target, key, value, receiver) {
console.log('Set')
return Reflect.set(target, key, value, receiver)
}
})
console.log(proxiedObj.a) // Get 1
接下來我們要做的就是結合 Proxy
的 handler
和 之前實現了的 track
、trigger
來完成一個響應式模組。
首先,我們來封裝一下 Proxy
相關程式碼,和Vue 3保持一致叫reactive
。
function reactive(target) {
const handler = {
get(target, key, receiver) {
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
return Reflect.set(target, key, value, receiver)
}
}
return new Proxy(target, handler)
}
這裡有一個問題,當我們每次呼叫 reactive
時都會重新定義一個 handler
的物件,為了優化這個,我們把 handler
提出去,程式碼如下:
const reactiveHandler = {
get(target, key, receiver) {
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
return Reflect.set(target, key, value, receiver)
}
}
function reactive(target) {
return new Proxy(target, reactiveHandler)
}
現在把reactive
引入到我們的第一節中最後的示例程式碼中。
let obj = reactive({ a: 10, b: 20 })
let timesA = 0
let divideA = 0
let effectTimesA = () => { timesA = obj.a * 10 }
let effectDivideA = () => { divideA = obj.a / 10 }
track(obj, 'a')
trigger(obj, 'a')
console.log(`${timesA}, ${divideA}`) // 100, 1
obj.a = 100
trigger(obj, 'a')
console.log(`${timesA}, ${divideA}`) // 1000, 10
現在我們要做的是去掉示例程式碼中的 track
和 trigger
。
回到本節開頭提出的解決方案,我們已經可以攔截 GET
和 SET
操作了,只需要在適當的時候呼叫 track
和 trigger
方法即可,我們修改 reactiveHandler
程式碼如下:
const reactiveHandler = {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
track(target, key)
return result
},
set(target, key, value, receiver) {
const oldVal = target[key]
const result = Reflect.set(target, key, value, receiver)
// 這裡判斷條件不對,result為一個布林值
if(oldVal !== result){
trigger(target, key)
}
return result
}
}
現在我們的示例程式碼可以精簡為這樣:
let obj = reactive({ a: 10, b: 20 })
let timesA = 0
let divideA = 0
let effectTimesA = () => { timesA = obj.a * 10 }
let effectDivideA = () => { divideA = obj.a / 10 }
// 恢復呼叫 effect 的形式
effectTimesA()
effectDivideA()
console.log(`${timesA}, ${divideA}`) // 100, 1
obj.a = 100
console.log(`${timesA}, ${divideA}`) // 1000, 10
我們已經去掉了手動 track
和 trigger
程式碼,至此,我們已經實現了 reactive
函式,看起來和Vue 3原始碼差不多了。
但這還有點問題:
-
track
函式中的effect
現在還沒處理,只能手動新增 -
reactive
現在只能作用於物件,基本型別變數怎麼處理?
下一個章節我們將解決這個問題,讓我們的程式碼更加接近Vue 3。
3. activeEffect 和 ref
首先,我們修改一下示例程式碼:
let obj = reactive({ a: 10, b: 20 })
let timesA = 0
let effect = () => { timesA = obj.a * 10 }
effect()
console.log(timesA) // 100
obj.a = 100
// 新增一行,使用到obj.a
console.log(obj.a)
console.log(timesA) // 1000
由上節知識可以知道,當 effect
執行時我們訪問到了 obj.a
,因此會觸發 track
收集該依賴 effect
。同理,console.log(obj.a)
這一行也同樣觸發了 track
,但這並不是響應式程式碼,我們預期不觸發 track
。
我們想要的是隻在 effect
中的程式碼才觸發 track
。
能想到怎麼來實現嗎?
只響應需要依賴更新的程式碼(effect)
首先,我們定義一個變數 shouldTrack
,暫且認為它表示是否需要執行 track
,我們修改 track
程式碼,只需要增加一層判斷條件,如下:
const targetMap = new WeakMap();
let shouldTrack = null
function track(target, key) {
if(shouldTrack){
let depsMap = targetMap.get(target)
if(!depsMap){
targetMap.set(target, depsMap = new Map())
}
let dep = depsMap.get(key)
if(!dep) {
depsMap.set(key, dep = new Set());
}
// 這裡的 effect 為使用時定義的 effect
// shouldTrack 時應該把對應的 effect 傳進來
dep.add(effect)
// 如果有多個就手寫多個
// dep.add(effect1)
// ...
}
}
現在我們需要解決的就是 shouldTrack
賦值問題,當有需要響應式變動的地方,我們就寫一個 effect
並賦值給 shouldTrack
,然後 effect
執行完後重置 shouldTrack
為 null
,這樣結合剛才修改的 track
函式就解決了這個問題,思路如下:
let shouldTrack = null
// 這裡省略 track trigger reactive 程式碼
...
let obj = reactive({ a: 10, b: 20 })
let timesA = 0
let effect = () => { timesA = obj.a * 10 }
shouldTrack = effect // (*)
effect()
shouldTrack = null // (*)
console.log(timesA) // 100
obj.a = 100
console.log(obj.a)
console.log(timesA) // 1000
此時,執行到 console.log(obj.a)
時,由於 shouldTrack
值為 null
,所以並不會執行 track
,完美。
完美了嗎?顯然不是,當有很多的 effect
時,你的程式碼會變成下面這樣:
let effect1 = () => { timesA = obj.a * 10 }
shouldTrack = effect1 // (*)
effect1()
shouldTrack = null // (*)
let effect2 = () => { timesB = obj.a * 10 }
shouldTrack = effect1 // (*)
effect2()
shouldTrack = null // (*)
我們來優化一下這個問題,為了和Vue 3保持一致,這裡我們修改 shouldTrack
為 activeEffect
,現在它表示當前執行的 effect
。
我們把這段重複使用的程式碼封裝成函式,如下:
let activeEffect = null
// 這裡省略 track trigger reactive 程式碼
...
function effect(eff) {
activeEffect = eff
activeEffect()
activeEffect = null
}
同時我們還需要修改一下 track
函式:
function track(target, key) {
if(activeEffect){
...
// 這裡不用再根據條件手動新增不同的 effect 了!
dep.add(activeEffect)
}
}
那麼現在的使用方法就變成了:
const targetMap = new WeakMap();
let activeEffect = null
function effect (eff) { ... }
function track() { ... }
function trigger() { ... }
function reactive() { ... }
let obj = reactive({ a: 10, b: 20 })
let timesA = 0
let timesB = 0
effect(() => { timesA = obj.a * 10 })
effect(() => { timesB = obj.b * 10 })
console.log(timesA) // 100
obj.a = 100
console.log(obj.a)
console.log(timesA) // 1000
現階段完整程式碼
現在新建一個檔案reactive.ts
,內容就是當前實現的完整響應式程式碼:
const targetMap = new WeakMap();
let activeEffect = null
function effect(eff) {
activeEffect = eff
activeEffect()
activeEffect = null
}
function track(target, key) {
if(activeEffect){
let depsMap = targetMap.get(target)
if(!depsMap){
targetMap.set(target, depsMap = new Map())
}
let dep = depsMap.get(key)
if(!dep) {
depsMap.set(key, dep = new Set());
}
dep.add(activeEffect)
}
}
function trigger(target, key) {
let depsMap = targetMap.get(target)
if(depsMap){
let dep = depsMap.get(key)
if(dep) {
dep.forEach(effect => effect())
}
}
}
const reactiveHandler = {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
track(target, key)
return result
},
set(target, key, value, receiver) {
const oldVal = target[key]
const result = Reflect.set(target, key, value, receiver)
if(oldVal !== result){
trigger(target, key)
}
return result
}
}
function reactive(target) {
return new Proxy(target, reactiveHandler)
}
現在我們已經解決了非響應式程式碼也觸發track
的問題,同時也解決了上節中留下的問題:track
函式中的 effect
只能手動新增。
接下來我們解決上節中留下的另一個問題:reactive
現在只能作用於物件,基本型別變數怎麼處理?
實現ref
修改 demo.js
程式碼如下:
import { effect, reactive } from "./reactive"
let obj = reactive({ a: 10, b: 20 })
let timesA = 0
let sum = 0
effect(() => { timesA = obj.a * 10 })
effect(() => { sum = timesA + obj.b })
obj.a = 100
console.log(sum) // 期望: 1020
這段程式碼並不能實現預期效果,因為當 timesA
正常更新時,我們希望能更新 sum
(即重新執行 () => { sum = timesA + obj.b }
),而實際上由於 timesA
並不是一個響應式物件,沒有 track
其依賴,所以這一行程式碼並不會執行。
那我們如何才能讓這段程式碼正常工作呢?其實我們把基本型別變數包裝成一個物件去呼叫 reactive
即可。
看過 Vue composition API
的同學可能知道,Vue 3中用一個 ref
函式來實現把基本型別變數變成響應式物件,通過 .value
獲取值,ref
返回的就是一個 reactive
物件。
實現這樣的一個有 value
屬性的物件有這兩種方法:
- 直接給一個物件新增
value
屬性
function ref(intialValue) {
return reactive({
value: intialValue
})
}
- 用
getter
和setter
來實現
function ref(raw) {
const r = {
get value() {
track(r, 'value')
return raw
},
set value(newVal) {
raw = newVal
trigger(r, 'value)
}
}
return r
}
現在我們的示例程式碼修改成:
import { effect, reactive } from "./reactive"
function ref(intialValue) {
return reactive({
value: intialValue
})
}
let obj = reactive({ a: 10, b: 20 })
let timesA = ref(0)
let sum = 0
effect(() => { timesA.value = obj.a * 10 })
effect(() => { sum = timesA.value + obj.b })
// 期望: timesA: 100 sum: 120 實際:timesA: 100 sum: 120
console.log(`timesA: ${timesA.value} sum: ${sum}`)
obj.a = 100
// 期望: timesA: 1000 sum: 1020 實際:timesA: 1000 sum: 1020
console.log(`timesA: ${timesA} sum: ${sum}`)
增加了 ref
處理基本型別變數後,我們的示例程式碼執行結果符合預期了。至此我們已經解決了遺留問題:reactive
只能作用於物件,基本型別變數怎麼處理?
Vue 3中的 ref
是用第二種方法來實現的,現在我們整理一下程式碼,把 ref
放到 reactive.j
中。
現階段完整程式碼
const targetMap = new WeakMap();
let activeEffect = null
function effect(eff) {
activeEffect = eff
activeEffect()
activeEffect = null
}
function track(target, key) {
if(activeEffect){
let depsMap = targetMap.get(target)
if(!depsMap){
targetMap.set(target, depsMap = new Map())
}
let dep = depsMap.get(key)
if(!dep) {
depsMap.set(key, dep = new Set());
}
dep.add(activeEffect)
}
}
function trigger(target, key) {
let depsMap = targetMap.get(target)
if(depsMap){
let dep = depsMap.get(key)
if(dep) {
dep.forEach(effect => effect())
}
}
}
const reactiveHandler = {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
track(target, key)
return result
},
set(target, key, value, receiver) {
const oldVal = target[key]
const result = Reflect.set(target, key, value, receiver)
if(oldVal !== result){
trigger(target, key)
}
return result
}
}
function reactive(target) {
return new Proxy(target, reactiveHandler)
}
function ref(raw) {
const r = {
get value() {
track(r, 'value')
return raw
},
set value(newVal) {
raw = newVal
trigger(r, 'value)
}
}
return r
}
有同學可能就要問了,為什麼不直接用第一種方法實現 ref
,而是選擇了比較複雜的第二種方法呢?
主要有三方面原因:
- 根據定義,
ref
應該只有一個公開的屬性,即value
,如果使用了reactive
你可以給這個變數增加新的屬性,這其實就破壞了ref
的設計目的,它應該只用來包裝一個內部的value
而不應該作為一個通用的reactive
物件; - Vue 3中有一個
isRef
函式,用來判斷一個物件是ref
物件而不是reactive
物件,這種判斷在很多場景都是非常有必要的; - 效能方面考慮,Vue 3中的
reactive
做的事情遠比第二種實現ref
的方法多,比如有各種檢查。
4. Computed
回到上節中最後的示例程式碼:
import { effect, reactive, ref } from "./reactive"
let obj = reactive({ a: 10, b: 20 })
let timesA = ref(0)
let sum = 0
effect(() => { timesA.value = obj.a * 10 })
effect(() => { sum = timesA.value + obj.b })
看到 timesA
和 sum
兩個變數,有同學就會說:“這不就是計算屬性嗎,不能像Vue 2一樣用 computed
來表示嗎?” 顯然是可以的,看過 Vue composition API
的同學可能知道,Vue 3中提供了一個 computed
函式。
示例程式碼如果使用 computed
將變成這樣:
import { effect, reactive, computed } from "./reactive"
let obj = reactive({ a: 10, b: 20 })
let timesA = computed(() => obj.a * 10)
let sum = computed(() => timesA.value + obj.b)
現在的問題就是如何實現 computed
?
實現computed
我們拿 timesA
前後的改動來說明,思考一下 computed
應該是什麼樣的?
- 返回響應式物件,也許是
ref()
- 內部需要執行
effect
函式以收集依賴
function computed(getter) {
const result = ref();
effect(() => result.value = getter())
return result
}
現在測試一下示例程式碼:
import { effect, reactive, ref } from "./reactive"
let obj = reactive({ a: 10, b: 20 })
let timesA = computed(() => obj.a * 10)
let sum = computed(() => timesA.value + obj.b)
// 期望: timesA: 1000 sum: 1020 實際:timesA: 1000 sum: 1020
console.log(`timesA: ${timesA.value} sum: ${sum.value}`)
obj.a = 100
// 期望: timesA: 1000 sum: 1020
console.log(`timesA: ${timesA.value} sum: ${sum.value}`)
結果符合預期。
這樣實現看起來很容易,實際上Vue 3中的 computed
支援傳入一個 getter
函式或傳入一個有 get
和 set
的物件,並且有其它操作,這裡我們不做實現,感興趣可以去看原始碼。
現階段完整程式碼
至此我們已經實現了一個簡易版本的響應式庫了,完整程式碼如下:
const targetMap = new WeakMap();
let activeEffect = null
function effect(eff) {
activeEffect = eff
activeEffect()
activeEffect = null
}
function track(target, key) {
if(activeEffect){
let depsMap = targetMap.get(target)
if(!depsMap){
targetMap.set(target, depsMap = new Map())
}
let dep = depsMap.get(key)
if(!dep) {
depsMap.set(key, dep = new Set());
}
dep.add(activeEffect)
}
}
function trigger(target, key) {
let depsMap = targetMap.get(target)
if(depsMap){
let dep = depsMap.get(key)
if(dep) {
dep.forEach(effect => effect())
}
}
}
const reactiveHandler = {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
track(target, key)
return result
},
set(target, key, value, receiver) {
const oldVal = target[key]
const result = Reflect.set(target, key, value, receiver)
if(oldVal !== result){
trigger(target, key)
}
return result
}
}
function reactive(target) {
return new Proxy(target, reactiveHandler)
}
function ref(raw) {
const r = {
get value() {
track(r, 'value')
return raw
},
set value(newVal) {
raw = newVal
trigger(r, 'value')
}
}
return r
}
function computed(getter) {
const result = ref();
effect(() => result.value = getter())
return result
}
尚存問題
我們現在的程式碼非常簡易,有很多細節尚未實現,你都可以在原始碼中學習到,比如:
- 操作一些內建的屬性,如
Symbol.iterator
、Array.length
等觸發了track
如何處理 - 巢狀的物件,如何遞迴響應
- 物件某個
key
對應的value
本身是一個reactive
物件,如何處理
你也可以自己嘗試著實現它們。
本文完整內容見從零開始建立你的Vue 3