Skip to content

ref 與 reactive

重新認識 Vue.js: 008 天絕對看不完的 Vue.js 3.x 指南

上一章曾提過,除了 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() 的限制也不少。

refreactive 的核心差異

比較維度refreactive
可接受的值任何值:原始型別、物件、陣列都行只接受物件、陣列這類物件型別
存取方式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。理由有三個:

  1. 什麼值都能裝,不用在「這筆資料是不是物件」之間切換心智。
  2. .value 雖然多打幾個字,卻讓「這是響應式資料」在程式碼裡一目瞭然,資料來源好追蹤。
  3. 整體替換、傳遞、解構都不會踩到失聯的坑。

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 的前提:它只在渲染、computedwatch 這類響應式工作執行期間發生。平常在程式裡讀一下 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 陣列的元素,或放在 MapSet 這類原生集合中,取用時不會自動解包,仍然需要 .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 /> 這樣的元件,能夠接收外部傳進來的資料。