昨天我們談了 ref 存在的理由:reactive 只能包物件,而且不能整包替換,ref 用一個 .value 補上了這兩個洞。
但 reactive 還有一個更常踩到的坑,幾乎每個 Vue 3 新手都掉進去過:
const state = reactive({ count: 0 })
const { count } = state
看起來只是在偷懶少打幾個字,結果畫面再也不會更新了。
今天就來拆這件事。跟前幾天一樣,答案不在 Vue 裡,在 JavaScript 本身。
reactive 的響應式,綁在「Proxy 物件的屬性存取」上。值一旦被取出來、離開這個物件,就斷線了。
toRefs做的事,是讓取出來的東西「每次讀寫都回頭問原物件」。
支撐這個結論的有三個論點:
const state = reactive({ count: 0 })
const { count } = state
count 拿到的是什麼?是「數字 0」,還是「某個能連回 state 的東西」?state.count++,這個 count 變數會跟著變嗎?想一下再往下看。
第 1 題:const { count } = state 等於 const count = state.count。
第 2 題:state.count 是原始型別的數字,JS 對原始型別是「複製值」,所以 count 只是一個獨立的 0,跟 state 已經沒有關係。
第 3 題:不會。這跟 Day 4 是同一個結論:JS 攔不到變數賦值,也沒有機制讓一個變數自己去追蹤它的來源。
沿用前幾天的概念,精簡成可以直接貼進 console 的版本:
let activeEffect = null
function effect(fn) {
activeEffect = fn
fn()
activeEffect = null
}
function reactive(obj) {
const deps = {} // 一個 key 一個 Set
return new Proxy(obj, {
get(target, key) {
if (activeEffect) (deps[key] ??= new Set()).add(activeEffect)
return target[key]
},
set(target, key, value) {
target[key] = value
deps[key]?.forEach(fn => fn())
return true
}
})
}
先看解構的情況:
const state = reactive({ count: 0 })
const { count } = state
effect(() => console.log(count)) // 印 0
state.count++ // 沒有任何輸出
再看正確的讀法:
effect(() => console.log(state.count)) // 印 0
state.count++ // 印 1
差別只有一個:effect 裡面讀的是 state.count(經過 Proxy)還是 count(普通變數)。
const { count } = state 雖然也會觸發 Proxy 的 get,但當下 activeEffect 是 null,沒有人被收集。count 就是一個普通的數字 0。之後 effect 讀它,根本不會經過 Proxy。Vue 的追蹤能力,完全建立在「透過 Proxy 讀取屬性」這件事上。值一旦離開 Proxy,Vue 就看不到了。
Day 4 學過,ref 的本質是一個帶 getter / setter 的 { value } 物件。
那如果這個 getter / setter 不自己存值,而是轉發到 state[key],會怎樣?
.value 時,走的是 state[key],會觸發 Proxy 的 get,被追蹤.value 時,走的是 state[key] = v,會觸發 Proxy 的 set,通知更新換句話說,這個東西雖然被拿走了,但每次讀寫都會回頭經過 Proxy。
這就是 toRefs 在做的事。
function myToRef(obj, key) {
return {
get value() { return obj[key] },
set value(v) { obj[key] = v }
}
}
function myToRefs(obj) {
const result = {}
for (const key in obj) {
result[key] = myToRef(obj, key)
}
return result
}
不到十行。驗證看看:
const state = reactive({ count: 0 })
const { count } = myToRefs(state) // 解構出來的是 ref 物件,不是數字
effect(() => console.log(count.value)) // 印 0
state.count++ // 印 1(從原物件改)
count.value++ // 印 2(從 ref 改,一樣會回到 state)
兩個方向都通。因為 count 手上拿的不是值,而是一個「代理人」,每次都去問 state。
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0, name: '小明' })
const { count, name } = toRefs(state)
count.value++
console.log(state.count) // 1
官方文件的說法也是這樣:toRefs 會把 reactive 物件轉成一組 ref,這些 ref 與原物件維持連線。代價是解構出來的東西要用 .value。
const count = ref(state.count)
這行看起來像「幫 state.count 做一個 ref」,其實只是把當下的值複製進一個全新的 ref,跟 state 沒有任何連線。斷線的原因跟解構完全一樣。
要「連回原物件」的 ref,用 toRef:
const count = toRef(state, 'count')
原因都一樣:值被複製出去,或引用被換掉。
| 寫法 | 為什麼斷 |
|---|---|
const { count } = state |
解構是複製值 |
let n = state.count |
一樣是複製值 |
foo(state.count) |
傳給函式的是複製的數字(傳整個 state 則不會斷) |
ref(state.count) |
只是用當下的值做一個新 ref |
state = { count: 1 } |
變數換了指向,新物件不是 Proxy |
toRefs 只會處理呼叫當下已經存在的屬性。如果屬性是之後才出現的(例如可有可無的欄位),就用 toRef(state, 'key'),它可以處理還不存在的屬性。
...toRefs(state):讓使用的人可以放心解構。toRef(state, 'key')。你可能看過這種寫法:
const { foo } = defineProps(['foo'])
在 Vue 3.5 之前,這樣解構出來的變數同樣是斷線的。3.5 之後,Vue 讓它在 <script setup> 裡保持響應式,做法不是魔法,而是編譯器直接幫你改寫:讀取 foo 的地方,會被自動編譯成 props.foo。
這跟今天的主題是同一個道理:JS 攔不到變數,所以 Vue 選擇在編譯階段把「變數讀取」改寫成「屬性讀取」,讓它重新經過 Proxy。
但要注意,這也帶來一個限制:如果想把解構出來的 prop 交給 watch 或 composable,必須包成 getter,例如 watch(() => foo, ...),直接寫 watch(foo, ...) 會在編譯階段報錯。
順帶一提,Day 4 提到的 Reactivity Transform($ref 那套語法糖)就是想在更大範圍做同樣的事,最後因為種種理由被移除了。
| 層級 | 一句話 |
|---|---|
| 結論 | 值離開 Proxy 物件就斷線;toRefs 讓取出來的東西「隨時回頭問原物件」 |
| 原因 | 只有 Proxy 的 get / set 有追蹤;解構是複製原始值,而且讀取時不在 effect 裡 |
| 解法 | ref 的 getter / setter 轉發到 state[key],讀寫都會經過 Proxy |
| 邊界 | 解構、傳值、ref(state.x)、整包替換都會斷;預設用 ref 最省事 |
幾個重點:
const count = state.count,是複製值,不是連結toRefs 不是魔法,就是十行以內的 getter / setter 轉發ref(state.count) 是複製,toRef(state, 'count') 才是連結toRefs 只處理呼叫當下存在的屬性,之後才出現的屬性用 toRef
Day 4 結尾還留了一個洞:我們的迷你 reactive 只能處理單層物件,state.nested.count++ 是不會觸發更新的。下一篇就來補這個洞。