Skip to content

Vue 3 實戰:從零到一封裝一個 useMouse Composable

Vue 3 座標取得:useMouse Composable

在 Vue 3 中,將元件邏輯 去中心化 是保持程式碼整潔的關鍵。今天我們以常見的 滑鼠座標監聽 為例,示範如何從傳統的元件件寫法,重構成靈活的 Composable 函數。

初始狀態:在單一元件 (SFC) 中撰寫

通常我們會直接在 App.vue 寫入監聽邏輯。雖然功能正常,但若其他元件也需要同樣的功能,就必須複製貼上,這違反了 DRY (Don't Repeat Yourself) 原則。

vue
// App.vue
<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>Mouse Position: {{ x }}, {{ y }}</p>
</template>

這裡有兩個容易被忽略的細節:

  • update 必須是具名函式,因為 removeEventListener 要傳入與註冊時相同的函式參考,寫成匿名箭頭函式就永遠移不掉。
  • pageX 而非 clientX,代表座標 包含頁面捲動距離。跟隨畫面的浮動提示則適合改用 clientX

進階重構:封裝成可重複使用的邏輯函數

為了達到多元件共享,我們根據 Composable 的設計規範,在 composables 目錄下建立專屬檔案,並以 use 開頭命名。

實作 useMouse.js

核心邏輯保持不變,但我們將狀態與行為封裝在函數中,並 回傳 需要暴露給外部使用的響應式屬性。

js
// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue';

export default () => {
  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 };
};

生命週期為什麼能寫在這裡?

因為這個函式是在元件的 setup同步呼叫 的,Vue 知道 onMounted 屬於哪個元件。而且同一個鉤子 可以註冊多次,所以一個元件同時使用多個 Composable,各自的建立與清理都不會互相干擾。

最終成果:在元件中優雅呼叫

重構後的元件變得極度簡潔,所有的複雜邏輯都被抽離到 useMouse 之中,元件只負責讀取數據與渲染 UI。

vue
// App.vue
<script setup>
import useMouse from "@/composables/useMouse";

const { x, y } = useMouse();
</script>

<template>
  <p>Mouse Position: {{ x }}, {{ y }}</p>
</template>

核心要點筆記

獨立狀態

每一個引用 useMouse 的元件,都會擁有自己獨立的 xy 狀態。即使在不同元件中同時使用,數據也不會相互干擾或衝突。

若希望 全站共用一份座標,只要把 ref 移到函式外層,所有呼叫者就會共享同一組狀態,也只需要一組事件監聽:

js
// 模組層級的狀態:所有元件共用
const x = ref(0);
const y = ref(0);

export default () => {
  /* 註冊監聽並回傳同一份 x、y */
};

清理機制

onUnmounted 封裝在 Composable 內部,確保了當元件銷毀時,監聽器會自動移除,有效防止記憶體洩漏 (Memory Leak)。

這正是把 建立與清理寫在一起 的價值:呼叫端不需要知道內部註冊了什麼,也不會有人忘記解除監聽。相關的清理原則可參考 生命週期實戰

高可讀性

透過解構賦值,我們可以一眼看出元件使用了哪些外部邏輯,提升了大型專案的可維護性。

效能:高頻事件要不要節流?

mousemove 每秒可能觸發數十次。若只是更新兩個數值,Vue 的響應式系統完全負擔得起;但如果在回呼中做計算或直接操作 DOM,建議把更新收斂到畫面重繪的節奏上:

js
let rafId = null;

function update(e) {
  if (rafId) return;

  rafId = requestAnimationFrame(() => {
    x.value = e.pageX;
    y.value = e.pageY;
    rafId = null;
  });
}

記得在 onUnmounted 一併 cancelAnimationFrame(rafId),清理才算完整。

常見問題

為什麼可以把 onMounted 寫在 Composable 裡?

因為 Composable 是在元件的 setup 中同步呼叫的,Vue 能判斷這些鉤子屬於哪個元件。同一個鉤子也允許註冊多次,所以多個 Composable 各自管理自己的建立與清理不會衝突。

多個元件同時使用 useMouse 會互相影響嗎?

不會。每次呼叫都會建立獨立的 xy,也各自註冊一組事件監聽。若希望全站共用一份座標,可以把狀態移到函式外層變成共享狀態。

為什麼 update 要寫成具名函式?

因為 removeEventListener 必須傳入與註冊時相同的函式參考才能解除。若寫成匿名箭頭函式,卸載時就再也找不到那個監聽器。

pageX 與 clientX 有什麼不同?

pageX 是相對於整份文件的座標,會把頁面捲動距離算進去;clientX 是相對於可視區域。需要在捲動後仍然定位正確就用 pageX,跟隨畫面的浮動提示則用 clientX

mousemove 觸發太頻繁會有效能問題嗎?

單純更新兩個數值通常沒問題。但若在回呼中做計算或操作 DOM,建議搭配節流或 requestAnimationFrame,把更新頻率控制在畫面重繪的節奏上。

延伸閱讀