Skip to content

副作用與清理

重新認識 Vue.js: 008 天絕對看不完的 Vue.js 3.x 指南

上一節的 useQuantity 只有 refcomputed,呼叫完不留任何痕跡。但很多邏輯不是這樣:監聽視窗大小要 addEventListener、輪詢要 setInterval、觀察資料要 watch。這些副作用會在元件之外留下東西,抽進 composable 之後,「誰負責收拾」就成了必須回答的問題。

composable 裡也能用生命週期 hooks

先看一個需要副作用的例子:讓畫面知道目前的視窗寬度,例如書封列表在窄螢幕換成單欄。這需要監聽 resize 事件,而監聽器用完必須拆掉。

好消息是,第三章介紹過的生命週期 hooks 並沒有被關在元件裡,composable 一樣能用:

src/composables/useWindowWidth.js

js
import { ref, onMounted, onUnmounted } from 'vue'

export function useWindowWidth() {
  const width = ref(0)

  function update() {
    width.value = window.innerWidth
  }

  onMounted(() => {
    update()
    window.addEventListener('resize', update)
  })

  onUnmounted(() => {
    // 沒有這行,元件卸載後監聽器會一直留在 window 上
    window.removeEventListener('resize', update)
  })

  return { width }
}

使用端一行搞定,完全不需要知道背後有事件監聽在運作:

src/App.vue

vue
<script setup>
import { useWindowWidth } from './composables/useWindowWidth.js'

const { width } = useWindowWidth()
</script>

<template>
  <main class="app">
    <p>目前視窗寬度:{{ width }}px</p>
    <p v-if="width < 600">(窄螢幕:書封改單欄排列)</p>
  </main>
</template>

<style scoped>
.app {
  padding: 2rem;
}
</style>

這就是 composable 處理副作用的原則:誰建立,誰清理useWindowWidth 自己掛上監聽器,就自己在 onUnmounted 拆掉。使用它的元件只管拿 width,掛載與清理的細節全部封裝在函式裡;要是把清理的責任丟給呼叫端,composable 就白抽了。

不過,能在 composable 裡呼叫 onMounted,有一個看不見的前提:Vue 得知道「現在是哪個元件在 setup」,hooks 才能掛到正確的元件上。這件事只在 setup 的同步執行期間成立,所以出現了這一章第一個常見錯誤:

常見錯誤:在 async 脈絡裡註冊生命週期 hooks

setTimeout、Promise callback,或一般 async 函式的 await 之後呼叫 onMounted(),Vue 已經找不到「目前的元件」,開發模式下會警告 hook 沒有作用:

js
export async function useRemoteConfig() {
  const res = await fetch('/api/config')   // await 之後,setup 的同步階段已經結束

  onMounted(() => {                        // 這個 hook 掛不到任何元件上
    console.log('不會被呼叫')
  })
}

有一個例外:<script setup>頂層 await 是編譯器特別支援的,Vue 會在 await 之後自動恢復元件脈絡,所以在頁面元件的頂層 await 之後呼叫 composable 沒有問題。但這個待遇僅限 <script setup> 頂層;自己寫的 async 函式(包含 async composable)裡沒有這層保護。穩妥的做法是:composable 保持同步呼叫,非同步的部分放進函式內部處理,下一節的 useBookFetch 就是這樣設計的。

watcher 跟著誰

事件監聽要手動拆,那 watch 呢?第二章在元件裡用 watch 時,我們從來沒寫過「停止觀察」,因為 Vue 幫我們處理了:在 setup 同步執行期間建立的 watcher,會綁定到目前的元件,元件卸載時自動停止

這條規則搬進 composable 一樣成立。composable 是在 setup 期間被呼叫的,裡面建立的 watcher 自然也綁定到呼叫它的元件,元件卸載就跟著停,不需要額外處理。

要留意的是「同步」兩個字。跟生命週期 hooks 一樣,如果 watcher 是在 await 之後或 setTimeout 裡才建立的,它綁不到元件,元件卸載後仍會繼續觀察。這種 watcher 得自己收拾:watch() 的回傳值就是停止函式,留下來手動呼叫。

js
// 非同步建立的 watcher 不會自動停止,回傳值要留著
const stopWatch = watch(source, callback)

// 之後不需要時
stopWatch()

清理上一輪的副作用

到目前為止,「清理」都發生在元件卸載的時候。但 watcher 有另一種更頻繁的清理時機:下一輪執行開始前

watcher 的 callback 每次來源變化都會執行一次。如果 callback 裡有副作用,例如計時器或請求,上一輪留下的東西這一輪就成了垃圾。Vue 提供兩種方式登記「下一輪開始前先做這件事」:

第一種是 callback 的第三個參數 onCleanup,從 Vue 3 一開始就有:

js
watch(keyword, (value, oldValue, onCleanup) => {
  const timer = setTimeout(() => search(value), 500)

  onCleanup(() => clearTimeout(timer))   // 下一輪開始前,把上一輪的計時器清掉
})

第二種是 onWatcherCleanup()(Vue 3.5 新增),不用一路把參數傳下去,在 callback 裡直接 import 使用:

js
import { watch, onWatcherCleanup } from 'vue'

watch(keyword, (value) => {
  const timer = setTimeout(() => search(value), 500)

  onWatcherCleanup(() => clearTimeout(timer))
})

兩者登記的清理函式都會在「下一輪 callback 執行前」與「watcher 停止時」被呼叫。差別在呼叫限制:onWatcherCleanup() 跟生命週期 hooks 一樣依賴「Vue 知道目前在哪個 watcher 裡」,所以必須在 callback 的同步階段、第一個 await 之前呼叫onCleanup 是隨 callback 傳進來的參數,跟著這一輪執行走,沒有這個限制。如果 callback 是 async 函式而且清理要登記在 await 之後,用 onCleanup 才保險。

舊請求比新請求晚回來

第二章埋的問題可以收尾了。當 watcher 的 callback 裡發出請求,而來源又連續變化,就會發生這樣的事:讀者快速從《重新認識 Vue.js》切到《0 陷阱 JavaScript》,第一本書的請求比較慢、比較晚回來,畫面最後顯示的反而是舊資料。

用剛學的清理機制,在下一輪開始前把上一輪「作廢」:

js
import { ref, watch } from 'vue'

const bookId = ref(1)
const book = ref(null)

watch(bookId, async (id, oldId, onCleanup) => {
  let cancelled = false
  onCleanup(() => {
    cancelled = true          // 來源又變了,這一輪作廢
  })

  const res = await fetch(`/api/books/${id}`)
  const data = await res.json()

  if (cancelled) return       // 作廢的請求不准寫入狀態
  book.value = data
})

cancelled 登記在 await 之前,所以這裡用 onCleanuponWatcherCleanup() 都行。切換兩次 bookId 時,第一輪的 cancelled 會先被設成 true,等它的請求姍姍來遲,最後一行已經不會執行,畫面安全。

不過這只是最小示範,它擋住了「寫入」,請求本身還在網路上跑;載入中、錯誤這些狀態也還沒交代。把這些通通封裝成一個可靠的 composable,是下一節封裝非同步資料的任務。