本文 AI 產出,尚未審核

Vue3 課程 – 響應式系統(Reactivity System)

主題:reactive 限制(不能替換整個物件)


簡介

在 Vue 3 中,reactive 是建立深層響應式資料的核心 API。它讓我們只要改變資料的內容,畫面就會自動更新,極大提升開發效率與程式可讀性。
然而,reactive 物件在設計上有一個重要限制:不能直接以新物件取代整個 reactive 參考。如果不瞭解這個行為,常會導致 UI 不更新、錯誤的資料流,甚至執行時例外。

本篇文章將深入說明這個限制的根本原因、實作細節,並提供實用範例、常見陷阱與最佳實踐,協助你在真實專案中正確使用 reactive


核心概念

1. reactive 的工作原理

  • Vue 3 使用 Proxy 來攔截物件的讀取 (get) 與寫入 (set) 行為。
  • 每一次 set 時,Vue 會追蹤依賴 (Dep) 並在資料變更後觸發相應的 effect(即重新渲染或 computed 更新)。
  • 這個追蹤機制只針對 屬性變動(例如 obj.name = 'Tom')建立,不會監聽整個物件的引用變化

重點reactive 只關心「屬性」的變動,而非「引用」本身的變化。

2. 為什麼不能直接替換整個 reactive 物件?

當我們執行 state = { a: 1, b: 2 } 時,實際上是改變了變數 state 的指向,而不是原本 Proxy 包裝的物件。Vue 並沒有機制去偵測這樣的引用改變,結果是:

  1. 原本的 Proxy 仍然存在於記憶體中,仍被組件持有。
  2. 新物件未被 Proxy 包裝,Vue 無法追蹤其屬性變化。
  3. UI 仍然渲染舊的資料,或出現 Uncaught TypeError: Cannot read property ... of undefined

因此,正確的做法是改變 Proxy 內部的屬性,或使用 Vue 提供的 set / delete 方法(在 Vue 3 中已不需要手動 set,只要直接賦值即可),但不要直接把整個物件換掉

3. 替代方案:Object.assignfor...inref 包裝

方法 說明 是否保持響應式
Object.assign(state, newObj) 把新物件的屬性逐一寫入已有的 Proxy
for (const key in newObj) state[key] = newObj[key] 手動迭代賦值
state = reactive(newObj)(在 setup 之外) 重新建立 Proxy,需重新傳遞給所有使用者 ❌(需重新注入)
const state = ref(newObj) ref 包裝整個物件,改變 .value 時會保持響應式 ✅(適合整體置換)

實務建議:如果真的需要「整體置換」的語意,請改用 ref 包裝整個物件,再操作 .value


程式碼範例

以下範例皆以 Composition API 為前提,使用 setup() 內的 reactive

範例 1:直接替換會失效

import { reactive, watch } from 'vue'

export default {
  setup() {
    const state = reactive({ count: 0, name: 'Alice' })

    // 觀察 state 變化
    watch(
      () => state,
      (newVal) => console.log('state changed:', newVal),
      { deep: true }
    )

    // ❌ 直接替換整個物件
    const replace = () => {
      state = { count: 10, name: 'Bob' }   // <-- 這行會拋錯或不觸發 watch
    }

    return { state, replace }
  }
}

說明state = {...} 嘗試改變 state 的引用,Vue 無法追蹤,watch 不會被觸發,且在嚴格模式下會拋出錯誤。


範例 2:使用 Object.assign 保持響應式

import { reactive, watch } from 'vue'

export default {
  setup() {
    const state = reactive({ count: 0, name: 'Alice' })

    watch(
      () => state,
      (newVal) => console.log('state updated:', newVal),
      { deep: true }
    )

    const replace = () => {
      // ✅ 把新屬性寫入原有的 reactive 物件
      Object.assign(state, { count: 10, name: 'Bob' })
    }

    return { state, replace }
  }
}

說明Object.assign 會逐屬性寫入原 Proxy,所有依賴都會正確更新。


範例 3:使用 for...in 手動賦值

import { reactive } from 'vue'

export default {
  setup() {
    const state = reactive({ a: 1, b: 2, c: 3 })

    const replace = (newObj) => {
      // 先清除舊屬性(若需要)
      for (const key in state) delete state[key]

      // 再寫入新屬性
      for (const key in newObj) state[key] = newObj[key]
    }

    // 呼叫範例
    replace({ x: 100, y: 200 })   // state 變成 { x:100, y:200 }

    return { state, replace }
  }
}

說明:此方式較為彈性,適合「先移除舊屬性、再加入新屬性」的情境。


範例 4:改用 ref 包裝整個物件

import { ref, watch } from 'vue'

export default {
  setup() {
    const state = ref({ count: 0, name: 'Alice' })

    watch(
      () => state.value,
      (newVal) => console.log('state ref changed:', newVal),
      { deep: true }
    )

    const replace = () => {
      // ✅ 直接替換 .value,Vue 會自動追蹤
      state.value = { count: 10, name: 'Bob' }
    }

    return { state, replace }
  }
}

說明ref 包裝的物件在改變 .value 時會觸發依賴,適合需要「一次性置換」的需求。


範例 5:在大型表單中使用 reactive + Object.assign

import { reactive, toRefs } from 'vue'

export default {
  props: {
    initialData: {
      type: Object,
      required: true
    }
  },
  setup(props) {
    // 表單資料保持 reactive,方便雙向綁定
    const form = reactive({
      username: '',
      email: '',
      age: null
    })

    // 初始化或重置表單
    const resetForm = () => {
      Object.assign(form, props.initialData)
    }

    // 假設從 API 取得新資料
    const loadFromServer = async () => {
      const res = await fetch('/api/user/1')
      const data = await res.json()
      Object.assign(form, data)   // 保持響應式
    }

    // 一開始先呼叫一次
    resetForm()

    return {
      ...toRefs(form),   // 讓模板能直接使用 form 欄位
      resetForm,
      loadFromServer
    }
  }
}

說明:在表單重置或從 API 載入資料時,使用 Object.assign 可一次性更新多個欄位,而不會失去響應式。


常見陷阱與最佳實踐

陷阱 說明 解決方式
直接賦值換物件 state = {...} 會失效 改用 Object.assignfor...inref 包裝
刪除屬性未觸發更新 delete state.prop 在 Vue 3 仍會觸發,但若使用 Object.freeze 會失效 確保物件未被凍結;使用 deletestate[prop] = undefined
深層巢狀物件不更新 只改變最內層屬性仍會更新,但若直接替換整個巢狀物件則失效 同樣使用 Object.assign(state.nested, newNested)
setup 之外重新賦值 重新賦值後,先前的組件仍持有舊 Proxy,導致 UI 不同步 若需要全域共享,考慮使用 pinia/vuex,或將資料放在 ref
使用 watch 時忘記 deep: true 監聽整個物件的屬性變化需要 deep 設定 { deep: true },或分別監聽每個屬性

最佳實踐

  1. 保持單一來源:所有需要被多個組件共享的資料,建議使用 PiniaVuex,避免在不同地方自行替換 reactive 物件。
  2. 盡量使用屬性賦值:只要能用 state.prop = newValue 完成,就不要考慮整體置換。
  3. 若必須整體置換,改用 refref.value 會被 Vue 完整追蹤,最適合「整體覆寫」的情境。
  4. 使用 toRefs:在模板中直接解構 reactive 物件會失去響應式,使用 toRefs...toRefs(state) 以保留響應式。
  5. 測試與除錯:在開發過程中加入 watchdeep: true)或 console.log 觀察資料變化,快速定位是否因引用替換而失效。

實際應用場景

1. 表單重置與回填

在大型表單(如註冊、訂單)中,常需要「載入舊資料」或「清空表單」。使用 Object.assign(form, data) 能一次性把所有欄位寫入 reactive 表單物件,保持 UI 即時同步。

2. 分頁資料快取

假設有一個列表頁面,每次切換分頁會從 API 取回新資料。若把列表資料放在 reactive 陣列中,直接 list = newData 會失效。正確做法:

Object.assign(state, { items: newData, page: newPage })

或使用 ref 包裝整個列表:

const list = ref([])
list.value = newData

3. 多層次設定檔

設定檔常是深層巢狀結構。當使用者點擊「恢復預設」時:

Object.assign(settings, defaultSettings)   // 保持每層響應式

若改成 settings = defaultSettings,所有子組件都不會收到更新。

4. 共享全局狀態(Pinia 示例)

// store.js
import { defineStore } from 'pinia'
import { reactive } from 'vue'

export const useUserStore = defineStore('user', () => {
  const user = reactive({ id: null, name: '', role: '' })

  const setUser = (payload) => {
    Object.assign(user, payload)   // 替換屬性
  }

  return { user, setUser }
})

透過 Pinia,所有使用 useUserStore() 的組件都共享同一個 reactive 物件,且不會因為「整體置換」而失效。


總結

  • reactive 依賴 Proxy 追蹤 屬性變動不會監聽引用本身的改變。因此 不能直接以新物件取代整個 reactive 參考
  • 常見的解決方式包括 Object.assign、手動迭代賦值,或在需要「一次性置換」的情況改用 ref 包裝整個物件。
  • 在實務開發中,應盡量以屬性賦值的方式更新資料,並結合 watchtoRefs、Pinia 等工具,確保資料流的可預測與 UI 的即時同步。
  • 只要熟悉這個限制並遵守最佳實踐,你就能在 Vue 3 中自信地使用 reactive,打造流暢且可維護的前端應用。

祝開發順利,玩得開心! 🎉