Vue 3 響應式原理及實現

natee發表於2020-06-08

從零開始實現你自己的響應式庫,從零開始實現 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

那麼 tracktrigger 分別做了什麼,是如何實現的呢?我們暫且可以簡單理解為一個“釋出-訂閱者模式”,track 就是不斷給一個陣列 dep 新增 effecttrigger 用來遍歷執行 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}`)

這段程式碼中,按照上文講解的,屬性abdep應該是如下:

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

那麼現在問題就是,我們如何在訪問或修改一個屬性時做到這樣的事情?也即是如何攔截這種 GETSET 操作?

Vue 2中我們使用 ES5 中的 Object.defineProperty 來攔截 GETSET
Vue 3中我們將使用 ES6 中的 ReflectProxy。(注意:Vue 3不再支援IE瀏覽器,所以可以用比較多的高階特性)

我們先來看一下怎麼輸出一個物件的一個屬性值,可以用下面這三種方法:

  • 使用 . => obj.a
  • 使用 [] => obj['a']
  • 使用 ES6 中的 Reflect => Reflect.get(obj, 'a')

這三種方法都是可行的,但是 Reflect 有非常強大的能力,後面會講到。

Proxy

我們先來看看 ProxyProxy 是另一個物件的佔位符,預設是對這個物件的委託。你可以在這裡檢視 Proxy 更詳細的用法。

let obj = { a: 1}
let proxiedObj = new Proxy(obj, {})
console.log(proxiedObj.a) // 1

這個過程可以表述為,獲取 proxiedObj.a 時,直接去從查詢 obj.a然後返回給 proxiedObj,再輸出 proxiedObj.a

Proxy 的第二個引數被稱為 handlerhandler就是包含捕捉器(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]這一行:

  1. 當讀取 childObj.b 時,childObj 上沒有屬性 b,因此會從原型鏈上查詢
  2. 原型鏈是 proxiedObj
  3. 讀取 proxiedObj.b 時,會觸發Proxy捕捉器(trap)中的 get,這直接從原始物件中返回了 target[key]
  4. 這裡target[key]key 是一個 getter,因此這個 getter 中的上下文 this 即為target,這裡的 target 就是 obj,因此直接返回了 1
參考 為什麼要使用 Reflect

那麼我們怎麼解決這個 this 出錯的問題呢?

Reflect

現在我們就可以講講 Reflect 了。你可以在這裡檢視 Reflect 更詳細的用法。

捕獲器 get 有第三個引數叫做 receiver

Proxyhandler.get(target, prop, receiver) 中的引數 receiverProxy 或者繼承 Proxy 的物件。

Reflect.get(target, prop, receiver) 中的引數 receiver :如果target 物件中指定了 getterreceiver 則為 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

接下來我們要做的就是結合 Proxyhandler 和 之前實現了的 tracktrigger 來完成一個響應式模組。

首先,我們來封裝一下 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

現在我們要做的是去掉示例程式碼中的 tracktrigger

回到本節開頭提出的解決方案,我們已經可以攔截 GETSET 操作了,只需要在適當的時候呼叫 tracktrigger 方法即可,我們修改 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

我們已經去掉了手動 tracktrigger 程式碼,至此,我們已經實現了 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 執行完後重置 shouldTracknull,這樣結合剛才修改的 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保持一致,這裡我們修改 shouldTrackactiveEffect,現在它表示當前執行的 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 屬性的物件有這兩種方法:

  1. 直接給一個物件新增 value 屬性
function ref(intialValue) {
  return reactive({
    value: intialValue
  })
}
  1. gettersetter 來實現
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,而是選擇了比較複雜的第二種方法呢?
主要有三方面原因:

  1. 根據定義,ref 應該只有一個公開的屬性,即 value,如果使用了 reactive 你可以給這個變數增加新的屬性,這其實就破壞了 ref 的設計目的,它應該只用來包裝一個內部的 value 而不應該作為一個通用的 reactive 物件;
  2. Vue 3中有一個 isRef 函式,用來判斷一個物件是 ref 物件而不是 reactive 物件,這種判斷在很多場景都是非常有必要的;
  3. 效能方面考慮,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 })

看到 timesAsum 兩個變數,有同學就會說:“這不就是計算屬性嗎,不能像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 應該是什麼樣的?

  1. 返回響應式物件,也許是 ref()
  2. 內部需要執行 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 函式或傳入一個有 getset 的物件,並且有其它操作,這裡我們不做實現,感興趣可以去看原始碼

現階段完整程式碼

至此我們已經實現了一個簡易版本的響應式庫了,完整程式碼如下:

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.iteratorArray.length 等觸發了 track 如何處理
  • 巢狀的物件,如何遞迴響應
  • 物件某個 key 對應的 value 本身是一個 reactive 物件,如何處理

你也可以自己嘗試著實現它們。

本文完整內容見從零開始建立你的Vue 3

相關文章