iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 5

Vue 走過路過不要錯過 Day05 - 解構為什麼讓 reactive 失效?toRefs 到底在做什麼

  • 分享至 

  • xImage
  •  

昨天我們談了 ref 存在的理由:reactive 只能包物件,而且不能整包替換,ref 用一個 .value 補上了這兩個洞。

reactive 還有一個更常踩到的坑,幾乎每個 Vue 3 新手都掉進去過:

const state = reactive({ count: 0 })
const { count } = state

看起來只是在偷懶少打幾個字,結果畫面再也不會更新了。

今天就來拆這件事。跟前幾天一樣,答案不在 Vue 裡,在 JavaScript 本身。


先講結論

reactive 的響應式,綁在「Proxy 物件的屬性存取」上。值一旦被取出來、離開這個物件,就斷線了。
toRefs 做的事,是讓取出來的東西「每次讀寫都回頭問原物件」。

支撐這個結論的有三個論點:

  1. 為什麼會斷線:Proxy 只追蹤屬性存取,而解構是把值複製出來
  2. toRefs 怎麼補:讓 ref 的 getter / setter 轉發回原物件
  3. 哪裡還會踩到:不只解構,還有幾種情況一樣會斷,以及該怎麼避開

先想三個問題

const state = reactive({ count: 0 })
const { count } = state
  1. 這行解構,用一般 JS 改寫等於什麼?
  2. 執行的那一刻,count 拿到的是什麼?是「數字 0」,還是「某個能連回 state 的東西」?
  3. 之後 state.count++,這個 count 變數會跟著變嗎?

想一下再往下看。

第 1 題:const { count } = state 等於 const count = state.count

第 2 題:state.count 是原始型別的數字,JS 對原始型別是「複製值」,所以 count 只是一個獨立的 0,跟 state 已經沒有關係。

第 3 題:不會。這跟 Day 4 是同一個結論:JS 攔不到變數賦值,也沒有機制讓一個變數自己去追蹤它的來源。


論點一:為什麼會斷線

用迷你版 reactive 驗證

沿用前幾天的概念,精簡成可以直接貼進 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(普通變數)。

斷線的兩層原因

  1. 解構時的讀取不在 effect 裡面const { count } = state 雖然也會觸發 Proxy 的 get,但當下 activeEffectnull,沒有人被收集。
  2. 拿到的是複製的值count 就是一個普通的數字 0。之後 effect 讀它,根本不會經過 Proxy。

Vue 的追蹤能力,完全建立在「透過 Proxy 讀取屬性」這件事上。值一旦離開 Proxy,Vue 就看不到了。


論點二:toRefs 怎麼補

先想:能不能讓取出來的東西「一直連回原物件」?

Day 4 學過,ref 的本質是一個帶 getter / setter 的 { value } 物件。

那如果這個 getter / setter 不自己存值,而是轉發到 state[key],會怎樣?

  • .value 時,走的是 state[key],會觸發 Proxy 的 get,被追蹤
  • .value 時,走的是 state[key] = v,會觸發 Proxy 的 set,通知更新

換句話說,這個東西雖然被拿走了,但每次讀寫都會回頭經過 Proxy

這就是 toRefs 在做的事。

自己寫一個 myToRefs

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

真正的 Vue 用法

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 本身的限制

toRefs 只會處理呼叫當下已經存在的屬性。如果屬性是之後才出現的(例如可有可無的欄位),就用 toRef(state, 'key'),它可以處理還不存在的屬性。

三、根本的對策

  1. 預設用 ref:一開始就一個個宣告成 ref,就不會有「解構會斷線」的問題。這也是社群偏好預設用 ref 的原因之一。
  2. 一定要用 reactive 時,在 composable 回傳 ...toRefs(state):讓使用的人可以放心解構。
  3. 只想拿其中一個屬性時,用 toRef(state, 'key')

補充:defineProps 解構為什麼在 3.5 之後可以用

你可能看過這種寫法:

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
  • 預設用 ref,可以直接避開大部分解構的坑

Day 4 結尾還留了一個洞:我們的迷你 reactive 只能處理單層物件,state.nested.count++ 是不會觸發更新的。下一篇就來補這個洞。



上一篇
Vue 走過路過不要錯過 Day04 - 有了 reactive,為什麼還要 ref?從 .value 說起
下一篇
Vue 走過路過不要錯過 Day06 - computed:不只是「快取」這麼簡單
系列文
重新認識Vue 走過路過不要錯過9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言