把 Vue Composable 重構成自己管理生命週期的 Lazy-loaded Singleton
有一個共用的 composable,負責去後端撈一份「選項清單」(下拉選單用的 label)。它用 module scope 的 ref 做成 singleton + cache,設計上要外層 component 先呼叫 fetch() 把資料載進來,子 component 再直接讀 cache。
結果某天一個「只負責顯示」的表單壞了:它只解構了 options、沒呼叫 fetch(),剛好又沒有任何父層先載入 → cache 是空的 → 下拉選單整排空白。
這篇記錄怎麼把它從「依賴外部正確呼叫 fetch」重構成「被使用時自己載入、自己管 cache」的 lazy-loaded singleton,以及過程中兩個容易忽略的點:computed 也該外提到 module scope,還有 fire-and-forget 的 floating promise 該怎麼處理。
問題出在「誰負責載入」
先看原本的結構(已去識別化、簡化欄位):
// useLabelOptions.ts
const rawLabels = ref<LabelData>({ category_label: [], region_label: [] })
const fetchedAt = ref<number | null>(null)
function isCacheValid() {
if (!fetchedAt.value) return false
return Date.now() - fetchedAt.value < CACHE_TTL
}
export function useLabelOptions() {
async function fetch() {
if (isCacheValid()) return rawLabels.value
const { data } = await fetchLabelOptions()
rawLabels.value = data
fetchedAt.value = Date.now()
return rawLabels.value
}
const groupOptions = computed(() => formatOptions(rawLabels.value.category_label))
// ...其他 computed
return { fetch, groupOptions }
}
rawLabels 在 module scope,所以是全域共用的 singleton——這部分沒問題。問題在資料載入的責任被推給了使用端:
- 載入方:表單頁在 mount/開啟 modal 時
await fetch() - 純消費方:某些表單只解構
groupOptions,完全沒呼叫fetch(),靠父層先填好 cache
只要純消費方在 cache 還空的時候被掛載(或父層忘了 fetch),options 就是空的。這是一個隱性的時序耦合:能不能正常運作,取決於「有沒有別人在對的時機先呼叫 fetch」。
還有一個沒被注意到的問題:沒有 in-flight 去重。若多個 component 同時 mount 各自 await fetch(),在第一個請求回來前 isCacheValid() 都是 false → 會打出好幾個重複 request。
重構一:self-fetch + in-flight 去重
目標是讓 composable 自己管生命週期:只要被使用就觸發載入,使用端什麼都不用做。
核心是一個 idempotent 的 ensureLoaded(),搭配一個 module scope 的 in-flight promise 做去重:
const rawLabels = ref<LabelData>({ category_label: [], region_label: [] })
const fetchedAt = ref<number | null>(null)
let fetchPromise: Promise<void> | null = null
async function ensureLoaded() {
if (isCacheValid()) return
if (fetchPromise) return fetchPromise // 已有人在打 → 共用同一個 promise
fetchPromise = (async () => {
const { data } = await fetchLabelOptions()
rawLabels.value = data
fetchedAt.value = Date.now()
})()
try {
await fetchPromise
}
finally {
// 成功後讓 TTL 接手快取;失敗則清空,下次被使用時可重試
fetchPromise = null
}
}
兩層去重:TTL 擋已載入的、fetchPromise 擋同一時間併發的。N 個 component 同時用,最後只會打一次 request。
然後在 composable body 裡自動觸發(先用最直覺的寫法,等一下會修正):
export function useLabelOptions() {
ensureLoaded() // ← 只要被使用就觸發;cache 有效時是 no-op
// ...computed
return { ensureLoaded, groupOptions }
}
ensureLoaded() 是 fire-and-forget,不阻塞 setup。因為 options 是 reactive 的,資料載完會自動更新到畫面——純消費方完全不用改任何 code 就有資料了。
需要「等資料就緒」的地方(例如表單顯示前的 loading gate),仍然可以 await ensureLoaded()——它已經被去重,多 await 一次沒有額外成本。這也是為什麼我把對外的名稱從 fetch 改成 ensureLoaded:fetch 這名字會讓人誤以為「必須先呼叫才有資料」,正是當初害表單壞掉的錯誤心智模型;ensureLoaded 的語意是「確保載好」,呼不呼叫都不影響資料會自己進來。
這整包其實就是手刻版的 Pinia store(global state + getters + action)。但若專案沒在用 Pinia,為了一個選項 cache 導入一套狀態管理並不划算(YAGNI),module scope singleton 就夠了——這也是 Vue 官方文件提到的「在 module scope 建立 global state」做法。12
重構二:computed 也該外提到 module scope
重構完一個 code smell 還在:state 是 singleton(module scope),但 derived 的 computed 卻寫在 useLabelOptions() 裡。
export function useLabelOptions() {
ensureLoaded()
const groupOptions = computed(() => formatOptions(rawLabels.value.category_label)) // ← 每次呼叫都新建一個
return { ensureLoaded, groupOptions }
}
每次呼叫 useLabelOptions() 都會重新建立一組 computed。5 個 component 用它,就有 5 份各自獨立、但內容完全相同的 computed,全部 derive 自同一個 rawLabels。功能對,但「同一個東西散落在不同 scope」就是不一致。
有趣的是,大多數人對 ref 的直覺很準——「共用的狀態當然放 module scope」,但對 computed 卻不會這樣想。原因是大腦把它們歸成兩類:
ref= state(身分):天生知道「同一份資料只能有一份」,所以一定外提。computed= derived(視圖):感覺像「拿 state 算一下的結果」,隨手算、哪裡要哪裡算都行,所以本能寫在用的地方。
但 derived 也是有身分的。把 computed 也提到 module scope,useLabelOptions() 就退化成單純的 accessor:
// module scope —— 全部 singleton,建立一次
const groupOptions = computed(() => formatOptions(rawLabels.value.category_label))
const regionLabelMap = computed(() =>
new Map(rawLabels.value.region_label.map(r => [r.value, r.label])),
)
function getRegionLabel(id: string) {
return regionLabelMap.value.get(id) ?? id
}
export function useLabelOptions() {
ensureLoaded() // 唯一的 per-call 行為:觸發載入
return { ensureLoaded, groupOptions, getRegionLabel }
}
判準不是「成本」,是「依賴來源」
該不該外提,真正的判準是 computed derive 自什麼:
- 只 derive 自 module scope 的 state、不依賴任何 per-call 的東西 → 外提(它本質就是 singleton 的一部分)。
- 有 close over 到 per-call 的參數 / local ref / props → 必須留在 function 內。
一個更快的嗅覺:useXxx() 不收參數、回傳的全是 derive 自全域的東西 → 它根本是個 accessor,所有 computed 都能外提。一旦哪天它開始收參數(例如 useLabelOptions(marketId)),那些依賴 marketId 的 computed 就得縮回 function 內——這也是為什麼一般 composable(像 useCounter)的 computed 都在裡面:它們的 state 本來就是 per-call 的。2
至於「per-call computed 成本很低」這句——是對的,但它是「就算不共用也死不了」的安心牌,不是「該不該共用」的理由。per-call computed 不是零成本:每個都是獨立的 reactive effect,有自己的依賴追蹤跟 cache,N 個 component 就是 N 份在算同一件事。便宜,但不是免費。決策順序應該是:先用「依賴來源」決定該不該共用,成本只是備註,別讓「反正很便宜」變成不去想 single source of truth 的藉口。
重構三:fire-and-forget 的 floating promise
最後回頭看 composable body 那個自動觸發:
ensureLoaded() // 沒有 await、沒有 .catch
這是一個 floating(懸空)promise——被呼叫卻沒人接它的結果。當初我寫的是 void ensureLoaded(),後來想想 void 在這裡其實沒必要,但拿掉的過程牽出一個真正該處理的點。
void 到底做什麼
void <expr> 的語意極簡單:執行 expr,丟掉結果、回傳 undefined。它不會取消 promise、不會吞 error、對執行毫無影響。3
所以 void ensureLoaded() 跟 ensureLoaded() 在 runtime 完全一樣。void 純粹是寫給人和 linter 看的訊號:「我知道這是 promise,我是故意不等它的,別當我漏寫 bug」。typescript-eslint 的 no-floating-promises 規則就是在強迫你對每個 promise 表態——要嘛 await、要嘛 .catch、要嘛用 void 明示放生(allowVoid 選項)。4
但這個專案沒開 no-floating-promises(它需要 type-aware linting),而且整個 codebase 沒有人用 void operator——留著反而是引進一個團隊沒在用的慣例。所以 void 該拿掉。
真正的問題:這個 floating promise 會 reject
void 只是「宣告我故意不等」,但不處理 error。回頭看 ensureLoaded 沒有 catch,API 失敗時 reject 會一路往外拋。那 composable body 裡這個沒接的呼叫:
ensureLoaded() // API 掛掉時 → floating rejection → 觸發全域 unhandledrejection
每個用到 composable 的 component 都會觸發它。一旦選項 API 失敗,就會產生 unhandled rejection——專案若接了 Sentry 之類的錯誤監控,這會直接噴成一筆筆錯誤記錄。5
那些 await ensureLoaded() 且包在 try/catch 裡的呼叫是安全的;但 composable body 這個 auto-trigger 永遠沒人接。所以正確做法不是糾結 void 要不要留,而是明確處理這個 error:
export function useLabelOptions() {
// self-fetch:失敗靜默(下次被使用時會重試),只留 log,避免 floating rejection。
ensureLoaded().catch(err => console.error('[labelOptions] 自動載入失敗', err))
return { ensureLoaded, groupOptions, getRegionLabel }
}
心智模型:void 是「我故意不等、出事算我的」的宣告;.catch() 是真的去處理。當 floating promise 會 reject 時,你需要的是後者。
Trade-off 與適用場景
這個 lazy-loaded singleton pattern 適合:全 app 共用、不常變動、可 cache 的唯讀資料——選項清單、字典檔、設定常數這類。
不適合的情況:
- 跟使用者 / 路由參數綁定的資料:那種資料是 per-instance 的,硬塞 module scope singleton 會在不同情境間互相污染。
- SSR 場景:module scope 的 singleton 在 server 上會被多個 request 共用,造成跨請求的狀態洩漏。Vue 官方文件特別提醒過這點,SSR 下要改用 request-scoped 的狀態。1 本文的情境是
ssr: false的 SPA,所以沒這個問題。 - 複雜的跨頁狀態:有大量 mutation、需要 devtools/time-travel 時,直接上 Pinia 比手刻 singleton 划算。
環境資訊
- Node.js: v22.16.0
- Nuxt: 3.17.x(
ssr: false,SPA 模式) - Vue: 3.5.x
- TypeScript: 5.8.x
本文撰寫時間:2026 年 6 月,技術版本可能隨時間更新,請以官方文件為準。
Reference
- State Management | Vue.js — 說明在 module scope 用
ref()建立 global state 的做法,以及 SSR 下 singleton 會跨請求共用的注意事項。 ↩ ↩2 - Composables | Vue.js — composable 可回傳 module scope 的 global state,也可回傳 per-component 的 local state。 ↩ ↩2
- void operator - JavaScript | MDN —
void運算子執行 expression 並丟棄回傳值。 ↩ - no-floating-promises | typescript-eslint — 要求 promise 必須被處理(await / catch / void),
allowVoid選項允許用void標記為刻意不處理。 ↩ - Using promises | MDN — 未處理的 promise rejection 與 floating promise 造成的時序與錯誤處理問題。 ↩