Skip to content

從重複邏輯到 composable

重新認識 Vue.js: 008 天絕對看不完的 Vue.js 3.x 指南

上一章結尾我們留下一句話:元件解決畫面的重複,那邏輯的重複呢?兩個長得完全不一樣的元件,裡面卻寫著同一段「數量加減、不能超過庫存」的程式碼,這種重複沒辦法靠抽成元件解決,因為要重用的不是畫面,是行為。這一章要介紹的 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>

quantityisMinisMaxincrementdecrement,五樣東西抄了兩份。今天改了「不能超過庫存」的規則,明天就得記得改兩個地方。

抽出第一個 composable

這段邏輯只用到 refcomputed,而 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 開頭的小駝峰,例如 useQuantityuseCartTotal。這不是語法規定,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 只有 refcomputed,屬於最乾淨的那一種邏輯;一旦邏輯裡出現事件監聽、計時器這類「會留下痕跡」的副作用,抽出去之後就多了一個問題:誰負責收拾?下一節副作用與清理就來處理它。