技術筆記

Vue 3 的響應式原理:Proxy、track 和 trigger

畫面會自動更新,是因為 Proxy 攔截了資料的讀寫:讀取時登記誰在用,寫入時通知它們重新執行。

#Vue #面試準備

目錄

  1. 一句話
  2. 核心模型:三個角色
  3. 怎麼運作:get 登記、set 觸發、完整的循環
  4. 怎麼用:reactive、ref、shallowRef
  5. 和 Vue 2 的差別
  6. 我原本的誤解
  7. 附錄:可以執行的最小實作

1. 一句話

Vue 3 用 Proxy 攔截資料的讀寫:讀取(get)時登記「誰在用這筆資料」,寫入(set)時通知這些人重新執行,畫面就更新了。

2. 核心模型:三個角色

整個響應式系統只有三個角色,後面所有內容都是在講它們怎麼互動。

角色是什麼例子
資料被 Proxy 包住的物件,讀寫都會被攔截reactive({ count: 0 })、ref(0)
effect會讀資料的函式,資料變了要重新執行元件的渲染函式、computed、watch
名單每個屬性一份,記錄哪些 effect 讀過它count 的名單上有「元件 A 的渲染函式」

我用 axios 的攔截器來理解 Proxy:請求和回應都先經過攔截器處理,使用的人照常呼叫 axios.get(),感覺不到它存在。Proxy 也一樣,照常寫 state.count,讀寫都會先被攔下來。

const raw = { count: 0 }
const state = reactive(raw)

state.count       // 經過 Proxy 的 get
state.count = 1   // 經過 Proxy 的 set
raw.count = 2     // 繞過 Proxy,Vue 不知道,畫面不會更新

3. 怎麼運作

get:回傳值,順便登記(track)

get 本來的工作是把值交出去,同時把正在執行的 effect 加進這個屬性的名單。

  • 每個屬性各有一份名單:改 count 只會通知讀過 count 的 effect
  • 不會重複登記:名單用 Set 存
  • 只有在 effect 裡讀才會登記:在一般函式或事件處理裡讀,沒有正在執行的 effect,就不登記
  • 每次執行都重新登記:有 if 判斷時,只有實際讀到的屬性會被登記

set:寫入,然後觸發(trigger)

觸發是 set 最主要的工作。沒有觸發,名單上的 effect 就不知道資料變了,會一直用舊的值。

set 寫入新的值之後,如果值真的有變,就把這個屬性名單上的 effect 一個一個拿出來重新執行。值沒變就不通知。

被通知的 effect 不一定馬上執行:

effect收到通知後
元件渲染排進佇列,同一輪的多次修改合併成一次渲染,nextTick 就是在等這個佇列
computed只標記舊值作廢,下次被讀到才重算
watch依設定的時機執行回呼

完整的循環

  1. 渲染函式執行,讀到 count:get 回傳值,把渲染函式登記到 count 的名單
  2. 修改 count:set 寫入,把名單上的渲染函式拿出來重新執行
  3. 渲染函式重新執行,又讀到 count:get 拿到新的值,重新登記
  4. 渲染函式產生新的虛擬 DOM,和舊的比對(diff),只更新有變化的真實 DOM
  5. 等下一次 set,回到第 2 步

第 3 步又呼叫了 get,但不會無限循環,因為 get 只登記、不觸發。反過來說,如果在渲染函式或 computed 裡修改資料,就可能變成「set、重新渲染、又 set」的循環,所以 computed 裡不應該改資料。

例子:v-model

<input v-model="name">
<!-- 編譯後大致是 -->
<input :value="name" @input="name = $event.target.value">
  • :value="name" 在渲染時讀取:元件的渲染函式被登記到 name 的名單
  • @input 在打字時寫入:觸發 set,元件重新渲染

4. 怎麼用:reactive、ref、shallowRef

Proxy 只能包物件

數字、字串、布林值不是物件,Proxy 包不了。

  • reactive() 直接用 Proxy,所以只能放物件(物件、陣列、Map、Set)
  • ref() 為了能放任何值,做一個物件把值放在 .value 裡,再用 getter、setter 攔截 .value 的讀寫

ref 要寫 .value 的原因就在這裡:要有一個屬性可以攔截。在 template 裡會自動解開,直接寫名稱就好。

ref 放物件:其實是兩層

ref 放的值如果是物件,會在內部用 reactive 轉成 Proxy,所以 ref([]) 有兩層,各有自己的名單:

list(ref 物件)
 └─ .value         第一層:getter、setter,名單掛在 list 的 value
     └─ Proxy(陣列)  第二層:reactive,名單掛在陣列的索引、length
const list = ref([])
list.value.push(4)       // 觸發第二層:陣列的 length 變了,.value 還是同一個陣列
list.value = [7, 8, 9]   // 觸發第一層:.value 換成新的陣列,新陣列也會先轉成 Proxy

第二行是陣列用 ref 最實用的地方:打 API 拿到新的列表,可以直接整個換掉。

reactive 的坑

reactive 只有第二層,沒有外面那層 .value:

let state = reactive({ list: [] })
state = reactive({ list: [1, 2] })
// 只是把普通變數指向新的 Proxy,沒有經過任何 set
// 元件登記在舊的 Proxy 上,不會收到通知

const s = reactive({ count: 0 })
let { count } = s
count++
// 解構等於把值複製出來,count 只是普通的數字,已經不經過 Proxy
// 要保留響應的話改用 toRefs(s)

所以 ref 多包一層 .value 不只是寫法上的麻煩,它就是 ref 比較泛用的原因:換值變成「寫 .value」,會經過 setter,就能觸發。

shallowRef

只有第一層,不會把內容轉成 Proxy。適合很大、只會整個換掉的資料:

const big = shallowRef([])
big.value = res.data   // 觸發
big.value.push(1)      // 不觸發,沒有第二層

怎麼選

第一層(.value)第二層(Proxy)整個換掉改內容能放基本型別
reactive沒有有不會觸發會觸發不行
ref有有會觸發會觸發可以
shallowRef有沒有會觸發不會觸發可以

Vue 官方文件(Reactivity Fundamentals)列出 reactive 的三個限制:只能放物件、不能整個換掉、解構會斷掉響應,因此建議宣告響應式狀態時優先用 ref()。

5. 和 Vue 2 的差別

Vue 2 用 defineProperty

Vue 2 在初始化時遞迴走過整個資料,對每一層的每個屬性用 Object.defineProperty 設定 getter、setter。所以 Vue 2 也有深層響應,改巢狀物件的屬性一樣會更新畫面。

問題在於 defineProperty 只能攔截設定當下已經存在的屬性,而且 Vue 2 沒有對陣列的每個索引設定 getter、setter(陣列可能很長,成本太高)。所以這些偵測不到:

this.items[0] = 'x'     // 用索引改
this.items.length = 0   // 改長度
this.user.age = 30      // 新增一開始不存在的屬性

Vue 2 的補救是改寫陣列的 7 個方法(push、pop、shift、unshift、splice、sort、reverse),其他情況要用 Vue.set 或 splice。

Vue 3 用 Proxy

Proxy 攔截整個物件的所有操作,不管屬性一開始存不存在,上面三種寫法都會經過 set。

深層響應也改成讀到才轉換:get 讀到的值如果是物件,當下才用 reactive 包起來回傳,沒讀到的巢狀物件就一直不會被處理。

問題Vue 2Vue 3解決的關鍵
巢狀物件改了要響應可以(初始化時遞迴)可以(讀到才轉換)兩個版本都有,Vue 3 改成延遲
用索引改陣列、改長度偵測不到可以Proxy
物件新增屬性偵測不到,要用 Vue.set可以Proxy

所以 Vue 3 改用 Proxy 的好處有兩個:能攔截新增的屬性、陣列索引和長度,不用再寫 Vue.set。深層響應改成讀到才轉換,大資料初始化比較快。

6. 我原本的誤解

我原本以為實際上
Proxy 監聽的是元件Proxy 包的是資料,元件是讀資料的 effect,被登記在名單上
Proxy 是一種封裝是攔截。封裝是把東西藏起來,Proxy 沒有藏任何東西,介面也沒變
用「監聽」形容 Proxy口語可以,但監聽是事後收到通知,攔截是事前攔下來,還能決定回傳什麼
set 通知是「副作用」在 Vue 的術語裡,effect(副作用)指的是被通知的那些函式,通知這件事叫觸發
set 觸發後,名單上的 DOM 再呼叫一次 get名單上是函式不是 DOM。函式重新執行時順便呼叫了 get,改 DOM 的是 diff 之後的 patch
畫面會更新是因為 v-modelv-model 只是 :value 讀加 @input 寫,任何響應式資料改了畫面都會更新
深層攔截是為了解決 Vue 2 陣列的問題兩件事。Vue 2 本來就有深層響應,陣列問題是靠 Proxy 解決的

7. 附錄:可以執行的最小實作

存成 .js 用 Node 執行。這個版本沒有排程佇列,所以每次修改都會馬上重新執行,Vue 實際上會合併成一次渲染。

const targetMap = new WeakMap()   // 資料 → 屬性 → 名單
let activeEffect = null           // 現在正在執行的 effect

function track(target, key) {
  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.add(activeEffect)
}

function trigger(target, key) {
  targetMap.get(target)?.get(key)?.forEach(effect => effect())
}

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      track(target, key)
      const res = Reflect.get(target, key, receiver)
      // 讀到物件才轉成 Proxy(延遲轉換)
      return typeof res === 'object' && res !== null ? reactive(res) : res
    },
    set(target, key, value, receiver) {
      const isNew = !Object.prototype.hasOwnProperty.call(target, key)
      const changed = !Object.is(target[key], value)
      const ok = Reflect.set(target, key, value, receiver)
      if (changed) trigger(target, key)
      // 陣列新增索引時(例如 push),length 已經跟著變了,要另外通知讀過 length 的人
      if (isNew && Array.isArray(target)) trigger(target, 'length')
      return ok
    }
  })
}

function ref(value) {
  const wrapper = {
    _value: toReactive(value),
    get value() {
      track(wrapper, 'value')
      return this._value
    },
    set value(newVal) {
      if (Object.is(newVal, this._value)) return
      this._value = toReactive(newVal)
      trigger(wrapper, 'value')
    }
  }
  return wrapper
}

function toReactive(v) {
  return typeof v === 'object' && v !== null ? reactive(v) : v
}

function effect(fn) {
  activeEffect = fn
  fn()
  activeEffect = null
}

// reactive:只有讀過的屬性會觸發
const state = reactive({ count: 0, other: 0 })
effect(() => console.log('render, count =', state.count))
state.count++   // 印出 count = 1
state.other++   // 不會印,effect 沒有讀 other

// ref 放陣列:兩層都會觸發
const list = ref([])
effect(() => console.log('render, length =', list.value.length))
list.value.push(4)       // 第二層,印出 length = 1
list.value = [7, 8, 9]   // 第一層,印出 length = 3