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 並沒有機制去偵測這樣的引用改變,結果是:
- 原本的 Proxy 仍然存在於記憶體中,仍被組件持有。
- 新物件未被 Proxy 包裝,Vue 無法追蹤其屬性變化。
- UI 仍然渲染舊的資料,或出現
Uncaught TypeError: Cannot read property ... of undefined。
因此,正確的做法是改變 Proxy 內部的屬性,或使用 Vue 提供的 set / delete 方法(在 Vue 3 中已不需要手動 set,只要直接賦值即可),但不要直接把整個物件換掉。
3. 替代方案:Object.assign、for...in 或 ref 包裝
| 方法 | 說明 | 是否保持響應式 |
|---|---|---|
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.assign、for...in 或 ref 包裝 |
| 刪除屬性未觸發更新 | delete state.prop 在 Vue 3 仍會觸發,但若使用 Object.freeze 會失效 |
確保物件未被凍結;使用 delete 或 state[prop] = undefined |
| 深層巢狀物件不更新 | 只改變最內層屬性仍會更新,但若直接替換整個巢狀物件則失效 | 同樣使用 Object.assign(state.nested, newNested) |
在 setup 之外重新賦值 |
重新賦值後,先前的組件仍持有舊 Proxy,導致 UI 不同步 | 若需要全域共享,考慮使用 pinia/vuex,或將資料放在 ref 中 |
使用 watch 時忘記 deep: true |
監聽整個物件的屬性變化需要 deep |
設定 { deep: true },或分別監聽每個屬性 |
最佳實踐
- 保持單一來源:所有需要被多個組件共享的資料,建議使用 Pinia 或 Vuex,避免在不同地方自行替換
reactive物件。 - 盡量使用屬性賦值:只要能用
state.prop = newValue完成,就不要考慮整體置換。 - 若必須整體置換,改用
ref:ref的.value會被 Vue 完整追蹤,最適合「整體覆寫」的情境。 - 使用
toRefs:在模板中直接解構reactive物件會失去響應式,使用toRefs或...toRefs(state)以保留響應式。 - 測試與除錯:在開發過程中加入
watch(deep: 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包裝整個物件。 - 在實務開發中,應盡量以屬性賦值的方式更新資料,並結合
watch、toRefs、Pinia 等工具,確保資料流的可預測與 UI 的即時同步。 - 只要熟悉這個限制並遵守最佳實踐,你就能在 Vue 3 中自信地使用
reactive,打造流暢且可維護的前端應用。
祝開發順利,玩得開心! 🎉