這一篇不介紹新功能,整理兩類「寫的時候沒報錯、上線之後才出事」的坑。
第一類很常見:想在列表裡過濾掉某些項目,順手在同一個標籤上寫了 v-for 加 v-if。有時候能跑,有時候直接噴 todo is not defined。
第二類更隱蔽:頁面功能都正常,但切到別的頁面之後,打開 Console 發現某個 console.log 還在每秒印一次。或是來回切換幾次路由,頁面越來越卡。
這兩類的共通點是:程式不會告訴你有問題,你得自己知道要去檢查。
假設有一份待辦清單,只想顯示未完成的項目,直覺會這樣寫:
<li v-for="todo in todos" v-if="!todo.isComplete">
{{ todo.name }}
</li>
這段在 Vue 3 會報錯,訊息大意是 todo 沒有定義在實例上。
原因在優先順序:當 v-if 和 v-for 同時出現在同一個節點,v-if 的優先權比 v-for 高。
也就是說,Vue 會先判斷 v-if 的條件,這時候 v-for 還沒開始跑,todo 這個變數根本還不存在,自然就找不到。
官方文件明確指出,不建議把 v-if 和 v-for 放在同一個元素上,因為這種隱含的優先順序很容易讓人誤會。而人們會這樣寫,通常是出於兩種原因:
情境 A:想過濾列表中的項目
用 computed 先算出過濾後的陣列,再拿去 v-for:
<script setup>
import { ref, computed } from 'vue'
const todos = ref([
{ id: 1, name: '寫文章', isComplete: false },
{ id: 2, name: '洗衣服', isComplete: true },
{ id: 3, name: '整理程式碼', isComplete: false }
])
const activeTodos = computed(() => todos.value.filter((todo) => !todo.isComplete))
</script>
<template>
<ul>
<li v-for="todo in activeTodos" :key="todo.id">
{{ todo.name }}
</li>
</ul>
</template>
這樣模板只負責「畫出來」,過濾邏輯放在 JS 裡,也比較好測試、好閱讀。
情境 B:想在條件不成立時,整個列表都不要渲染
把 v-if 移到外層容器:
<ul v-if="shouldShowTodos">
<li v-for="todo in todos" :key="todo.id">
{{ todo.name }}
</li>
</ul>
有些時候判斷的條件真的跟「每一項」有關,又不適合先過濾(例如要保留某些項目的位置,或判斷邏輯很零碎)。這時候把 v-for 移到外層的 <template>,裡面的元素再用 v-if:
<template v-for="todo in todos" :key="todo.id">
<li v-if="!todo.isComplete">
{{ todo.name }}
</li>
</template>
這樣 v-for 先展開、v-if 在裡面才拿得到 todo,語意也更明確。注意:用 <template v-for> 的時候,key 要放在 <template> 上。
既然提到用 computed 處理列表,順便記一個很容易踩的:sort() 和 reverse() 會直接修改原陣列,而 computed 的 getter 裡應該避免修改來源資料。要排序或反轉時,先複製一份:
const sortedTodos = computed(() => {
// 先用展開運算子複製,再排序,不動到原本的陣列
return [...todos.value].sort((a, b) => a.name.localeCompare(b.name))
})
組件被卸載(unmount)的時候,Vue 會自動處理它「認識」的東西:
模板裡用 @click 這類寫法綁的事件
在 setup() 或 <script setup> 裡同步建立的 watch、watchEffect、computed
但是下面這些,Vue 不知道它們存在,不會自動清除:
setInterval / setTimeout
手動綁在 window、document 上的事件監聽
第三方套件建立的實例(圖表、地圖、Modal 等)
在非同步回呼裡才建立的 watcher
WebSocket、EventSource 之類的連線
組件被卸載了,這些東西卻還活著,而且它們的回呼函式還抓著組件裡的變數,於是整個組件的記憶體都釋放不掉。這就是俗稱的記憶體洩漏(memory leak)。
最典型的錯誤:
<script setup>
import { ref, onMounted } from 'vue'
const seconds = ref(0)
onMounted(() => {
setInterval(() => {
seconds.value++
}, 1000)
})
</script>
這個組件離開畫面之後,setInterval 還在每秒執行,seconds 還在被累加。如果使用者來回進出這個頁面十次,就會同時有十個計時器在背景跑。
正確的寫法是把 timer id 存起來,在 onUnmounted 清掉:
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const seconds = ref(0)
let timer = null
onMounted(() => {
timer = setInterval(() => {
seconds.value++
}, 1000)
})
onUnmounted(() => {
clearInterval(timer)
})
</script>
養成一個習慣:寫下 setInterval 的當下,馬上去寫對應的 clearInterval。
另一個常見狀況是監聽 window 的事件,例如視窗大小或捲動:
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const width = ref(window.innerWidth)
function updateWidth() {
width.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', updateWidth)
})
onUnmounted(() => {
window.removeEventListener('resize', updateWidth)
})
</script>
這裡有個細節:removeEventListener 要傳入跟 addEventListener 同一個函式參照才移除得掉。如果當初寫的是匿名函式:
// 這樣之後就沒辦法移除了
window.addEventListener('resize', () => {
width.value = window.innerWidth
})
因為你沒有任何變數可以指向那個函式,removeEventListener 也就無從下手。所以要先把函式取名字、放在變數裡。
這種「掛上去、移除掉」的成對動作,非常適合抽成 Day 21 講過的 composable:
// useEventListener.js
import { onMounted, onUnmounted } from 'vue'
export function useEventListener(target, event, handler) {
onMounted(() => target.addEventListener(event, handler))
onUnmounted(() => target.removeEventListener(event, handler))
}
使用的時候只要一行,不可能忘記清除:
<script setup>
import { ref } from 'vue'
import { useEventListener } from './useEventListener.js'
const width = ref(window.innerWidth)
useEventListener(window, 'resize', () => {
width.value = window.innerWidth
})
</script>
這裡用匿名函式也沒問題,因為 handler 是以參數傳進 composable,add 和 remove 用的是同一個參照。
圖表、地圖、Modal 這類套件,通常會自己建立 DOM、綁定事件、保存內部狀態。它們多半會提供銷毀的方法(例如圖表的 destroy()、Bootstrap Modal 的 dispose()),要在組件卸載時呼叫:
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const canvasRef = ref(null)
let chart = null
onMounted(() => {
chart = createChart(canvasRef.value) // 假設這是某個圖表套件的初始化
})
onUnmounted(() => {
chart?.destroy() // 離開時釋放
})
</script>
<template>
<canvas ref="canvasRef"></canvas>
</template>
實際方法名稱以各套件文件為準,重點是:誰建立的,誰負責收掉。
官方文件提到:在 setup() 或 <script setup> 裡同步宣告的 watcher,會綁定到組件實例,組件卸載時自動停止。但如果是在非同步的回呼裡才建立,就不會被綁定,必須自己停:
<script setup>
import { watchEffect } from 'vue'
// 這個會自動停止
watchEffect(() => {})
// 這個不會!
setTimeout(() => {
watchEffect(() => {})
}, 100)
</script>
如果真的有這種需求,watch 和 watchEffect 都會回傳一個停止函式:
const unwatch = watchEffect(() => {})
// 之後不需要的時候
unwatch()
不過官方也提醒,需要非同步建立 watcher 的情況其實很少。如果是在等非同步資料,更好的做法是同步建立 watcher,裡面再加條件判斷:
const data = ref(null)
watchEffect(() => {
if (data.value) {
// 資料載入後才做事
}
})
清理不只是組件卸載的時候。看這個情境:
watch(id, (newId) => {
fetch(`/api/${newId}`).then(() => {
// 處理結果
})
})
如果 id 在請求還沒回來之前又變了,前一個請求回來時還是會執行回呼,帶著已經過期的 id 去更新畫面,可能讓畫面顯示成舊的資料。
Vue 3.5 之後可以用 onWatcherCleanup 註冊清理函式,在 watcher 準備重新執行之前呼叫,取消舊的請求:
import { watch, onWatcherCleanup } from 'vue'
watch(id, (newId) => {
const controller = new AbortController()
fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
// 處理結果
})
onWatcherCleanup(() => {
// 取消過期的請求
controller.abort()
})
})
onWatcherCleanup 必須在 watch 回呼的同步執行期間呼叫,不能放在 await 後面。如果版本低於 3.5,或是需要在 await 之後使用,可以改用傳進回呼的第三個參數 onCleanup(watchEffect 則是第一個參數):
watch(id, (newId, oldId, onCleanup) => {
// ...
onCleanup(() => {
// 清理邏輯
})
})
最後一個跟這個主題相關的:onMounted、onUnmounted 這些 hook 要在 setup 的同步流程中註冊,Vue 才能把它們綁定到目前的組件實例。下面這樣是不會生效的:
setTimeout(() => {
onMounted(() => {
// 這不會運作
})
}, 100)
不過不是說一定要寫在 <script setup> 的字面範圍內,只要呼叫堆疊是從 setup() 同步進來的,放在外部函式(像上面的 useEventListener)也可以。
如果組件被 <KeepAlive> 快取,離開畫面時不會觸發 onUnmounted,只會觸發 onDeactivated。這時候計時器、輪詢這類東西要改在 onActivated / onDeactivated 成對處理,不然切走之後它們還是會在背景跑。這部分在前一篇有範例。
最簡單的土法煉鋼:
setInterval 的回呼或事件的 handler 裡暫時加一行 console.log
console.log 拿掉就好。v-if 優先權高於 v-for,同一個元素上寫兩個會讓 v-if 拿不到 v-for 的變數;過濾用 computed,整串隱藏就把 v-if 移到外層,逐項判斷則把 v-for 放到 <template> 上sort() / reverse() 前,先複製陣列setInterval、window 事件、第三方實例、非同步建立的 watcher 要自己清onWatcherCleanup 或 onCleanup 取消KeepAlive 快取時,改在 onDeactivated 處理離開畫面的清理