清單畫面已經多了一筆資料,放在 watch 裡的儲存或提示邏輯卻沒有執行。這通常不是 Vue 沒有更新,而是畫面和 watcher 觀察的變動範圍不同。把整個陣列換掉、對原陣列 push、修改某個元素的名稱,對監聽器來說是三件事。
先問這個回呼到底要處理什麼:只要清單被替換就重新載入?只要項目增減就更新摘要?還是每個欄位變更都要儲存?答清楚後再選 deep,比把所有監聽都改成 deep: true 更容易維護。
ref包著陣列,不代表watch會深入每個欄位
本文採 Vue 3 的 Composition API,清單放在一個 ref。下方 watcher 預設監聽這個 ref 的值被替換。push 修改的是原陣列的內容;陣列本身仍是同一個,所以預設 watcher 不會因此執行回呼。
import { ref, watch } from 'vue'
const items = ref([{ id: 1, name: 'A' }])
watch(items, () => {
console.log('array replaced')
})
items.value.push({ id: 2, name: 'B' })
// 原陣列增加元素,預設watch不因此觸發。
items.value = [{ id: 3, name: 'C' }]
// ref改指向另一個陣列,會觸發。
畫面若使用 v-for 顯示 items,可以因為響應式陣列變動而更新,並不需要等這個 watcher 執行。不要用「畫面有沒有動」判斷 watcher 的監聽條件,也不要為了回呼而把全部資料反覆重建成新陣列。先改對監聽範圍,再考慮資料更新方式。
Vue 3.5的deep:1可以只看到陣列這一層
如果目標是監聽陣列替換,以及 push 等陣列內容變動,Vue 3.5 起可以用數字深度。官方陣列遷移指南建議這種用途採 deep: 1。它不等於深入每個項目的所有欄位;修改某個物件的 name 是更深一層的事情。
watch(items, () => {
console.log('array replaced or changed')
}, { deep: 1 })
需要連物件內部的欄位一起監聽,才考慮 deep: true。Vue 3.5 以前沒有數字深度選項,陣列變動若靠深層監聽,通常使用 deep: true,同時也會監聽更深的元素內容。不要把一份 Vue 3.5 範例直接交給舊版專案就當成效果相同;先看 package.json 與鎖定檔,確認實際安裝版本。
watch(items, (newItems, oldItems) => {
console.log('nested content changed')
}, { deep: true })
深層監聽要走訪資料。清單很大、每個項目又帶著大量巢狀設定時,它會做比單一欄位監聽更多的工作。這是選範圍的理由,不是可以把某個項目數當成通用效能門檻。若只關心一個標題,就直接監聽那個標題;如果只是計算總數,甚至可能不需要 watcher。
三次操作,觀察四個回呼
以下實例同時註冊預設監聽、deep: 1、deep: true 及第一個項目的名稱 getter。每次操作後等待 nextTick(),避免把同一批同步修改合併後的結果,誤讀成每一步都立即觸發。
import { ref, watch, nextTick } from 'vue'
const items = ref([{ id: 1, name: 'A' }])
const hits = { shallow: 0, deep1: 0, deeptrue: 0, name: 0 }
watch(items, () => hits.shallow++)
watch(items, () => hits.deep1++, { deep: 1 })
watch(items, () => hits.deeptrue++, { deep: true })
watch(() => items.value[0].name, () => hits.name++)
items.value[0].name = 'B'
await nextTick()
console.log({ ...hits })
items.value.push({ id: 2, name: 'C' })
await nextTick()
console.log({ ...hits })
items.value = [{ id: 1, name: 'D' }]
await nextTick()
console.log({ ...hits })
放在支援頂層 await 的測試模組,或把操作部分放進 async 函式即可執行。本例固定讓陣列始終有第一個元素,因此 getter 可以直接讀 [0].name。真實清單如果可能清空,應改成 items.value[0]?.name,或依穩定 ID 找目標,避免空陣列讀取出錯。
在 Vue 3.5.43 的隔離案例中,改名稱後,四個累積次數依序是 0 / 0 / 1 / 1;push後是 0 / 1 / 2 / 1;整個陣列替換後是 1 / 2 / 3 / 2。這些是上述三次操作的結果,並不是任何專案都會出現固定的回呼次數。若同一個 tick 裡連續改動很多次,預設 watcher 會批次處理;要看每一種變動,就像範例一樣分開等待。
也不要為了讓數字好看而改成 flush: 'sync'。同步 watcher 沒有同樣的批次保護,大量陣列修改可能每次都呼叫。本文只需要分開觀察更新,nextTick 已足夠,不必改變產品的執行時序。
deep回呼的oldValue不是修改前的快照
另一個常見問題是深層 watcher 明明執行了,卻發現 newValue === oldValue。原陣列或原物件沒有被替換時,兩者可能指向同一個物件。你改了裡面的 name,並沒有讓 Vue替你保存一份修改前的深拷貝。
上述案例中,改名稱與 push 時,深層回呼得到的新舊陣列參照相同;替換陣列後才不同。因此不要寫成「新舊值相同,就沒有變動」。若需要修改前後的欄位差異,可以監聽具體的純量 getter,或者由應用程式另外保存符合資料格式的快照。
快照要按實際資料處理。直接 JSON 序列化再解析,會改變某些資料型別,也不能處理所有結構;它不是任意 Vue資料的通用拷貝方案。若你的任務只是提示某一個名稱改了,精確 getter 就可以讓回呼取得那個欄位的新舊值,無須複製整棵物件。
reactive物件與回傳物件的getter也要分清
Vue官方文件另有一個容易混淆的差別:直接 watch(reactiveObject, callback) 會隱含深層監聽;watch(() => state.someObject, callback) 則是監聽 getter 的回傳值,通常要換成另一個物件才觸發。不能把前者的行為直接套到後者,再認為框架表現不一致。
監聽一般欄位時,寫 watch(state.count, callback) 也不對,因為呼叫時傳進去的是當下的數字。應寫成 watch(() => state.count, callback),讓 Vue能追蹤讀取。這和解構是否保留響應式有關,但本篇不需要重寫整個元件;先找到實際傳入 watch 的來源類型就好。
若只要畫面顯示清單長度、符合條件的項目或總價,優先用 computed 表達衍生值。watcher 適合變動後的副作用,例如發送查詢或寫入儲存位置;用它把一份資料複製成另一份狀態,反而需要額外處理兩者不同步。
修清單監聽時,先保留原資料與操作方式,選一個最小範例確認「替換、陣列變動、項目欄位」哪個要觸發。確定範圍後,再把回呼接回實際功能。如果功能會呼叫 API,還要另外處理請求次序與失敗;深層監聽只決定何時執行回呼,並不保證儲存成功。
放回元件時,讓每個按鈕只改一件事
在自有 Vue測試頁,把三種操作分別連到三個按鈕,並把回呼次數顯示在畫面。第一個按鈕改第一筆的名稱,第二個按鈕新增項目,第三個按鈕替換整個陣列。點一下等畫面更新,再點下一個,比同時綁在一個按鈕裡更容易確認差異。
function renameFirst() {
if (items.value.length) items.value[0].name = 'B'
}
function appendItem() {
items.value.push({ id: 2, name: 'C' })
}
function replaceItems() {
items.value = [{ id: 1, name: 'D' }]
}
重複把名稱設成已經相同的值,不一定會產生新回呼;測試第二輪前應重設資料,或用不同值。陣列元素的 ID 也要維持唯一,尤其畫面使用 ID 當 v-for 的 key 時。這些資料條件若沒分開,可能一邊查監聽、一邊又碰到清單狀態問題,難以判斷是哪個地方造成結果。
元件內的 watcher 應在 setup階段同步建立。不要為了等資料載入,把註冊動作丟進隨意的計時器,再忘記管理停止。官方文件說明,同步建立在 setup的 watcher會跟元件生命週期一起停止;非同步建立則需要自行照顧。先註冊,再在回呼裡按資料是否齊備決定要不要執行工作,通常就能保留清楚的生命週期。

評論0