Appearance
副作用與清理
上一節的 useQuantity 只有 ref 與 computed,呼叫完不留任何痕跡。但很多邏輯不是這樣:監聽視窗大小要 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 之前,所以這裡用 onCleanup 或 onWatcherCleanup() 都行。切換兩次 bookId 時,第一輪的 cancelled 會先被設成 true,等它的請求姍姍來遲,最後一行已經不會執行,畫面安全。
不過這只是最小示範,它擋住了「寫入」,請求本身還在網路上跑;載入中、錯誤這些狀態也還沒交代。把這些通通封裝成一個可靠的 composable,是下一節封裝非同步資料的任務。
