跳至主要內容
Skip to content

Composition API 實戰與 Composable 設計

Composition API 不是把 Options API 換一種寫法而已。它真正解決的是:當一個元件變大後,相關邏輯可以按照「功能」組織,而不是分散在 datacomputedmethodswatch、生命週期裡。

一句話回答:Composition API 讓我們可以用 setuprefreactivecomputedwatch 和生命週期函式組合狀態與副作用,並把可復用的邏輯抽成 Composable;好的 Composable 應該有清楚的輸入輸出、明確的狀態歸屬、可清理的副作用,而不是把所有程式碼都硬拆成 useSomething


一、 必考觀念

1. Composition API 解決什麼問題?

Options API 依照選項分類:

text
data
computed
watch
methods
mounted

當元件很小時,這樣很直觀。

但元件變大後,同一個功能的邏輯可能被拆散:

text
搜尋功能:
- data 裡有 keyword、results、loading
- computed 裡有 filteredResults
- watch 裡監聽 keyword
- methods 裡有 search、reset
- mounted 裡初始化請求

Composition API 則可以依功能聚合:

text
useSearch()
usePagination()
useSelection()
usePermission()

它的核心不是「比較新」,而是讓大型元件與邏輯復用更容易維護。

2. setup 的執行時機

setup 會在元件建立時執行,早於 Options API 的 created

setup 裡可以:

  • 建立響應式狀態。
  • 宣告 computed。
  • 設定 watch。
  • 註冊生命週期。
  • 回傳模板可用資料。
  • 呼叫 Composable。

簡化理解:

vue
<script setup lang="ts">
import { computed, ref } from 'vue'

const count = ref(0)
const double = computed(() => count.value * 2)

function increment() {
  count.value++
}
</script>

<script setup> 是語法糖,讓你不用手動 return

3. setup 裡不能直接使用 this

Options API 常用:

javascript
export default {
  data() {
    return {
      count: 0,
    }
  },
  methods: {
    increment() {
      this.count++
    },
  },
}

Composition API 裡不依賴 this

typescript
const count = ref(0)

function increment() {
  count.value++
}

這讓邏輯更接近普通 JavaScript 函式,也更容易抽出、測試與復用。


二、 refreactive 怎麼選?

1. ref 適合單一值與可替換引用

常見:

typescript
const count = ref(0)
const keyword = ref('')
const loading = ref(false)
const user = ref<User | null>(null)
const list = ref<User[]>([])

在 JavaScript 中讀寫需要 .value

typescript
count.value++

模板中會自動解包:

vue
<button>{{ count }}</button>

延伸讀:Vue 中 ref 的作用

2. reactive 適合一組相關欄位

例如表單:

typescript
const form = reactive({
  name: '',
  email: '',
  role: 'user',
})

它適合多個欄位一起描述同一個物件狀態。

但要注意:不要直接解構 reactive,否則可能丟失響應式連結。

不建議:

typescript
const { name, email } = form

如果需要解構,可以使用 toRefs

typescript
const { name, email } = toRefs(form)

3. 團隊常見選擇

實務上很多團隊會偏好:

  • 基礎值用 ref
  • 表單物件用 reactive
  • 大型列表用 refshallowRef
  • 第三方實例用 shallowRef 搭配 markRaw

不是誰絕對比較好,而是要看資料形狀、更新方式與可讀性。

延伸讀:Vue 響應式性能優化


三、 computed、watch 與生命週期

1. 衍生資料用 computed

typescript
const firstName = ref('Ada')
const lastName = ref('Lovelace')

const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`
})

computed 適合:

  • 從狀態推導資料。
  • 模板要讀的結果。
  • 需要快取的純計算。

延伸讀:Vue computed 的實現原理

2. 副作用用 watch

typescript
watch(keyword, value => {
  fetchSearchResult(value)
})

watch 適合:

  • 發請求。
  • 寫 localStorage。
  • 同步 router query。
  • 呼叫第三方套件。
  • 上報 analytics。

如果只是推導一個值,不要用 watch 把資料塞到另一個 ref,computed 會更自然。

延伸讀:watch 與 computed 的區別

3. 生命週期函式

Composition API 使用函式註冊生命週期:

typescript
onMounted(() => {
  loadData()
})

onBeforeUnmount(() => {
  cleanup()
})

常見對照:

Options APIComposition API
mountedonMounted
updatedonUpdated
beforeUnmountonBeforeUnmount
unmountedonUnmounted
activatedonActivated
deactivatedonDeactivated

API 請求放哪裡,要看是否需要 SSR、是否依賴 DOM、是否和路由參數相關。

延伸讀:接口請求一般放在哪個生命週期?為什麼?


四、 Composable 是什麼?

1. Composable 是可組合的狀態邏輯

Composable 通常是一個以 use 開頭的函式:

typescript
export function useCounter(initialValue = 0) {
  const count = ref(initialValue)

  function increment() {
    count.value++
  }

  function reset() {
    count.value = initialValue
  }

  return {
    count,
    increment,
    reset,
  }
}

使用:

typescript
const { count, increment, reset } = useCounter()

它不是 Vue 專屬魔法,本質上是普通函式,只是裡面使用了 Vue 的響應式 API。

2. 什麼邏輯適合抽成 Composable?

適合:

  • 多個元件重複使用。
  • 有清楚輸入輸出。
  • 狀態、computed、watch、生命週期彼此相關。
  • 可以獨立測試。
  • 和 UI 結構沒有強綁定。

例如:

  • useSearch
  • usePagination
  • useSelection
  • useRequest
  • useLocalStorage
  • useEventListener
  • usePermission

不適合:

  • 只用一次、而且沒有變複雜。
  • 強依賴某個元件模板細節。
  • 抽出後參數很多、閱讀更困難。
  • 為了看起來高級而硬拆。

3. Composable 和普通工具函式差在哪?

普通工具函式通常是純計算:

typescript
function formatPrice(value: number) {
  return `$${value.toFixed(2)}`
}

Composable 通常包含響應式狀態或生命週期:

typescript
function useWindowSize() {
  const width = ref(window.innerWidth)
  const height = ref(window.innerHeight)

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

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

  onBeforeUnmount(() => {
    window.removeEventListener('resize', update)
  })

  return {
    width,
    height,
  }
}

如果函式沒有響應式狀態,也沒有生命週期或副作用,它可能只是 utility,不一定要命名成 useXxx


五、 Composable 設計原則

1. 輸入輸出要清楚

好的 Composable 一眼能看出:

  • 需要哪些參數。
  • 回傳哪些狀態。
  • 回傳哪些操作。
  • 是否會自動執行副作用。
  • 是否需要手動清理。

例如:

typescript
function useUserQuery(userId: Ref<string>) {
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<unknown>(null)

  async function refresh() {
    loading.value = true
    error.value = null

    try {
      user.value = await fetchUser(userId.value)
    } catch (err) {
      error.value = err
    } finally {
      loading.value = false
    }
  }

  watch(userId, refresh, {
    immediate: true,
  })

  return {
    user,
    loading,
    error,
    refresh,
  }
}

這比在元件裡散落 loadingerrorfetchUserwatch 更可讀。

2. 副作用要能清理

如果 Composable 註冊事件、timer、請求,就要思考清理。

typescript
export function useEventListener(
  target: EventTarget,
  event: string,
  handler: EventListener
) {
  onMounted(() => {
    target.addEventListener(event, handler)
  })

  onBeforeUnmount(() => {
    target.removeEventListener(event, handler)
  })
}

如果是 request,則要處理 AbortController 或 request id。

延伸讀:Vue 請求、快取與體驗優化

3. 不要隱藏太多行為

有些 Composable 看起來很方便,但副作用太隱性:

typescript
const user = useUser()

這行到底做了什麼?

  • 是否立即發請求?
  • 是否讀 localStorage?
  • 是否寫全域 store?
  • 是否監聽 route?
  • 是否需要登入?
  • 是否會彈 toast?

如果副作用很多,命名和文件要說清楚,或拆成更明確的 API:

typescript
const { user, refresh } = useUserQuery(userId)

讓呼叫方知道它是 query 類邏輯,而不是普通資料。

4. 不要過度封裝

過度封裝常見症狀:

  • 每三行程式碼都抽一個 useXxx
  • Composable 參數越來越多。
  • 回傳值非常巨大。
  • 呼叫者需要知道內部很多細節。
  • 為了復用犧牲可讀性。

比較好的判斷:

如果抽出後讓業務元件更清楚,且這段邏輯有獨立概念,才值得抽。

不是所有邏輯都要抽成 Composable。


六、 Composable 和其他復用方式

1. 和 Mixin 的差異

Mixin 的問題:

  • 來源不清楚。
  • 命名容易衝突。
  • 隱式注入 data / methods。
  • 多個 mixin 合在一起時很難追。

Composable 的優勢:

  • 顯式 import。
  • 顯式呼叫。
  • 顯式回傳。
  • 可以用 TypeScript 推導。
  • 更容易測試。
typescript
const { keyword, results, search } = useSearch()

呼叫方清楚知道資料從哪裡來。

2. 和 Provide / Inject 的差異

Provide / Inject 適合跨層傳遞上下文,例如:

  • 表單上下文。
  • 主題。
  • 國際化。
  • 父容器共享狀態。

Composable 適合抽取邏輯。

兩者也可以搭配:

typescript
function useFormProvider() {
  const form = reactive({})

  provide(FormKey, form)

  return form
}

function useFormContext() {
  const form = inject(FormKey)

  if (!form) {
    throw new Error('Form context is missing')
  }

  return form
}

延伸讀:Vue 元件通訊方式有哪些及原理

3. 和 Pinia 的差異

Composable 不等於 store。

類型適合
Composable可復用邏輯、局部狀態、生命週期、副作用
Pinia跨頁共享的業務狀態、全域資料、狀態持久化

例如搜尋框局部狀態不一定要進 Pinia;登入使用者、權限、購物車則更像全域 store。

判斷重點是:狀態歸屬在哪裡,以及是否需要跨頁共享。


七、 追問題庫

Q1:Composition API 比 Options API 好在哪?

不是絕對比較誰好。

Composition API 更適合大型元件、邏輯復用、TypeScript 推導與按功能組織程式碼。Options API 對小元件仍然直觀。

好的回答應該是:Composition API 的優勢在於邏輯聚合與可組合性,而不是因為 Options API 過時。

Q2:refreactive 怎麼選?

單一值、可整包替換的資料、列表常用 ref。多個欄位描述同一個物件狀態,例如表單,可以用 reactive

大型資料或第三方實例則要考慮 shallowRefshallowReactivemarkRaw

Q3:Composable 什麼時候會變成壞設計?

當它隱藏太多副作用、參數太多、回傳太大、只被使用一次卻讓閱讀更困難時,就可能是過度封裝。

Composable 應該降低複雜度,而不是把複雜度藏到另一個檔案。

Q4:Composable 裡可以用生命週期嗎?

可以。

Composable 如果在元件 setup 同步呼叫,就可以使用 onMountedonBeforeUnmount 等生命週期函式。這也是它能封裝事件監聽、timer、請求清理的重要原因。

Q5:Composable 裡的狀態是共享的嗎?

看寫法。

如果狀態寫在函式內,每次呼叫都會建立新狀態:

typescript
function useCounter() {
  const count = ref(0)

  return {
    count,
  }
}

如果狀態寫在模組頂層,所有呼叫會共享:

typescript
const count = ref(0)

function useSharedCounter() {
  return {
    count,
  }
}

共享狀態要小心 SSR 跨請求污染與測試隔離。


八、 實作題

題目:請設計一個 useSearch Composable

需求:

  • 接收搜尋 API。
  • 管理 keyword、results、loading、error。
  • 支援防抖。
  • 支援取消舊請求。
  • 回傳 reset 與 search 方法。

可以這樣設計:

typescript
type SearchApi<T> = (
  keyword: string,
  options: { signal: AbortSignal }
) => Promise<T[]>

export function useSearch<T>(api: SearchApi<T>, delay = 300) {
  const keyword = ref('')
  const results = ref<T[]>([])
  const loading = ref(false)
  const error = ref<unknown>(null)

  let timer: ReturnType<typeof setTimeout> | null = null
  let controller: AbortController | null = null

  async function search() {
    controller?.abort()

    controller = new AbortController()
    loading.value = true
    error.value = null

    try {
      results.value = await api(keyword.value, {
        signal: controller.signal,
      })
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        error.value = err
      }
    } finally {
      loading.value = false
    }
  }

  watch(keyword, () => {
    if (timer) {
      clearTimeout(timer)
    }

    timer = setTimeout(search, delay)
  })

  onBeforeUnmount(() => {
    if (timer) {
      clearTimeout(timer)
    }

    controller?.abort()
  })

  function reset() {
    keyword.value = ''
    results.value = []
    error.value = null
  }

  return {
    keyword,
    results,
    loading,
    error,
    search,
    reset,
  }
}

面試時可以補充:實務上還會考慮最短 keyword 長度、立即搜尋、快取 query、錯誤提示策略與 race condition。


九、 資深視角

1. Composable 是架構工具,不只是抽函式

好的 Composable 會讓團隊形成一致模式:

  • useRequest 管理請求狀態。
  • usePagination 管理分頁。
  • useSelection 管理勾選。
  • usePermission 管理權限判斷。
  • useRouteQuery 同步 URL query。

它可以讓業務元件更專注於模板與流程。

2. 邏輯復用要保留語意

不要為了共用,把兩個語意不同的功能硬塞進同一個 Composable。

例如「商品搜尋」和「使用者搜尋」可能都需要 keyword 和 results,但它們的權限、快取、排序、錯誤處理不一定相同。

過度抽象會讓每次需求變更都要改一個巨大通用函式。

3. SSR 與共享狀態要小心

Composable 如果在模組頂層保存狀態:

typescript
const user = ref<User | null>(null)

在純 CSR 通常是全域共享狀態;但在 SSR 中,如果沒有為每個 request 建立隔離狀態,可能發生跨請求污染。

資深回答要能說出:Composable 可以共享狀態,但共享狀態要看執行環境、生命週期與資料敏感性。

延伸讀:Vue SSR 的實現原理


總結

問題回答重點
Composition API 解決什麼依功能組織邏輯,改善大型元件與邏輯復用
setup 做什麼建立狀態、computed、watch、生命週期,回傳模板可用資料
ref / reactive 怎麼選單一值與可替換引用用 ref,相關欄位物件可用 reactive
Composable 是什麼使用 Vue 響應式 API 的可復用狀態邏輯
好的 Composable輸入輸出清楚、副作用可清理、狀態歸屬明確
常見風險過度封裝、隱藏副作用、共享狀態污染

一句完整的面試回答可以是:

Composition API 的價值不只是換語法,而是讓元件邏輯可以按照功能聚合,並把可復用的狀態邏輯抽成 Composable。setup 裡可以建立 refreactivecomputedwatch 與生命週期;單一值或可替換引用常用 ref,一組相關欄位可以用 reactive。Composable 本質是普通函式,只是內部使用 Vue 響應式 API。好的 Composable 應該有清楚輸入輸出、明確副作用與清理方式,不要隱藏太多行為,也不要為了抽象而抽象。若狀態寫在函式內,每次呼叫是獨立的;若寫在模組頂層,就會共享,SSR 時要小心跨請求污染。

延伸閱讀