iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 21 篇

Vue 走過路過不要錯過 Day21 - Composables:把重複邏輯抽成 useXxx

  • 分享至 

  • xImage
  •  

寫 Vue 寫久了,一定會遇到這種情況:A 組件寫了一段邏輯,過兩天 B 組件也要一模一樣的東西。

最直覺的做法就是複製貼上。能動,但接下來的事大家應該都經歷過:某天發現那段邏輯有 bug,修了 A 忘了 B;或是想加個功能,要記得每個地方都改一次。複製的次數越多,維護的地雷就越多。

還記得 Day 14 寫過的 useModal 嗎?當時重點放在模板 ref,沒有細講為什麼它要叫 use 開頭、為什麼可以獨立成一個檔案。今天就來把這件事講清楚:Composables(組合式函式)。

先看一段會被複製的邏輯

用官方文件也拿來當例子的「追蹤滑鼠座標」來說明,直接寫在組件裡長這樣:

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
 
const x = ref(0)
const y = ref(0)
 
function update(e) {
  x.value = e.pageX
  y.value = e.pageY
}
 
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>
 
<template>
  <p>滑鼠位置:{{ x }}, {{ y }}</p>
</template>

這段本身沒什麼問題。問題是當第二個、第三個組件也想要滑鼠座標時,這十幾行就會跟著到處跑。

把它抽出去

既然是重複的邏輯,那就抽成一個函式:

// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'
 
export function useMouse() {
  const x = ref(0)
  const y = ref(0)
 
  function update(e) {
    x.value = e.pageX
    y.value = e.pageY
  }
 
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))
 
  return { x, y }
}

組件裡只剩一行:

<script setup>
import { useMouse } from './composables/useMouse'
 
const { x, y } = useMouse()
</script>
 
<template>
  <p>滑鼠位置:{{ x }}, {{ y }}</p>
</template>

看起來很簡單,就是把程式碼搬到另一個檔案而已。但這裡面其實藏了三個值得想一想的問題:

  1. 抽出去的函式那麼多,為什麼這個叫 composable,formatDate() 就不算?
  2. ref 和 onMounted 不是組件的東西嗎?為什麼可以寫在組件外面?
  3. 為什麼回傳的是 x,而不是 x.value?

一、跟一般的 utils 差在哪?

先想想 formatDate(date) 這種工具函式:給它一個日期,它還你一個字串,然後就結束了。呼叫完之後,它不會「記得」任何事情,也不會再做任何事情。

但滑鼠座標不一樣。useMouse() 呼叫完之後,座標還要持續更新,組件卸載時還要記得把監聽拆掉。要做到這兩件事,它需要 utils 沒有的東西:

  • 會變的狀態:x、y 這兩個 ref
  • 跟著組件生命週期走的副作用:onMounted 綁事件、onUnmounted 拆事件
    所以 composable 和 utils 的分界線,不在於「有沒有抽成函式」,而在於有沒有狀態或生命週期。官方文件的說法是:composable 封裝的是「有狀態的邏輯(stateful logic)」。

命名上的慣例是用 use 開頭,例如 useMouse、useFetch、useModal,看到名字就知道「這裡面有響應式狀態,而且要在 setup 裡呼叫」。檔案通常會集中放在 composables/ 資料夾。

二、為什麼 ref 和 onMounted 能寫在組件外?

這題要拆成兩半講,因為 ref 和 onMounted 能搬出去的原因不一樣。

ref:響應式本來就不需要組件

回想 Day 3 自己用 Proxy 寫的迷你 reactive,那段程式碼裡有出現任何組件嗎?沒有。它就是 Proxy 攔截讀寫、收集依賴、觸發更新,純粹的 JavaScript。

Vue 的響應式系統也是一樣,它是一個獨立的模組(@vue/reactivity),跟組件沒有綁在一起。所以 ref 寫在 .vue 檔裡可以動,寫在一般的 .js 檔裡也可以動。

onMounted:靠的是「目前正在 setup 的組件」

onMounted 就沒那麼單純了。仔細看它的用法:

onMounted(() => { ... })

我們從頭到尾都沒告訴它「要掛在哪個組件上」。那 Vue 怎麼知道的?

原因是 Vue 在執行某個組件的 setup 時,會先把「目前正在 setup 的組件實例」記下來。onMounted 被呼叫時,就把 callback 掛到這個實例身上。

composable 說穿了只是一個普通函式,當它在 <script setup> 裡被呼叫時,那個「目前實例」還在,所以裡面的 onMounted 自然掛得上去。

這也帶出了 composable 最重要的使用規則:要在 setup 頂層同步呼叫。

// ❌ 在事件裡才呼叫
function handleClick() {
  const { x, y } = useMouse()
}

按鈕被點的時候,setup 早就跑完了,「目前實例」已經不在,onMounted 找不到人可以掛,Vue 會在 console 丟出警告,事件也不會被綁上去。

同樣的道理,在 await 之後才呼叫也有風險,因為 await 之後的程式碼已經是在之後的某個時間點才執行:

// ⚠️ await 之後,目前實例可能已經不在了
const data = await fetchSomething()
const { x, y } = useMouse()

順帶一提:每次呼叫都是獨立的一份

既然 composable 就是普通函式,每次呼叫都會重新執行一次 ref(0),所以 A 組件和 B 組件各自呼叫 useMouse(),拿到的是兩份互不相干的狀態。這跟上一篇 provide / inject「大家共用同一份」是不同的情境,選哪個看需求。

三、回傳 ref,還是回傳 .value?

假設回傳時寫成這樣:

// ❌ 回傳的是快照
return { x: x.value, y: y.value }

組件拿到的 x 是什麼?是一個數字,呼叫當下的值,也就是 0。之後滑鼠怎麼移動,那個 0 都不會變,因為它已經跟 ref 斷開了。

所以慣例是回傳一個裝著 ref 的普通物件:

return { x, y }

這樣做的好處是組件可以直接解構,解構出來的 x、y 依然是 ref,響應性不會丟失,模板裡也會自動解包,不用寫 .value。

那如果回傳的是 reactive 呢?

const state = reactive({ x: 0, y: 0 })
return state

組件一解構 const { x, y } = useMouse(),就會拿到兩個普通的數字。原因跟 Day 4 講過的一樣:解構等於把屬性的值讀出來,而讀出來的是值本身,不再經過 Proxy。

真的想用 reactive 管理內部狀態的話,回傳時包一層 toRefs,把每個屬性轉成 ref:

const state = reactive({ x: 0, y: 0 })
return toRefs(state)

不過比起每次都要記得 toRefs,直接用 ref 組成普通物件回傳,會是比較不容易出錯的寫法。

小結

composable 看起來只是「把程式碼搬到另一個檔案」,但能這樣搬,背後其實是 Vue 3 的設計:

  • 響應式系統本身就獨立於組件之外,所以 ref 可以到處寫
  • 生命週期 hook 靠的是「目前正在 setup 的組件實例」,所以 composable 要在 setup 頂層同步呼叫
  • 回傳一個裝著 ref 的物件,組件解構後才不會丟掉響應性
    判斷要不要抽成 composable,可以先問自己一句:這段邏輯有沒有狀態或生命週期? 沒有的話,寫成一般的 utils 就好;有的話,就是 useXxx 登場的時候了。

上一篇
Vue 走過路過不要錯過 Day20 - provide / inject:不想再一層一層傳 props
下一篇
Vue 走過路過不要錯過 Day22 - 串接 API:loading、error、資料三種狀態怎麼管
系列文
重新認識Vue 走過路過不要錯過 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言