Appearance
從重複邏輯到 composable
上一章結尾我們留下一句話:元件解決畫面的重複,那邏輯的重複呢?兩個長得完全不一樣的元件,裡面卻寫著同一段「數量加減、不能超過庫存」的程式碼,這種重複沒辦法靠抽成元件解決,因為要重用的不是畫面,是行為。這一章要介紹的 composable(組合式函式)就是為它而生。
整章的路線是「先能用,再通用」:這一節先把第一個 composable 抽出來,看清楚它的長相與規則;接著處理副作用的清理,再把非同步請求的狀態封裝成一個可靠的 composable;最後把它升級成純值、ref、getter 都能接受的可調適版本。
重複的不是畫面,是邏輯
第三章我們做過 QuantityPicker:把數量選擇的畫面與邏輯包成一個元件,哪裡需要就放哪裡。但元件重用有一個前提:畫面也要長一樣。
想像書店裡有兩個地方都需要「數量」這件事。商品頁的購買面板,數量旁邊是大大的「加入購物車」按鈕;購物車列表裡,每一列只有小小的加減符號。畫面完全不同,邏輯卻一模一樣:數量從 1 起跳、不能低於 1、不能超過庫存。
先看購買面板,邏輯直接寫在元件裡:
src/components/BuyPanel.vue:
vue
<script setup>
import { ref, computed } from 'vue'
const stock = 5
const quantity = ref(1)
const isMin = computed(() => quantity.value <= 1)
const isMax = computed(() => quantity.value >= stock)
function increment() {
if (!isMax.value) quantity.value++
}
function decrement() {
if (!isMin.value) quantity.value--
}
</script>
<template>
<section class="buy-panel">
<button type="button" :disabled="isMin" @click="decrement">−</button>
<span>{{ quantity }}</span>
<button type="button" :disabled="isMax" @click="increment">+</button>
<button type="button">加入購物車</button>
</section>
</template>
<style scoped>
.buy-panel {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 2rem;
}
</style>再看購物車列表的每一列。模板換成小小的加減符號與小計,但 <script setup> 的上半段,跟購買面板一個字都不差:
src/components/CartRow.vue:
vue
<script setup>
import { ref, computed } from 'vue'
const stock = 5
const quantity = ref(1)
const isMin = computed(() => quantity.value <= 1) // 和 BuyPanel 原封不動的五樣東西
const isMax = computed(() => quantity.value >= stock)
function increment() {
if (!isMax.value) quantity.value++
}
function decrement() {
if (!isMin.value) quantity.value--
}
</script>
<template>
<li class="cart-row">
<span>重新認識 Vue.js</span>
<button type="button" :disabled="isMin" @click="decrement">−</button>
{{ quantity }}
<button type="button" :disabled="isMax" @click="increment">+</button>
</li>
</template>
<style scoped>
.cart-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
</style>quantity、isMin、isMax、increment、decrement,五樣東西抄了兩份。今天改了「不能超過庫存」的規則,明天就得記得改兩個地方。
抽出第一個 composable
這段邏輯只用到 ref 與 computed,而 Composition API 的這些函式並沒有規定只能寫在 <script setup> 裡。把它們搬進一個獨立的函式,放到 src/composables/ 目錄(第一章介紹專案結構時預留的位置):
src/composables/useQuantity.js:
js
import { ref, computed } from 'vue'
export function useQuantity(max) {
const quantity = ref(1)
const isMin = computed(() => quantity.value <= 1)
const isMax = computed(() => quantity.value >= max)
function increment() {
if (!isMax.value) quantity.value++
}
function decrement() {
if (!isMin.value) quantity.value--
}
// 把狀態與操作一起交還給呼叫端
return { quantity, isMin, isMax, increment, decrement }
}兩個元件的 <script setup> 同時瘦身成一行呼叫,模板留著各自的長相:
src/components/BuyPanel.vue:
vue
<script setup>
import { useQuantity } from '../composables/useQuantity.js'
const { quantity, isMin, isMax, increment, decrement } = useQuantity(5)
</script>
<template>
<section class="buy-panel">
<button type="button" :disabled="isMin" @click="decrement">−</button>
<span>{{ quantity }}</span>
<button type="button" :disabled="isMax" @click="increment">+</button>
<button type="button">加入購物車</button>
</section>
</template>
<style scoped>
.buy-panel {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 2rem;
}
</style>src/components/CartRow.vue:
vue
<script setup>
import { useQuantity } from '../composables/useQuantity.js'
const { quantity, isMin, isMax, increment, decrement } = useQuantity(5)
</script>
<template>
<li class="cart-row">
<span>重新認識 Vue.js</span>
<button type="button" :disabled="isMin" @click="decrement">−</button>
{{ quantity }}
<button type="button" :disabled="isMax" @click="increment">+</button>
</li>
</template>
<style scoped>
.cart-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
</style>邏輯集中在一個檔案,規則改一次就好;兩個元件各拿各的狀態,畫面互不干涉。這就是上一章 scoped slots 出現過的「邏輯歸邏輯、畫面歸畫面」:scoped slots 是元件把資料交還給呼叫端決定畫面;composable 則是把邏輯整包搬出元件,畫面留在各自的元件裡。
composable 到底是什麼
說穿了,composable 就是用 Composition API 封裝「有狀態邏輯」的函式。跟一般工具函式的差別就在「有狀態」三個字:formatPrice(1200) 進去一個值、出來一個值,呼叫完就結束;useQuantity(5) 回傳的 quantity 是活的,之後每一次 increment() 都會讓用到它的畫面跟著更新。
命名慣例是以 use 開頭的小駝峰,例如 useQuantity、useCartTotal。這不是語法規定,Vue 不會因為函式不叫 use 就報錯,但社群與官方文件都遵循這個慣例:看到 use 開頭,就知道「這個函式裡面有響應式狀態,回傳值不是一次性的」。
composable 還是 hook?
如果各位讀者接觸過 React,會發現 composable 跟 custom hook 長得很像。概念確實相通,但 Vue 的 composable 只在 setup 期間執行一次,沒有「每次渲染重新執行」的心智負擔,也不需要煩惱依賴陣列。
每次呼叫,各自獨立
一個常見的疑問:兩個元件都呼叫 useQuantity(),數量會不會互相干擾?
不會。只要狀態是在 composable 函式內建立的,每一次呼叫都會產生一份全新的狀態。useQuantity 裡的 const quantity = ref(1) 在每次呼叫時都重新執行,商品頁拿到一份、購物車列拿到另一份,彼此毫無關聯,就像每次呼叫函式都有自己的區域變數。
同一個元件呼叫兩次也一樣:
src/App.vue:
vue
<script setup>
import { useQuantity } from './composables/useQuantity.js'
// 兩本書各自的數量狀態,互不影響
const vueBook = useQuantity(5)
const jsBook = useQuantity(3)
</script>
<template>
<main class="app">
<p>重新認識 Vue.js:{{ vueBook.quantity }} 本</p>
<p>0 陷阱 JavaScript:{{ jsBook.quantity }} 本</p>
</main>
</template>
<style scoped>
.app {
padding: 2rem;
}
</style>「狀態建立在函式內」這個前提很重要。如果把 ref 宣告搬到函式外面(模組層級),行為會完全不同,所有呼叫端會共用同一份狀態。這件事有它的用途也有它的陷阱,這一章的最後一個技術主題會回來處理。
composable 也能組合 composable
composable 的「組合」不只發生在元件裡,composable 自己也能呼叫其他 composable,跟一般函式互相呼叫沒有兩樣。例如在數量之上加一層小計:
src/composables/useSubtotal.js:
js
import { computed } from 'vue'
import { useQuantity } from './useQuantity.js'
export function useSubtotal(price, max) {
// 直接站在 useQuantity 的肩膀上
const { quantity, isMin, isMax, increment, decrement } = useQuantity(max)
const subtotal = computed(() => price * quantity.value)
return { quantity, isMin, isMax, increment, decrement, subtotal }
}小的 composable 各自負責一件事,再組合成大的。這種一層疊一層的寫法,正是 Composition API 名字裡 composition 的意思。
到這裡,第一個 composable 已經能用了。不過 useQuantity 只有 ref 與 computed,屬於最乾淨的那一種邏輯;一旦邏輯裡出現事件監聽、計時器這類「會留下痕跡」的副作用,抽出去之後就多了一個問題:誰負責收拾?下一節副作用與清理就來處理它。
