把 Vue Composable 重構成自己管理生命週期的 Lazy-loaded Singleton

· updated 2026-07-03·12 min read

有一個共用的 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 該怎麼處理

問題出在「誰負責載入」

先看原本的結構(已去識別化、簡化欄位):

ts
// 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 做去重:

ts
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 裡自動觸發(先用最直覺的寫法,等一下會修正):

ts
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 改成 ensureLoadedfetch 這名字會讓人誤以為「必須先呼叫才有資料」,正是當初害表單壞掉的錯誤心智模型;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()

ts
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:

ts
// 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 那個自動觸發:

ts
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 裡這個沒接的呼叫:

ts
ensureLoaded()   // API 掛掉時 → floating rejection → 觸發全域 unhandledrejection

每個用到 composable 的 component 都會觸發它。一旦選項 API 失敗,就會產生 unhandled rejection——專案若接了 Sentry 之類的錯誤監控,這會直接噴成一筆筆錯誤記錄。5

那些 await ensureLoaded() 且包在 try/catch 裡的呼叫是安全的;但 composable body 這個 auto-trigger 永遠沒人接。所以正確做法不是糾結 void 要不要留,而是明確處理這個 error

ts
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

  1. State Management | Vue.js — 說明在 module scope 用 ref() 建立 global state 的做法,以及 SSR 下 singleton 會跨請求共用的注意事項。 2
  2. Composables | Vue.js — composable 可回傳 module scope 的 global state,也可回傳 per-component 的 local state。 2
  3. void operator - JavaScript | MDNvoid 運算子執行 expression 並丟棄回傳值。
  4. no-floating-promises | typescript-eslint — 要求 promise 必須被處理(await / catch / void),allowVoid 選項允許用 void 標記為刻意不處理。
  5. Using promises | MDN — 未處理的 promise rejection 與 floating promise 造成的時序與錯誤處理問題。