【Vue3響應式原理#02】Proxy and Reflect

柏成發表於2023-11-06

專欄分享:vue2原始碼專欄vue3原始碼專欄vue router原始碼專欄玩具專案專欄,硬核?推薦?

歡迎各位ITer關注點贊收藏???

背景

以下是柏成根據Vue3官方課程整理的響應式書面文件 - 第二節,課程連結在此:Proxy and Reflect - Vue 3 Reactivity | Vue Mastery

本篇文章將解決 上一篇文章 結尾遺留的問題:如何讓程式碼自動實現響應性? 換句話說就是,如何讓我們的 effect 自動儲存 & 自動重新執行?

上一篇文章 中,我們最終執行的程式碼長這樣

聰明的你會立馬發現,我們現在仍要手動呼叫 track() 來儲存 effect;手動呼叫 trigger() 來執行 effects,這不是脫褲子放屁麼

我們想讓我們的響應性引擎自動呼叫 track()trigger()。那麼問題就來了,何時才是呼叫它們的最好時機呢?

從邏輯上來說,如果訪問了物件的屬性,就是我們呼叫 track() 去儲存 effect 的最佳時機;如果物件的屬性改變了,就是我們呼叫 trigger() 來執行 effects 的最佳時機

所以問題變成了,我們該如何攔截物件屬性的訪問和賦值操作?

Proxy(代理)

MDN 上的 Proxy 物件是這樣定義的

Proxy 物件用於建立一個物件的代理,從而實現基本操作的攔截和自定義(如屬性查詢、賦值、列舉、函式呼叫等)。

也可以理解為在操作目標物件前架設一層代理,將所有本該我們手動編寫的程式交由代理來處理,生活中也有許許多多的“proxy”, 如代購,中介,因為他們所有的行為都不會直接觸達到目標物件

語法

  • target: 要使用 Proxy 包裝的目標物件(可以是任何型別的物件,包括原生陣列,函式,甚至另一個代理)

  • handler: 一個通常以函式作為屬性的物件,用來定製攔截行為;它包含有 Proxy 的各個捕獲器(trap),例如 handler.get() / handler.set()

const p = new Proxy(target, handler)

常用方法

比較常用的兩個方法就是 get()set() 方法

方法 描述
handler.get(target, key, ?receiver) 屬性讀取操作的捕捉器
handler.set(target, key, value, ? receiver) 屬性設定操作的捕捉器

handler.get

用於代理目標物件的屬性讀取操作,其接受三個引數 handler.get(target, propKey, ?receiver)

  • target: 目標物件
  • key: 屬性名
  • receiver: Proxy 本身或者繼承它的物件,後面會重點介紹

舉個例子

const origin = {}
const obj = new Proxy(origin, {
  get: function (target, key, receiver) {
		return 10
  }
})

obj.a // 10
obj.b // 10
origin.a // undefined
origin.b // undefined

在這個栗子中,我們給一個空物件 origin 的 get 架設了一層代理,所有 get 操作都會直接返回我們定製的數字10

需要注意的是,代理只會對 proxy 物件生效,如訪問上方的 origin 物件就沒有任何效果

handler.set

用於代理目標物件的屬性設定操作,其接受四個引數 handler.set(target, key, value, ?receiver)

  • target: 目標物件
  • key: 屬性名
  • value: 新屬性值
  • receiver: Proxy 本身或者繼承它的物件,後面會重點介紹
const obj = new Proxy({}, {
  set: function(target, key, value, receiver) {
    target[key] = value
    console.log('property set: ' + key + ' = ' + value)
    return true
  }
})

'a' in obj  // false
obj.a = 10  // "property set: a = 10"
'a' in obj  // true
obj.a       // 10

Reflect(反射)

MDN 上的 Reflect 物件是這樣定義的

Reflect 是一個內建的物件,用來提供方法去攔截 JavaScript的操作。Reflect 不是一個函式物件,所以它是不可構造的,也就是說你不能透過 new運算子去新建一個 Reflect物件或者將 Reflect物件作為一個函式去呼叫。Reflect的所有屬性和方法都是靜態的(就像Math物件)

常用方法

Reflect物件掛載了很多靜態方法,所謂靜態方法,就是和 Math.round() 這樣,不需要 new 就可以直接使用的方法。
比較常用的兩個方法就是 get()set() 方法:

方法 描述
Reflect.get(target, key, ?receiver) 和 target[key] 類似,從物件中讀取屬性值
Reflect.set(target, key, value, ? receiver) 和 target[key] = value 類似,給物件的屬性設定一個新值

Reflect.get()

Reflect.get方法允許你從一個物件中取屬性值,返回值是這個屬性值

Reflect.set()

Reflect.set 方法允許你在物件上設定屬性,返回值是 Boolean 值,代表是否設定成功

  • target: 目標物件
  • key: 屬性名
  • value: 新屬性值
  • receiver: 後面會重點介紹
Reflect.get(target, key[, receiver])
// 等同於
target[key]

Reflect.set(target, key, value[, receiver])
// 等同於
target[key] = value

舉個例子

let product = {price: 5, quantity: 2}

// 以下三種方法是等效的
product.quantity
product['quantity']
Reflect.get(product, 'quantity')

// 以下三種方法是等效的
product.quantity = 3
product['quantity'] = 3
Reflect.set(product, 'quantity', 3)

關於receiver引數

在 Proxy 和 Reflect 物件中 get/set() 方法的最後一個引數都是 receiver,它到底是個什麼玩意?

receiver 是接受者的意思,譯為接收器

  1. 在 Proxy trap 的場景下(例如 handler.get() / handler.set()), receiver 永遠指向 Proxy 本身或者繼承它的物件,比方說下面這個例子
let origin = { a: 1 }

let p = new Proxy(origin, {
  get(target, key, receiver) {
    return receiver
  },
})

let child = Object.create(p)

p.getReceiver // Proxy {a: 1}
p.getReceiver === p // true
child.getReceiver // {}
child.getReceiver === child // true
  1. 在 Reflect.get / Reflect.set() 的場景下,receiver 可以改變計算屬性中 this 的指向
let target = {
  firstName: 'li',
  lastName: 'baicheng',
  get a() {
    return `${this.firstName}-${this.age}`
  },
  set b(val) {
    console.log('>>>this', this)
    this.firstName = val
  },
}

Reflect.get(target, 'a') // li-undefined
Reflect.get(target, 'a', { age: 24 }) // undefined-24

Reflect.set(target, 'b', 'huawei', { age: 24 })
// >>>this {age: 24}
// true

搭配Proxy

在 Proxy 裡使用 Reflect,我們會有一個附加引數,稱為 receiver (接收器),它將傳遞到我們的 Reflect呼叫中。它保證了當我們的物件有繼承自其它物件的值或函式時, this 指標能正確的指向物件,這將避免一些我們在 vue2 中有的響應式警告

let origin = { a: 1 }

let p = new Proxy(origin, {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    return Reflect.set(target, key, value, receiver)
  },
})

Reflect物件經常和Proxy代理一起使用,原因有三點:

  1. Reflect提供的所有靜態方法和Proxy第2個handle物件中的方法引數是一模一樣的,例如Reflect的 get/set() 方法需要的引數就是Proxy get/set() 方法的引數

  2. Proxy get/set() 方法需要的返回值正是Reflect的 get/set() 方法的返回值,可以天然配合使用,比直接物件賦值/獲取值要更方便和準確

  3. receiver 引數具有不可替代性!!!

    在下面示例中,我們在頁面中訪問了 alias 對應的值,稍後 name 變化了,要重新渲染麼?

    target[key] 方式訪問 proxy.alias 時,獲取到 this.name,此時 this 指向 target,無法監控到 name ,不能重新渲染

    Reflect 方式訪問 proxy.alias 時,獲取到 this.name,此時 this 指向 proxy,可監控到 name ,可以重新渲染

const target = {
  name: '柏成',
  get alias() {
    console.log('this === target', this === target)
    console.log('this === proxy', this === proxy)
    return this.name
  },
}
const proxy = new Proxy(target, {
  get(target, key, receiver) {
    console.log('key:', key)
    return target[key]
    // return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    return Reflect.set(target, key, value, receiver)
  },
})
proxy.alias

使用 target[key] 列印結果:

使用 Reflect 列印結果:

如何用(How)

讓我們建立一個稱為 reactive 的函式,如果你使用過Composition API,你會感覺很熟悉。然後再封裝一下我們的 handler 方法,讓它長得更像 Vue3 的原始碼,最後我們將建立一個新的 Proxy物件

程式碼如下

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      let result = Reflect.get(target, key, receiver)
      // 儲存effect
      track(target, key)
      return result
    },
    set(target, key, value, receiver) {
      let oldValue = target[key]
      let result = Reflect.set(target, key, value, receiver)
      if (oldValue !== value) {
        // 執行effect
        trigger(target, key)
      }
      return result
    },
  }
  
  return new Proxy(target, handler)
}

let product = reactive({ price: 5, quantity: 2 })

現在我們已經不再需要手動呼叫 track()trigger()

讓我們分析一下上圖內容

  1. 現在我們的響應式函式返回一個 product 物件的代理,我們還有變數 total ,方法 effect()

  2. 當我們執行 effect() ,試圖獲取 product.price 時,它將執行track(product, 'price')

  3. targetMap 裡,它將為 product 物件建立一個新的對映,它的值是一個新的 depsMap ,這將對映 price 屬性得到一個新的 dep ,這個 dep就是一個 effects集(Set),把我們 total 的 effect加到這個集(Set)中

  4. 我們還會訪問 product.quantity ,這是另一個get請求。我們將會呼叫track(product, 'quantity')。這將訪問我們 product 物件的 depsMap,並新增一個 quantity 屬性到一個新的 dep 物件的對映

  5. 然後我們把 total 列印到控制檯是 10

  6. 然後我們執行product.quantity = 3,它會呼叫 trigger(product, 'quantity'),然後執行被儲存的所有 effect

  7. 呼叫 effect() , 就會訪問到 product.price ,觸發track(product, 'price');訪問到 product.quantity ,則觸發track(product, 'quantity')

ActiveEffect

我們每訪問一次Proxy例項屬性,都將會呼叫一次 track 函式。然後它會去歷遍 targetMap、depsMap,以確保當前 effect 會被記錄下來,這不合理,不需要多次新增 effect

這不是我們想要的,我們只應該在 effect() 裡呼叫 track 函式

console.log('Update quantity to = '+ product.quantity)
console.log('Update price to = '+ product.price)

為此,我們引入了 activeEffect 變數,它代表現在正在執行中的 effect, Vue3 也是這樣做的,程式碼如下

let activeEffect = null
...
// 負責收集依賴
function effect(eff){ 
  activeEffect = eff 
  activeEffect() // 執行
  activeEffect = null //復位
}

// 我們用這個函式來計算total
effect(() => {
  total = product.price * product.quantity
})

現在我們需要新的 track() 函式,讓它去使用這個新的 activeEffect 變數

function track(target, key){
  // 關鍵!!!
  // 我們只想在我們有activeEffect時執行這段程式碼
  if(!activeEffect) return

  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)時我們要新增activeEffect
  dep.add(activeEffect)
}

這樣就保證了,如果不是透過 effect() 函式去訪問Proxy例項屬性,則這時的 activeEffect 為 null ,進入 track() 函式立即就被 return 掉了

完整程式碼

這樣一來,我們就實現了 Vue3 基本的響應性了。完整程式碼如下

// The active effect running
let activeEffect = null

// For storing the dependencies for each reactive object
const targetMap = new WeakMap()

// 負責收集依賴
function effect(eff) {
  activeEffect = eff
  activeEffect() // 執行
  activeEffect = null //復位
}

// Save this code
function track(target, key) {
  // 關鍵!!!
  // 我們只想在我們有activeEffect時執行這段程式碼
  if (!activeEffect) return

  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()))
  }
  console.log('>>>track', target, key)
  //當我們新增依賴(dep)時我們要新增activeEffect
  dep.add(activeEffect)
}

// Run all the code I've saved
function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    return
  }
  let dep = depsMap.get(key)
  if (dep) {
    console.log('>>>trigger', target, key)
    dep.forEach(eff => {
      eff()
    })
  }
}

// 響應式代理
function reactive(target) {
  // 如果不是物件或陣列
  // 丟擲警告,並返回目標物件
  if (!target || typeof target !== 'object') {
    console.warn(`value cannot be made reactive: ${String(target)}`)
    return target
  }
  const handler = {
    get(target, key, receiver) {
      let result = Reflect.get(target, key, receiver)
      track(target, key)

      // 遞迴建立並返回
      if (typeof target[key] === 'object' && target[key] !== null) {
        return reactive(target[key])
      }
      return result
    },
    set(target, key, value, receiver) {
      let oldValue = target[key]
      let result = Reflect.set(target, key, value, receiver)
      if (oldValue !== value) {
        trigger(target, key)
      }
      return result
    },
  }
  return new Proxy(target, handler)
}

let product = reactive({ price: 5, quantity: 2, rate: { value: 0.9 } })
let total = 0

effect(() => {
  total = product.price * product.quantity * product.rate.value
})

控制檯列印結果如下

參考資料

相關文章