Appearance
ref 與 reactive
上一章曾提過,除了 ref() 之外,Vue 還提供 reactive() 建立響應式物件。當時我們先擱著,現在是時候把兩者的差異與選擇方式講清楚了。這一節的最後,也會回答上一章留下的兩個問題:為什麼 ref 需要 .value,以及模板自動解包為什麼只適用於頂層。
reactive() 登場
reactive() 接收一個物件,回傳它的響應式版本。跟 ref 最直觀的差別是:存取屬性不需要 .value:
vue
<script setup>
import { reactive } from 'vue'
const form = reactive({
name: '',
subscribed: false
})
function fill() {
form.name = 'Kuro' // 直接賦值,不需要 .value
form.subscribed = true
}
</script>
<template>
<main class="app">
<p>{{ form.name || '(尚未填寫)' }}:{{ form.subscribed ? '已訂閱' : '未訂閱' }}</p>
<button type="button" @click="fill">
填入示範資料
</button>
</main>
</template>
<style scoped>
.app {
padding: 2rem;
}
</style>form.name = 'Kuro' 直接賦值,畫面照樣更新。而且響應性是深層的:form 裡再包幾層物件或陣列,修改巢狀屬性一樣會被追蹤。
看起來比 ref 省事?先別急著下結論,reactive() 的限制也不少。
ref 與 reactive 的核心差異
| 比較維度 | ref | reactive |
|---|---|---|
| 可接受的值 | 任何值:原始型別、物件、陣列都行 | 只接受物件、陣列這類物件型別 |
| 存取方式 | script 裡要 .value | 直接存取屬性 |
| 整體替換 | count.value = newValue 隨時可換 | 整個重新指派會斷開響應性 |
| 解構 | 變數本身就是一個 ref,傳遞不失聯 | 解構出原始型別屬性會中斷響應性 |
| 巢狀物件 | .value 裝的是物件時,內部一樣被深層追蹤 | 深層追蹤 |
| 模板解包 | 作為模板渲染脈絡的頂層屬性時自動解包 | 物件本身不是 ref,直接存取屬性 |
表格裡殺傷力最大的是「整體替換」與「解構」這兩列,我們分別看。
先看整體替換。用 reactive 裝了一份資料,之後想整包換新(例如從 API 重新抓了一份):
js
let form = reactive({ name: 'Kuro' })
form = reactive({ name: 'Vue Reader' }) // 模板不會更新第二行建立了一個全新的響應式物件,但模板追蹤的還是第一個物件,畫面從此和資料脫鉤。ref 沒有這個問題:form.value = { name: 'Vue Reader' } 換掉的是 ref 裡面的值,模板追蹤的 form 這個 ref 本身沒變,更新照常運作。
解構的問題留給下一節,它值得單獨一個小節。
該用哪一個
本書的建議與 Vue 官方一致:預設用 ref。理由有三個:
- 什麼值都能裝,不用在「這筆資料是不是物件」之間切換心智。
.value雖然多打幾個字,卻讓「這是響應式資料」在程式碼裡一目瞭然,資料來源好追蹤。- 整體替換、傳遞、解構都不會踩到失聯的坑。
那 reactive 什麼時候登場?當一組欄位緊密相關、永遠一起出現,而且不需要整體替換時,reactive 可以省去成串的 .value,例如表單欄位的集合。把它想成特定場景的選配,而不是 ref 的替代品。
上一章說過「先統一使用較容易追蹤資料來源的 ref()」,現在各位讀者應該能看出這句話的完整意思了。
解構與響應性
reactive 最容易踩的坑是解構。物件解構是 JavaScript 的日常寫法,但對 reactive 物件解構原始型別的屬性,拿到的是當下值的複本,跟來源從此斷開:
vue
<script setup>
import { reactive } from 'vue'
const state = reactive({ count: 0 })
const { count } = state // 解構的當下,count 就與 state.count 斷開了
function increment() {
state.count++
}
</script>
<template>
<main class="app">
<p>state.count:{{ state.count }}</p>
<p>解構出來的 count:{{ count }}</p>
<button type="button" @click="increment">
加一
</button>
</main>
</template>
<style scoped>
.app {
padding: 2rem;
}
</style>按幾次按鈕,state.count 一直增加,解構出來的 count 卻停在 0。
常見錯誤:解構 reactive() 的原始型別屬性,響應性就中斷了
const { count } = state 只是把「解構當下」的數字 0 複製給區域變數 count,之後 state.count 再怎麼變,都與這個區域變數無關。
兩種解法:
js
// 解法一:不解構,保持透過 state 存取
state.count
// 解法二:需要拆開傳遞時,用 toRefs() 把每個屬性轉成 ref
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0 })
const { count } = toRefs(state)
count.value // 這是 ref,與 state.count 保持連動要注意中斷的是「原始型別屬性」:如果解構出來的屬性本身是物件(例如 const { user } = state),拿到的是同一個響應式物件的參照,繼續修改 user.name 仍會觸發更新。斷開的是變數與屬性之間的繫結,不是物件內部的響應性。
ref 這邊就單純得多:ref 本身是一個物件,把它傳進函式、放進陣列、換個名字,拿到的都是同一個 ref,.value 永遠指向同一份資料。
響應式系統的運作原理
前面累積了不少「規則」:reactive 為什麼不能整體替換?解構為什麼會斷?ref 為什麼需要 .value?這些規則背後其實是同一套機制,理解它,規則就不用背了。
Vue 3 的響應式核心是 JavaScript 的 Proxy。reactive() 回傳的不是原本的物件,而是包了一層代理的版本,讀取與寫入都會被攔截:
- 元件渲染或 computed 執行並讀取屬性時 (track):Vue 記下「目前這項工作用到了哪個屬性」。例如渲染模板時讀到
form.name,Vue 就會記錄「這個元件的畫面依賴form.name」。 - 程式寫入屬性時 (trigger):Vue 查詢依賴表,找出哪些元件或計算結果用過這個屬性,再通知它們更新。
留意 track 的前提:它只在渲染、computed、watch 這類響應式工作執行期間發生。平常在程式裡讀一下 form.name(例如 console.log),不會憑空建立一個有用途的依賴。
另外,trigger 也不會立刻重畫 DOM,而是先把需要更新的元件排進佇列。短時間內連續修改多筆資料時,Vue 會把它們合併處理,避免同一個元件重複渲染。如果需要在 DOM 更新完成後才做某件事,Vue 提供了 await nextTick() 這個等待點;實際示範留到第四章講 template refs 時一起看。
把這個循環畫出來:
注意右下角的箭頭:更新佇列處理完,元件重新渲染,也就重新讀取資料、重新收集依賴。track 與 trigger 不是兩條單行道,而是一個循環。
用這套機制回頭看前面的規則,每一條都說得通:
為什麼 reactive 不能整體替換? 因為攔截發生在「那個被代理的物件」上。form = reactive({...}) 換掉的是變數指向,原本的代理物件還在,模板的依賴也還記在它身上,新物件則沒人追蹤。
為什麼解構原始型別會斷? 因為 const { count } = state 是一次「讀取」,Proxy 把當下的數字交出去之後,那個數字就只是普通的數字,後續對它的存取不會經過代理,Vue 自然無從追蹤。
為什麼 ref 需要 .value? 這是最關鍵的一題:Proxy 只能代理物件,0、'hello'、true 這些原始型別無法被代理。Vue 的解法是把值包進一個物件,用 .value 這個屬性來存取。如此一來,讀 count.value 會經過 getter(track 掛在這裡),寫 count.value 會經過 setter(trigger 掛在這裡)。.value 不是官方故意為難誰,它就是響應式追蹤的掛載點。
順帶回答上一章留下的第二個問題:模板的自動解包。為了讓模板容易閱讀,Vue 會自動解包模板渲染脈絡中的頂層 ref;在本書的 <script setup> 範例裡,就是直接宣告在頂層的 count。可以把它想成「Vue 幫我們取出 .value」,但不必理解成真的在原始碼裡補上一段 .value。
包在一般物件屬性裡的 ref 則不是頂層屬性。假設 const obj = { count: ref(1) },在模板插值裡計算 obj.count + 1 時,obj.count 不會自動解包,結果也不會是 2。純文字插值有一個方便閱讀的例外:ref 若剛好是插值最後算出的值,直接插值 obj.count 仍會顯示 1,效果等同插值 obj.count.value。
如果 ref 是放在 reactive() 建立的深層響應式物件屬性裡,情況又不同:存取屬性時,reactive 本身就會替它解包。也就是 const state = reactive({ count: ref(1) }) 的 state.count 會直接得到 1。記住分界即可:頂層 ref 在模板中會解包;一般物件裡的巢狀 ref 不會在運算式中解包;reactive 物件屬性則會在存取時解包。
陣列與集合不會自動解包
上述規則有一個重要例外:ref 如果是 reactive 陣列的元素,或放在 Map、Set 這類原生集合中,取用時不會自動解包,仍然需要 .value:
js
const list = reactive([ref(1)])
console.log(list[0].value)
const counts = reactive(new Map([['books', ref(1)]]))
console.log(counts.get('books').value)因此「reactive 會解包 ref」要更精確地理解為:它會解包深層響應式物件的屬性,不包含陣列元素與原生集合裡的值。
原理先講到這裡。日常開發只要知道 track 如何記錄依賴、trigger 如何通知更新即可;更底層的 effect 實作暫時不用碰。
下一節回到元件開發的主線:讓 <BookCard /> 這樣的元件,能夠接收外部傳進來的資料。
