昨天我們自己做了一個迷你響應式,核心只有兩件事:get 的時候記錄是誰在用,set 的時候通知它們重新執行。
寫完之後,reactive 看起來已經很萬能了。那為什麼 Vue 還要多給我們一個 ref,而且每次都要多寫一個 .value?
第一次寫 Vue 3 的人幾乎都問過這句:「不能全部用 reactive 就好嗎?」
今天就來回答這個問題。而且跟昨天一樣,答案不在 Vue 裡,在 JavaScript 本身。
直接把昨天的 reactive 拿來包一個數字:
const count = reactive(0)
先想一下會發生什麼事。
昨天的 reactive 實作是這樣:
function reactive(obj) {
return new Proxy(obj, { /* ... */ })
}
所以這行其實等於 new Proxy(0, handler)。丟進瀏覽器跑跑看:
new Proxy(0, {})
// TypeError: Cannot create proxy with a non-object as target
直接報錯。
Proxy 的 target 必須是物件或函式,原始型別(number、string、boolean、null、undefined)一律不行。這是 JavaScript 規格層級的規定,不是 Vue 擋你。
那 Vue 真正的 reactive() 呢?它不會報錯,但也沒有幫你做什麼:
import { reactive } from 'vue'
const count = reactive(0)
// [Vue warn] value cannot be made reactive: 0
console.log(count) // 0,原封不動還給你
Vue 在開發模式下印一行警告,然後把原本的值還給你。所以 reactive(0) 不是「不建議」,是根本做不到。
到這裡你可能會想:那是 Proxy 的問題,如果哪天 JS 讓 Proxy 能包數字,不就解決了嗎?
我們退一步假設它可以好了。來看這段:
let count = reactive(0)
count++
count++ 本質上是 count = count + 1。
問題來了:這行改的是「count 這個變數指向誰」,不是某個物件的屬性。
昨天我們能攔截,是因為 state.count++ 動到的是 state 這個物件的 count 屬性,Proxy 的守門人站在 state 前面,看得到這個動作。
但 count = 1 這種變數賦值,JavaScript 沒有提供任何插手的機制。沒有 trap,沒有 hook,沒有事件。你無法知道一個變數被重新指向了別的值。
這才是真正的核心限制:
Vue 的攔截能力,完全建立在「物件的屬性存取」這件事上。沒有屬性,就沒有 get 和 set 可以攔。
既然只有屬性攔得到,而我們手上是一個數字,那解法其實只剩一條路:
把值塞進一個物件的屬性裡。
const count = { value: 0 }
count.value = 1 // 這下有屬性可以攔了
就這樣。ref 的整個設計動機就是這一行。
.value 不是 Vue 想讓你多打幾個字,而是把「變數賦值」硬是改寫成「屬性賦值」的唯一辦法。
有了方向,接著就能實作。這裡直接沿用昨天的 activeEffect 和 effect:
let activeEffect = null
function effect(fn) {
activeEffect = fn
fn()
activeEffect = null
}
然後寫 myRef:
function myRef(initialValue) {
let _value = initialValue
const dep = new Set() // 記錄誰用到這個值
return {
get value() {
if (activeEffect) {
dep.add(activeEffect) // 讀取時:收集
}
return _value
},
set value(newValue) {
if (newValue === _value) return // 值沒變就不用通知
_value = newValue
dep.forEach(fn => fn()) // 修改時:通知
}
}
}
跟昨天的 reactive 比對一下會很清楚:
| reactive | ref | |
|---|---|---|
| 攔截方式 | Proxy 的 get / set trap | 物件的 getter / setter |
| 攔截範圍 | 整個物件的所有屬性 | 只有 .value 一個屬性 |
| 依賴存在哪 | 一個 key 一個 Set | 只有一個 Set |
因為只需要顧一個 .value,用 getter / setter 就夠了,不必開一個 Proxy。
實際用用看:
<p id="count"></p>
<button id="btn">+1</button>
const count = myRef(0)
effect(() => {
document.querySelector('#count').textContent = count.value
})
document.querySelector('#btn').addEventListener('click', () => {
count.value++
})
流程跟昨天一模一樣:
effect 執行更新畫面的函式,函式裡讀了 count.value
dep
count.value++ 觸發 setterdep 裡的函式重新執行,畫面更新很多教學會說「ref 和 reactive 都是用 Proxy 實作的」,這不太精確。
Vue 原始碼裡的 ref 是一個叫 RefImpl 的 class,本質上就是我們上面寫的那種帶 getter / setter 的物件。因為只要追蹤一個 .value,開一個完整的 Proxy 反而更重。
ref 不是只能包原始型別。當你傳一個物件進去時,Vue 內部會用 reactive() 把它轉成 Proxy,再存進 .value:
const user = ref({ name: '小明' })
user.value.name = '小華' // 有響應式,因為 .value 裡面是 reactive
user.value = { name: '小美' } // 整包換掉也有響應式
注意第二行。這是 reactive 做不到的事:
let state = reactive({ name: '小明' })
state = { name: '小美' } // 響應式斷了,畫面不會更新
因為 state = ... 又回到了「變數賦值」,守門人管不到。而 ref 因為外面包了一層 .value,換掉內容物是屬性賦值,攔得到。
這就是為什麼官方建議預設就用 ref:原始型別、物件、整包替換它都能處理,reactive 則只能處理物件,而且不能整包換。
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<p>{{ count }}</p>
</template>
因為 <script setup> 頂層的 ref 在編譯時會自動解包。
但只有頂層會。這個坑很常踩:
const obj = { count: ref(0) }
<template>
{{ obj.count }} <!-- 印出一個 ref 物件 -->
{{ obj.count.value }} <!-- 這才是 0 -->
</template>
obj 是普通物件,Vue 的自動解包不會往裡面鑽。
如果你覺得「兩個 API 做類似的事,很難選」,這不是你的錯覺。
2021 年,尤雨溪提出了一個叫 Reactivity Transform 的實驗性提案(RFC #369),想用編譯期語法糖讓你這樣寫:
let count = $ref(0)
count++ // 編譯成 count.value++
他在提案的動機裡直接寫道:自從 Composition API 推出以來,ref 與 reactive 該怎麼選一直是主要的未解問題之一;到處寫 .value 很繁瑣,在沒有型別系統輔助時又容易漏掉,所以有些使用者乾脆只用 reactive() 來迴避 ref。
同一份文件裡,他也解釋了 .value 為什麼非存在不可:原本的寫法不需要任何編譯就能運作,但受限於 JavaScript 的運作方式,必須透過 .value 這個屬性,Vue 才能攔截它的 get / set,藉此進行依賴追蹤與觸發更新。
跟我們今天從 new Proxy(0, {}) 一路推下來的結論,完全是同一件事。
2023 年 2 月,尤雨溪宣布團隊有共識放棄這個語法糖。理由裡有一條特別值得想一下:
拿掉 .value 之後,反而更難看出哪些東西正在被追蹤、哪一行會觸發更新。 在小組件裡不明顯,但在大型專案裡心智負擔會被放大。
其他理由還包括:有人只敢在 SFC 裡用、SFC 外不用,造成兩套心智模型;外部函式仍然需要真正的 ref 物件,轉換無可避免,反而讓初學者更困惑;以及讓變數賦值就能觸發副作用,扭曲了 JavaScript 原本的語義。
所以結論有點反直覺:
.value看起來是缺點,但它同時是一個「這行會觸發更新」的視覺標記。
你每次寫 .value 的時候,其實是在跟未來的自己說:這裡碰到的是響應式資料。
reactive 靠 Proxy,而 Proxy 不能包原始型別,所以 reactive(0) 做不到ref 的解法就是造一個 { value } 物件,把變數賦值轉成屬性賦值ref 內部是 getter / setter,不是 Proxy;傳物件進去時才用 reactive 包內容物ref 可以整包替換,reactive 不行,這是預設選 ref 的主因<script setup> 頂層的 ref 在模板會自動解包,巢狀的不會.value,最後發現它其實是個有用的標記昨天的 mini reactive 還留了一個洞:它只能處理單層物件,巢狀物件完全沒管。明天 Day 5 就來補這個洞,順便談 reactive 最常見的陷阱——為什麼解構出來的變數就失去響應式了,以及 toRefs 到底在做什麼。