Vue 3 的響應式原理:Proxy、track 和 trigger
畫面會自動更新,是因為 Proxy 攔截了資料的讀寫:讀取時登記誰在用,寫入時通知它們重新執行。
目錄
- 一句話
- 核心模型:三個角色
- 怎麼運作:get 登記、set 觸發、完整的循環
- 怎麼用:reactive、ref、shallowRef
- 和 Vue 2 的差別
- 我原本的誤解
- 附錄:可以執行的最小實作
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 | 依設定的時機執行回呼 |
完整的循環
- 渲染函式執行,讀到
count:get 回傳值,把渲染函式登記到count的名單 - 修改
count:set 寫入,把名單上的渲染函式拿出來重新執行 - 渲染函式重新執行,又讀到
count:get 拿到新的值,重新登記 - 渲染函式產生新的虛擬 DOM,和舊的比對(diff),只更新有變化的真實 DOM
- 等下一次 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 2 | Vue 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-model | v-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