Composition API 實戰與 Composable 設計
Composition API 不是把 Options API 換一種寫法而已。它真正解決的是:當一個元件變大後,相關邏輯可以按照「功能」組織,而不是分散在 data、computed、methods、watch、生命週期裡。
一句話回答:Composition API 讓我們可以用 setup、ref、reactive、computed、watch 和生命週期函式組合狀態與副作用,並把可復用的邏輯抽成 Composable;好的 Composable 應該有清楚的輸入輸出、明確的狀態歸屬、可清理的副作用,而不是把所有程式碼都硬拆成 useSomething。
一、 必考觀念
1. Composition API 解決什麼問題?
Options API 依照選項分類:
data
computed
watch
methods
mounted當元件很小時,這樣很直觀。
但元件變大後,同一個功能的邏輯可能被拆散:
搜尋功能:
- data 裡有 keyword、results、loading
- computed 裡有 filteredResults
- watch 裡監聽 keyword
- methods 裡有 search、reset
- mounted 裡初始化請求Composition API 則可以依功能聚合:
useSearch()
usePagination()
useSelection()
usePermission()它的核心不是「比較新」,而是讓大型元件與邏輯復用更容易維護。
2. setup 的執行時機
setup 會在元件建立時執行,早於 Options API 的 created。
在 setup 裡可以:
- 建立響應式狀態。
- 宣告 computed。
- 設定 watch。
- 註冊生命週期。
- 回傳模板可用資料。
- 呼叫 Composable。
簡化理解:
<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 常用:
export default {
data() {
return {
count: 0,
}
},
methods: {
increment() {
this.count++
},
},
}Composition API 裡不依賴 this:
const count = ref(0)
function increment() {
count.value++
}這讓邏輯更接近普通 JavaScript 函式,也更容易抽出、測試與復用。
二、 ref 與 reactive 怎麼選?
1. ref 適合單一值與可替換引用
常見:
const count = ref(0)
const keyword = ref('')
const loading = ref(false)
const user = ref<User | null>(null)
const list = ref<User[]>([])在 JavaScript 中讀寫需要 .value:
count.value++模板中會自動解包:
<button>{{ count }}</button>延伸讀:Vue 中 ref 的作用。
2. reactive 適合一組相關欄位
例如表單:
const form = reactive({
name: '',
email: '',
role: 'user',
})它適合多個欄位一起描述同一個物件狀態。
但要注意:不要直接解構 reactive,否則可能丟失響應式連結。
不建議:
const { name, email } = form如果需要解構,可以使用 toRefs:
const { name, email } = toRefs(form)3. 團隊常見選擇
實務上很多團隊會偏好:
- 基礎值用
ref。 - 表單物件用
reactive。 - 大型列表用
ref或shallowRef。 - 第三方實例用
shallowRef搭配markRaw。
不是誰絕對比較好,而是要看資料形狀、更新方式與可讀性。
延伸讀:Vue 響應式性能優化。
三、 computed、watch 與生命週期
1. 衍生資料用 computed
const firstName = ref('Ada')
const lastName = ref('Lovelace')
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})computed 適合:
- 從狀態推導資料。
- 模板要讀的結果。
- 需要快取的純計算。
延伸讀:Vue computed 的實現原理。
2. 副作用用 watch
watch(keyword, value => {
fetchSearchResult(value)
})watch 適合:
- 發請求。
- 寫 localStorage。
- 同步 router query。
- 呼叫第三方套件。
- 上報 analytics。
如果只是推導一個值,不要用 watch 把資料塞到另一個 ref,computed 會更自然。
延伸讀:watch 與 computed 的區別。
3. 生命週期函式
Composition API 使用函式註冊生命週期:
onMounted(() => {
loadData()
})
onBeforeUnmount(() => {
cleanup()
})常見對照:
| Options API | Composition API |
|---|---|
mounted | onMounted |
updated | onUpdated |
beforeUnmount | onBeforeUnmount |
unmounted | onUnmounted |
activated | onActivated |
deactivated | onDeactivated |
API 請求放哪裡,要看是否需要 SSR、是否依賴 DOM、是否和路由參數相關。
延伸讀:接口請求一般放在哪個生命週期?為什麼?。
四、 Composable 是什麼?
1. Composable 是可組合的狀態邏輯
Composable 通常是一個以 use 開頭的函式:
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
function increment() {
count.value++
}
function reset() {
count.value = initialValue
}
return {
count,
increment,
reset,
}
}使用:
const { count, increment, reset } = useCounter()它不是 Vue 專屬魔法,本質上是普通函式,只是裡面使用了 Vue 的響應式 API。
2. 什麼邏輯適合抽成 Composable?
適合:
- 多個元件重複使用。
- 有清楚輸入輸出。
- 狀態、computed、watch、生命週期彼此相關。
- 可以獨立測試。
- 和 UI 結構沒有強綁定。
例如:
useSearchusePaginationuseSelectionuseRequestuseLocalStorageuseEventListenerusePermission
不適合:
- 只用一次、而且沒有變複雜。
- 強依賴某個元件模板細節。
- 抽出後參數很多、閱讀更困難。
- 為了看起來高級而硬拆。
3. Composable 和普通工具函式差在哪?
普通工具函式通常是純計算:
function formatPrice(value: number) {
return `$${value.toFixed(2)}`
}Composable 通常包含響應式狀態或生命週期:
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 一眼能看出:
- 需要哪些參數。
- 回傳哪些狀態。
- 回傳哪些操作。
- 是否會自動執行副作用。
- 是否需要手動清理。
例如:
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,
}
}這比在元件裡散落 loading、error、fetchUser、watch 更可讀。
2. 副作用要能清理
如果 Composable 註冊事件、timer、請求,就要思考清理。
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 看起來很方便,但副作用太隱性:
const user = useUser()這行到底做了什麼?
- 是否立即發請求?
- 是否讀 localStorage?
- 是否寫全域 store?
- 是否監聽 route?
- 是否需要登入?
- 是否會彈 toast?
如果副作用很多,命名和文件要說清楚,或拆成更明確的 API:
const { user, refresh } = useUserQuery(userId)讓呼叫方知道它是 query 類邏輯,而不是普通資料。
4. 不要過度封裝
過度封裝常見症狀:
- 每三行程式碼都抽一個
useXxx。 - Composable 參數越來越多。
- 回傳值非常巨大。
- 呼叫者需要知道內部很多細節。
- 為了復用犧牲可讀性。
比較好的判斷:
如果抽出後讓業務元件更清楚,且這段邏輯有獨立概念,才值得抽。
不是所有邏輯都要抽成 Composable。
六、 Composable 和其他復用方式
1. 和 Mixin 的差異
Mixin 的問題:
- 來源不清楚。
- 命名容易衝突。
- 隱式注入 data / methods。
- 多個 mixin 合在一起時很難追。
Composable 的優勢:
- 顯式 import。
- 顯式呼叫。
- 顯式回傳。
- 可以用 TypeScript 推導。
- 更容易測試。
const { keyword, results, search } = useSearch()呼叫方清楚知道資料從哪裡來。
2. 和 Provide / Inject 的差異
Provide / Inject 適合跨層傳遞上下文,例如:
- 表單上下文。
- 主題。
- 國際化。
- 父容器共享狀態。
Composable 適合抽取邏輯。
兩者也可以搭配:
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:ref 和 reactive 怎麼選?
單一值、可整包替換的資料、列表常用 ref。多個欄位描述同一個物件狀態,例如表單,可以用 reactive。
大型資料或第三方實例則要考慮 shallowRef、shallowReactive 或 markRaw。
Q3:Composable 什麼時候會變成壞設計?
當它隱藏太多副作用、參數太多、回傳太大、只被使用一次卻讓閱讀更困難時,就可能是過度封裝。
Composable 應該降低複雜度,而不是把複雜度藏到另一個檔案。
Q4:Composable 裡可以用生命週期嗎?
可以。
Composable 如果在元件 setup 同步呼叫,就可以使用 onMounted、onBeforeUnmount 等生命週期函式。這也是它能封裝事件監聽、timer、請求清理的重要原因。
Q5:Composable 裡的狀態是共享的嗎?
看寫法。
如果狀態寫在函式內,每次呼叫都會建立新狀態:
function useCounter() {
const count = ref(0)
return {
count,
}
}如果狀態寫在模組頂層,所有呼叫會共享:
const count = ref(0)
function useSharedCounter() {
return {
count,
}
}共享狀態要小心 SSR 跨請求污染與測試隔離。
八、 實作題
題目:請設計一個 useSearch Composable
需求:
- 接收搜尋 API。
- 管理 keyword、results、loading、error。
- 支援防抖。
- 支援取消舊請求。
- 回傳 reset 與 search 方法。
可以這樣設計:
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 如果在模組頂層保存狀態:
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裡可以建立ref、reactive、computed、watch與生命週期;單一值或可替換引用常用ref,一組相關欄位可以用reactive。Composable 本質是普通函式,只是內部使用 Vue 響應式 API。好的 Composable 應該有清楚輸入輸出、明確副作用與清理方式,不要隱藏太多行為,也不要為了抽象而抽象。若狀態寫在函式內,每次呼叫是獨立的;若寫在模組頂層,就會共享,SSR 時要小心跨請求污染。