Appearance
設計可調適的 composable
上一節的 useBookFetch 已經能安全地處理競態,但它挑食:來源只收 ref。這一節我們處理 composable 設計的最後一塊:讓輸入更有彈性、讓回傳值有一致的慣例,並且弄清楚狀態該放哪裡。這些慣例決定一個 composable 是「只有作者會用」還是「誰接手都不會用錯」。
讓輸入更有彈性:純值、ref、getter
呼叫端手上的來源,其實有三種常見的樣子:
- 純值:這一節永遠顯示 id 為 1 的書,
useBookFetch(1)最直覺。 - ref:使用者會切換書籍,來源是
ref,上一節就是這種。 - getter:來源是 props 的欄位。第三章 Reactive Props Destructure 提過,把解構出來的 prop 交給要持續追蹤的函式時,要包成
() => bookId這樣的 getter 才不會斷線。
Vue 3.3 新增的 toValue() 就是為了讓 composable 一次接住三種形狀:收到純值回傳原樣、收到 ref 回傳 .value、收到 getter 就呼叫它取值。
升級只需要動兩個地方。把「讀取來源」的動作全部換成 toValue(source),觀察來源的 watch 改成 getter 寫法:
js
// 上一節:只收 ref
watch(bookId, (id) => load(id), { immediate: true })
function refresh() {
load(bookId.value)
}
// 升級:純值、ref、getter 都收
watch(() => toValue(source), (id) => load(id), { immediate: true })
function refresh() {
load(toValue(source))
}watch(() => toValue(source), ...) 這個 getter 讀到什麼,就追蹤什麼:來源是 ref 或 getter 時,它們一變就重抓;來源是純值時,沒有任何響應式依賴,immediate 發出第一次請求之後就不會再觸發,正好符合「固定顯示一本書」的預期。
三個狀態、load() 的競態防線都與上一節相同,完整版收在下面,這也是全章唯一一份完整程式碼:
useBookFetch 完整最終版
src/composables/useBookFetch.js:
js
import { ref, watch, toValue } from 'vue'
// source:純值、ref 或 getter 皆可
export function useBookFetch(source) {
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)
let controller = null // 永遠指向最新一輪請求的身分證
async function load(id) {
controller?.abort() // 舊請求直接取消
const current = new AbortController()
controller = current
isLoading.value = true
error.value = null
try {
const res = await fetch(`/api/books/${id}`, { signal: current.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`) // fetch 對 4xx/5xx 不會 reject
const json = await res.json()
if (controller !== current) return // 身分證對不上:無權寫入
data.value = json
} catch (e) {
if (current.signal.aborted) return // 被取消的請求安靜退場,AbortError 不算錯誤
if (controller !== current) return
data.value = null
error.value = e
} finally {
if (controller === current) {
isLoading.value = false // 只有最新請求能把載入狀態關掉
}
}
}
watch(() => toValue(source), (id) => load(id), { immediate: true })
function refresh() {
load(toValue(source))
}
return { data, error, isLoading, refresh }
}用起來像這樣,三種來源都是同一行呼叫:
src/App.vue:
vue
<script setup>
import { ref } from 'vue'
import { useBookFetch } from './composables/useBookFetch.js'
const currentId = ref(1)
// 換成 useBookFetch(1) 或 useBookFetch(() => props.bookId) 也一樣能動
const { data, error, isLoading, refresh } = useBookFetch(currentId)
</script>
<template>
<main class="app">
<button type="button" @click="currentId = 1">第一本</button>
<button type="button" @click="currentId = 2">第二本</button>
<button type="button" @click="refresh">重新整理</button>
<p v-if="isLoading">載入中⋯</p>
<p v-else-if="error">出錯了:{{ error.message }}</p>
<article v-else-if="data">
<h2>{{ data.title }}</h2>
<p>庫存:{{ data.stock }}</p>
</article>
</main>
</template>
<style scoped>
.app {
padding: 2rem;
}
</style>TypeScript 裡的名字
在 TypeScript 中,「純值、ref 或 getter 都收」的參數型別叫 MaybeRefOrGetter<T>,toValue() 會把它化簡回 T。第九章談 TypeScript with Vue 時會再遇到它。
回傳一個裝著 refs 的普通物件
輸入談完了換輸出。useBookFetch 回傳的是普通物件,裡面裝著多個 ref,這是 composable 回傳值的通用慣例,理由就藏在第三章的解構陷阱裡:對 reactive 物件解構原始型別屬性,拿到的是斷線的複本;但解構一個普通物件裡的 ref,拿到的是 ref 本身,響應性完好無損。
所以呼叫端才能放心寫 const { data, error } = useBookFetch(...),各取所需。
常見錯誤:把回傳值包成 reactive
有些人覺得回傳 reactive({ data, error, isLoading }) 比較「乾淨」,用的時候不必寫 .value。代價是呼叫端一解構就斷線,而 composable 的回傳值幾乎注定會被解構。維持「普通物件+refs」的慣例,讓每一個 composable 的用法都長一樣。
狀態放哪裡:函式內與模組層級
本章第一節立下的規則是「狀態在函式內建立,每次呼叫各自獨立」。反過來說,把狀態宣告搬到函式外面,行為就翻轉了:
js
import { ref } from 'vue'
const count = ref(0) // 模組層級:整個應用程式只有這一份
export function useSharedCounter() {
function increment() {
count.value++
}
return { count, increment }
}模組只會被載入一次,所以 count 只有一份,每個呼叫 useSharedCounter() 的元件都在操作同一個數字。這是簡單粗暴的共享狀態,小工具可以用,但它是一把雙面刃。
常見錯誤:不小心把狀態宣告在函式外面
如果只是想「每個元件自己一份」,ref 卻宣告在函式外,所有元件會莫名互相干擾:A 頁面改了數量,B 頁面跟著動。反過來,刻意要共享時也要想清楚:誰都能改的模組狀態,出了問題很難追出是誰改的,這正是上一章 provide/inject 用 readonly() 防範過的情境。
真的需要跨元件、跨頁面的共享狀態時,模組層級的 ref 只能撐過最簡單的場景;修改的規則、除錯的工具、與路由或伺服器資料的整合,這些第七章的 Pinia 都有正式答案。這裡先記住界線:元件自己的邏輯用 composable,跨頁面的全域狀態交給狀態管理工具。
先查 VueUse,再自己造
這一章帶各位讀者手工打造了三個 composable,但實務上動手前值得先查一個地方:VueUse,Vue 生態系最完整的 composable 工具庫,這一章的常見需求它幾乎都有現成品。例如上一節手動掛、手動拆的事件監聽,它的 useEventListener 一行就包好:
js
import { useEventListener } from '@vueuse/core'
// 掛載時註冊、卸載時自動移除,上一節 onMounted/onUnmounted 做的事它都包了
useEventListener(window, 'resize', update)安裝方式與一般套件相同:pnpm add @vueuse/core。
查的時候重點不是抄範例,而是讀懂 API 再評估:輸入接不接受 getter?回傳的是不是 refs?行為跟需求有沒有落差?例如 VueUse 也有 useFetch,功能比本章的教學版多得多,但預設行為(何時發出請求、錯誤怎麼呈現)也更需要讀清楚才敢用。評估之後符合就用,省下的時間拿去寫自己領域的邏輯;不符合,各位讀者現在也有能力自己寫一個了。
本章小結
composables 的主線到這裡完成。簡單記:
- 重複的邏輯抽成
use*函式;狀態在函式內建立,每次呼叫各自獨立。 - 副作用誰建立誰清理;hooks 與 watcher 都要在 setup 的同步脈絡裡登記。
- 非同步狀態封裝成三態,寫入前驗明正身:只有最新請求有權更新。
- 輸入用
toValue()接住純值、ref、getter;回傳普通物件裝 refs。 - 模組層級狀態是共享,不是預設。
下一節是本章的可選單元:把「抽 composable」這件事交給 AI 動手,我們負責把關。跳過它不影響主線;想看看 AI 協作的實際流程,就翻到下一節。
