iT邦幫忙

2026 iThome 鐵人賽

0
Modern Web

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

Vue 走過路過不要錯過 Day29 - 常見踩坑整理:v-if 配 v-for、忘了清除的計時器與監聽

  • 分享至 

  • xImage
  •  

這一篇不介紹新功能,整理兩類「寫的時候沒報錯、上線之後才出事」的坑。

第一類很常見:想在列表裡過濾掉某些項目,順手在同一個標籤上寫了 v-for 加 v-if。有時候能跑,有時候直接噴 todo is not defined。

第二類更隱蔽:頁面功能都正常,但切到別的頁面之後,打開 Console 發現某個 console.log 還在每秒印一次。或是來回切換幾次路由,頁面越來越卡。

這兩類的共通點是:程式不會告訴你有問題,你得自己知道要去檢查。


坑一:v-if 和 v-for 寫在同一個元素上

先看會發生什麼事

假設有一份待辦清單,只想顯示未完成的項目,直覺會這樣寫:

<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 處理列表,順便記一個很容易踩的:sort() 和 reverse() 會直接修改原陣列,而 computed 的 getter 裡應該避免修改來源資料。要排序或反轉時,先複製一份:

const sortedTodos = computed(() => {
  // 先用展開運算子複製,再排序,不動到原本的陣列
  return [...todos.value].sort((a, b) => a.name.localeCompare(b.name))
})

坑二:忘了清除的計時器與監聽

Vue 會幫你清,也有不會幫你清的

組件被卸載(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。

事件監聽:remove 的函式必須是同一個

另一個常見狀況是監聽 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 也就無從下手。所以要先把函式取名字、放在變數裡。

做成 composable,不要每次都手寫一對

這種「掛上去、移除掉」的成對動作,非常適合抽成 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>

實際方法名稱以各套件文件為準,重點是:誰建立的,誰負責收掉。

Watcher:同步建立才會自動停止

官方文件提到:在 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(() => {
    // 清理邏輯
  })
})

生命週期 hook 要同步註冊

最後一個跟這個主題相關的:onMounted、onUnmounted 這些 hook 要在 setup 的同步流程中註冊,Vue 才能把它們綁定到目前的組件實例。下面這樣是不會生效的:

setTimeout(() => {
  onMounted(() => {
    // 這不會運作
  })
}, 100)

不過不是說一定要寫在 <script setup> 的字面範圍內,只要呼叫堆疊是從 setup() 同步進來的,放在外部函式(像上面的 useEventListener)也可以。


搭配 KeepAlive 時

如果組件被 <KeepAlive> 快取,離開畫面時不會觸發 onUnmounted,只會觸發 onDeactivated。這時候計時器、輪詢這類東西要改在 onActivated / onDeactivated 成對處理,不然切走之後它們還是會在背景跑。這部分在前一篇有範例。


怎麼確認自己有沒有漏掉?

最簡單的土法煉鋼:

  1. 在 setInterval 的回呼或事件的 handler 裡暫時加一行 console.log
  2. 進入該頁面,確認有在印
  3. 切換到別的路由
  4. 看 Console 還有沒有繼續印
    如果離開之後還在印,就是漏了清除。確認完再把 console.log 拿掉就好。

小結

  • v-if 優先權高於 v-for,同一個元素上寫兩個會讓 v-if 拿不到 v-for 的變數;過濾用 computed,整串隱藏就把 v-if 移到外層,逐項判斷則把 v-for 放到 <template> 上
  • computed 裡要用 sort() / reverse() 前,先複製陣列
  • Vue 只會自動清除它認識的東西;setInterval、window 事件、第三方實例、非同步建立的 watcher 要自己清
  • 計時器要存 id、事件監聽要用同一個函式參照移除,成對的動作可以包成 composable
  • 過期的請求可以用 onWatcherCleanup 或 onCleanup 取消
  • 組件被 KeepAlive 快取時,改在 onDeactivated 處理離開畫面的清理

上一篇
Vue 走過路過不要錯過 Day28 - KeepAlive 與 Transition:保留狀態與進出場動畫
下一篇
Vue 走過路過不要錯過 Day30 - 30 天走完,一個 Vue 3 小專案應該長什麼樣子
系列文
重新認識Vue 走過路過不要錯過 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言