iT邦幫忙

2021 iThome 鐵人賽

DAY 6
0
Modern Web

不只懂 Vue 語法:Vue.js 觀念篇系列 第 6

不只懂 Vue 語法:Vue 3 如何使用 Proxy 實現響應式(Reactivity)?

問題回答

Vue 3 會為 data 建立一個 Proxy 物件,並在裏面建立 gettersetter 來取值和更新值,藉此實現響應式。因此不用直接操作原本的 data,而是操作代理 data 的 Proxy 的物件。相反, Vue 2 因為使用 Object.defineProperty 來為 data 的最外層每一個屬性加上 gettersetter,所以 Vue 2 是直接修改 data

同時,因為現在是操作 Proxy ,所以即使 data 裏有多層物件資料,我們仍然可以透過 Proxy 裏建立的 setter 來修改多層物件資料。但 Vue 2 就不能,因為 Vue 2 只是為 data 的最外層屬性逐一加上 gettersetter,因此內層的資料並沒有 setter,無法被正確修改。

以下會詳細解說 Vue 3 如何使用 Proxy 。承接上一篇討論 Vue 實現響應式(reactivity)的原理。這篇集中整理有關 Vue 3 響應式的原理。內容主要是參考 Vue 3 官方教學影片以及文件,並以個人理解和整理來作解說。

為什麼 Vue 3 要使用 Proxy 來達成響應式?

  • Vue 3 不用支援 IE,所以可以使用 ES6 語法 Proxy 和 Reflect 來處理響應式
  • 解決了沒法偵測陣列和物件變動的問題
  • 提升效能,不用跑迴圈的方式,為每個data裏的屬性,透過 Object.defineProperty() 逐一加上 getset

Proxy 是什麼?

Proxy 是代理的意思。就像在你要操作的資料前放置一個欄截器,每次操作資料時,都會先跑在 Proxy 裏的程式。以下是一個簡單例子:

const target = {
  user: {
    name: "Alysa",
    jobTitle: "front-end developer",
  },
};

const p = new Proxy(target, {
  get(target, key) {
    return target[key];
  },

  set(target, key, value) {
    // key 是 name, value 是 Tom
    target[key] = value;
    return true;
  },
});

p.user.name = "Tom"; // 觸發 set
console.log(p.user.name); // Tom

透過建立一個 Proxy 物件,並指向代理 target 這個物件。之後觸發 setter 來修改 user 裏的 name

所謂響應式(reactivity)要處理的事

在討論如何用 Proxy 實現響應式之前,簡單重溫要實現響應式所需要解決的問題。這一部分在上一篇已經提過,在這裏再簡單總結一次。重用上一篇的例子。假設現在有以下情景:

const data = {
    price: 100,
    quantity: 2,
}

const total = () => data.price * data.quantity
console.log(total()) // 200

當我修改了 price,照理 total 也要隨之然更新,所以 total 也要被重新執行,以下是錯誤示範:

let total;

const data = {
    price: 100,
    quantity: 2,
}

total = data.price * data.quantity
console.log(total) // 200
data.price = 200
console.log(total) // 仍然是 200,但我們期望是 400

正確做法是再次計算 total 的公式,才可以更新 total

let total;

const data = {
    price: 100,
    quantity: 2,
}

total = data.price * data.quantity
console.log(total) // 200
data.price = 200

// 再計算一次,才會更新 total
total = data.price * data.quantity
console.log(total) // 400

以上簡單例子,可見所謂的響應式,要達成兩件事才可以完成:

  1. 更新在 data 裏的 price 這個屬性的值
  2. 重新渲染所有有涉及到 price 值的元件

第一點:更新在 data 裏的 price 這個屬性的值

第一點,Vue 3 與 Vue 2 的原理是相同。同樣是在修改值時,就會觸發 set。並在 set 裏執行更新舊值的動作。但是, Vue 2 是在 Object.definedProperty() 裏的所定義的 set,而 Vue 3 就是在 Proxyhandler 函式裏定義 set

const data = {
    price: 100,
    quantity: 2,
}

const proxyData = new Proxy(data, {
    get(target, key, receiver) {
        
        //...(先作省略)執行把依賴儲存起來的程式碼
        
        let result = Reflect.get(target, key, receiver)
        return result
    },
    set(target, key, value, receiver){
        // 以下會回傳布林值
        let bol = Reflect.set(target, key, value, receiver)
        
        //...(先作省略)執行所有依賴,更新所有受這依賴影響的值
        // set handler 規定要回傳 true
        return bol
    }
})

proxyData.price = 1000  // 觸發 set()
console.log(proxyData.price) // 觸發 get()。結果是 1000

以上就是 Vue 3 使用 Proxy 來實現更新的概念。利用 Proxydata 包起來,並在 handler 裏使用 setget。所以,每次我們操作 data 時,我們是操作由 Vue 產生出來的 Proxy 物件,而非直接修改本身data這個物件,而它本身亦沒有響應式(reactive)。擁有響應式的是經過 Vue 包裝出來的Proxy 物件。相反,Vue 2 是直接修改 data 這物件,而且把它變成響應式。

另外,也補充說明Reflect 以及receiver參數。Reflect 是等同於使用.[] 來訪問物件:

Reflect.get(data, 'price')
// 等於
data.price

Reflect.set(data, 'price', 1000)
// 等於
data.price = 1000

receiver參數並非必需。 只是為了確保當我們所訪問的物件,如果它本身有繼承其他物件時,this是指向仍然是該物件本身。詳細解說可參考這篇文章提及的 Reflect 和 receiver 例子。因此,Vue 使用了 Reflect 來取值和修改值,而非一般像是obj.a 或是 obj['a'] 這樣的寫法。

第二點:重新渲染所有有涉及到 price 值的元件

第二點,因為 price 改變了,所以 total 就要重新計算,換言之所有用到 total 的元件都要重新渲染。但 Vue 怎麼知道哪一個元件有用到 total 這個值?同時又怎樣知道當 price 更新時, total 要隨之然更新?

這些問題,不論是 Vue 2 還是 Vue 3 所用的手法雖然不相同,但是概念是相同。概念一樣是:

  • 把依賴(dependency)的程式碼,即是 total = data.price * data.quantity 收集起來。
  • 為每個 data 裏的屬性做記錄。記錄它們會牽涉到哪些依賴。所以,當某個 data 裏的屬性的值更新時(即是 price),就會按這個紀錄,找出此值連帶的所有依賴程式碼,並重新執行這堆依賴(即是 total = ... ),從而更新所有有牽涉到此屬性的值 (即是 total)。

第一點,Vue 2 的做法是把這些依賴都存放在每個 data 屬性裏所建立的 Dep 實體。但 Vue 3 的做法是用 Map 來存放。概念如下圖:

值得一提是 Vue 3 是使用 Set 來存放所有依賴,Set 物件裏只會存放唯一值,裏面不會有重複的值。所以裏面不會有重複的依賴。

第二點,Vue 2 與 3 的原理是一樣。一樣是透過觸發 set,並在 set 裏面把之前儲存起來的依賴取出來,並重新跑一次。但因為第一點提到,記錄的手法不一樣了,所以實際上程式碼也會不一樣。

Vue 2 是觸發 dep.depend() 來儲存依賴,並用 dep.notify() 來執行依賴並更新值。但 Vue 3 是觸發 track() 來達成前者,trigger() 來達成後者。

接下來看看 trigger()track() 的程式碼及其概念:

// 儲存不同響應式物件,例如是 "data" 
const targetMap = new WeakMap()

// 觸發 get 時會跑 track,儲存依賴:

function track(target, key) {
  // 取得 depsMap
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    // 如果還沒建立 depsMap,就建立一個
    targetMap.set(target, (depsMap = new Map()))
  }
  // 取得對應 dep 實體
  let dep = depsMap.get(key)
  if (!dep) {
    // 如果沒有 dep 實體,就建立一個
    depsMap.set(key, (dep = new Set())) // Create a new Set
  }
  // 把依賴儲存起來
  dep.add(effect) 
}
// 觸發 set 時會跑 trigger,執行所有依賴來更新所有值:

function trigger(target, key) {
  // 取得 depsMap
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    return
  }
  // 取得對應的 dep 實體
  let dep = depsMap.get(key)
  if (dep) {
    dep.forEach(effect => {
      // 跑一次所有依賴
      effect()
    })
  }
}

所以,如果把之前討論到的 Proxy 部分,以及以上的track()trigger()合併起來,就是 Vue 3 實現響應式的概念。

完整程式碼

https://codepen.io/alysachan/pen/VwWLGGJ

總結

  • Vue 3 是透過 Proxy 來為資料實現響應式(reactivity),並在裏面建立 settergetter
  • Vue 2 和 3 響應式的原理是相同,只是手法不同。因為兩者同樣都是在處理兩個問題:一是如何更新值,二是如何更新被這個值所影響的值。
  • 第一點,兩者都是同樣在 setter 裏解決。第二點,涉及到 Vue 怎麼把依賴儲存起來,以及怎樣重新執行這些依賴。Vue 3 是使用 MapSet,以及 dep 實體來儲存依賴。當值被修改時,就儲透過查找Map,去找出對應的 dep 實體,把這實體裏的所有依賴跑一次,從而更新所有受影響的值。

最後,這篇文章只是簡單示範了 Vue 3 響應式的概念,實際上 Vue 在實行此概念時是更加複雜。

參考資料

Proxy and Reflect
一起來了解 Javascript 中的 Proxy 與 Reflect


上一篇
不只懂 Vue 語法: 在 Vue 2 為何無法直接修改物件型別資料裏的值?
下一篇
不只懂 Vue 語法:什麼是 Virtual DOM?Vue 如何利用 Virtual DOM?
系列文
不只懂 Vue 語法:Vue.js 觀念篇31

尚未有邦友留言

立即登入留言