本文 AI 產出,尚未審核

Vue3 教學:自訂指令(Custom Directive)

簡介

在 Vue3 中,**指令(Directive)**是 Vue 為了在模板(template)裡直接操作 DOM 而提供的特殊語法。雖然 Vue 已經內建了如 v-ifv-showv-model 等常用指令,但在實際開發時,我們常會遇到 需要重複使用的 DOM 行為,例如點擊外部關閉彈窗、拖曳、懶載入圖片等。此時,透過 自訂指令 能夠把這些重複的邏輯抽離出來,讓模板保持乾淨、可讀性更高,同時也方便日後維護與測試。

本文將從概念說明、實作範例、常見陷阱到最佳實踐,完整介紹在 Vue3 中如何建立與使用自訂指令,讓你能在專案中即時上手、解決實務需求。


核心概念

1. 什麼是自訂指令?

自訂指令是一段可以在 掛載(mount)更新(update)解除掛載(unmount) 時執行的函式集合。它的生命週期與組件相似,但僅負責操作與該指令綁定的 DOM 元素。

重點:自訂指令不會改變 Vue 的響應式系統,它只是提供一個「掛鉤」讓開發者在特定時機直接操作原生 DOM。

2. 註冊方式

Vue3 支援兩種註冊方式:

註冊層級 說明 使用情境
全域指令 透過 app.directive(name, definition) 註冊,整個應用都能使用。 常見的跨頁面功能,如點擊外部關閉、懶載入。
局部指令 在單一組件的 directives 選項中定義,只在該組件內有效。 僅在特定組件使用的特殊行為,避免全域汙染。

3. 指令定義的生命週期鉤子

鉤子 參數 說明
created(el, binding, vnode, prevVnode) el:綁定的元素
binding:指令的值與參數
vnode:當前虛擬節點
建立時呼叫,尚未插入 DOM。
beforeMount(el, binding, vnode, prevVnode) 同上 元素即將插入 DOM 前。
mounted(el, binding, vnode, prevVnode) 同上 掛載完成,元素已在 DOM 中,可安全存取尺寸、事件等。
beforeUpdate(el, binding, vnode, prevVnode) 同上 更新前,當指令的值改變時呼叫。
updated(el, binding, vnode, prevVnode) 同上 更新後,可根據新值調整行為。
beforeUnmount(el, binding, vnode, prevVnode) 同上 卸載前,可做清理工作。
unmounted(el, binding, vnode, prevVnode) 同上 卸載完成,釋放資源、移除事件監聽等。

小技巧:若只需要在掛載與卸載時做事,僅實作 mountedunmounted 即可,省去其他不必要的鉤子。

4. binding 物件說明

binding 包含了指令傳入的資訊:

interface Binding {
  instance: ComponentPublicInstance | null   // 所屬的 Vue 組件實例
  value: any                               // v-directive="value" 中的值
  oldValue: any | null                     // 前一次的值(僅在 update 時有)
  arg?: string                             // v-directive:arg 中的 arg
  modifiers: Record<string, boolean>       // v-directive.mod1.mod2 中的修飾詞
}

透過 binding.valuebinding.argbinding.modifiers,我們可以在指令內實作高度客製化的行為。


程式碼範例

以下示範 5 個常見且實用的自訂指令,從最簡單的「自動聚焦」到較複雜的「拖曳」功能,均附上完整註解說明。

範例 1:v-focus – 自動聚焦

// 全域指令:自動聚焦
import { createApp } from 'vue';
import App from './App.vue';

const app = createApp(App);

app.directive('focus', {
  // 當元素掛載完成時自動取得焦點
  mounted(el) {
    el.focus();
  }
});

app.mount('#app');

使用方式

<input type="text" v-focus placeholder="載入即自動聚焦">

說明:只需要在 mounted 鉤子中呼叫 el.focus(),即可在元件渲染完畢後自動聚焦該輸入框。


範例 2:v-tooltip – 簡易提示文字

// 局部指令範例(在 MyComponent.vue 中)
export default {
  directives: {
    tooltip: {
      mounted(el, binding) {
        const tooltip = document.createElement('span');
        tooltip.textContent = binding.value; // 取得指令傳入的文字
        tooltip.className = 'my-tooltip';
        el._tooltip = tooltip; // 暫存於元素上,方便 later cleanup

        // 基本樣式(可自行改寫 CSS)
        Object.assign(tooltip.style, {
          position: 'absolute',
          background: '#333',
          color: '#fff',
          padding: '4px 8px',
          borderRadius: '4px',
          fontSize: '12px',
          whiteSpace: 'nowrap',
          zIndex: 1000,
          opacity: 0,
          transition: 'opacity 0.2s'
        });

        document.body.appendChild(tooltip);

        const show = (e) => {
          const { left, top, height } = el.getBoundingClientRect();
          tooltip.style.left = `${left}px`;
          tooltip.style.top = `${top + height + 4}px`;
          tooltip.style.opacity = 1;
        };
        const hide = () => (tooltip.style.opacity = 0);

        el.addEventListener('mouseenter', show);
        el.addEventListener('mouseleave', hide);

        // 保存事件參考,稍後可移除
        el._tooltipShow = show;
        el._tooltipHide = hide;
      },
      unmounted(el) {
        // 清理:移除 tooltip 元素與事件監聽
        document.body.removeChild(el._tooltip);
        el.removeEventListener('mouseenter', el._tooltipShow);
        el.removeEventListener('mouseleave', el._tooltipHide);
      }
    }
  }
};

使用方式

<button v-tooltip="'點擊此按鈕即會送出表單'">送出</button>

說明:利用 binding.value 把提示文字傳入,並在 mounted 時建立 DOM、掛上事件;unmounted 時負責移除,避免記憶體洩漏。


範例 3:v-lazy – 圖片懶載入(IntersectionObserver)

// 全域指令:圖片懶載入
import { createApp } from 'vue';
import App from './App.vue';

const app = createApp(App);

app.directive('lazy', {
  mounted(el, binding) {
    // 若瀏覽器不支援 IntersectionObserver,直接載入圖片
    if (!('IntersectionObserver' in window)) {
      el.src = binding.value;
      return;
    }

    const observer = new IntersectionObserver((entries, obs) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          el.src = binding.value; // 設定真正的圖片來源
          obs.unobserve(el); // 不再觀察
        }
      });
    });

    observer.observe(el);
    // 把 observer 暫存於元素,方便 unmounted 時斷開
    el._lazyObserver = observer;
  },
  unmounted(el) {
    if (el._lazyObserver) {
      el._lazyObserver.unobserve(el);
    }
  }
});

app.mount('#app');

使用方式

<img v-lazy="'https://example.com/large-photo.jpg'" alt="懶載入圖片">

說明:透過 IntersectionObserver 判斷圖片是否進入視窗,若是則設定 src,大幅降低首屏載入時間。


範例 4:v-click-outside – 點擊外部關閉(彈窗、下拉選單)

// 局部指令:點擊外部關閉
export default {
  directives: {
    clickOutside: {
      beforeMount(el, binding) {
        // 點擊事件會在捕獲階段先觸發,確保先於子元素
        el._clickOutsideHandler = (e) => {
          if (!el.contains(e.target)) {
            // 執行傳入的回呼函式
            binding.value(e);
          }
        };
        document.addEventListener('click', el._clickOutsideHandler);
      },
      unmounted(el) {
        document.removeEventListener('click', el._clickOutsideHandler);
      }
    }
  }
};

使用方式

<div class="modal" v-click-outside="closeModal">
  <!-- Modal 內容 -->
</div>

<script setup>
function closeModal() {
  // 關閉 Modal 的邏輯
  showModal.value = false;
}
</script>

說明:指令接受一個回呼函式(binding.value),當點擊發生在元素外部時觸發,常用於 Modal、Dropdown、Tooltip 等情境。


範例 5:v-draggable – 拖曳指令(支援修飾詞)

// 全域指令:簡易拖曳
import { createApp } from 'vue';
import App from './App.vue';

const app = createApp(App);

app.directive('draggable', {
  mounted(el, binding) {
    // 允許使用修飾詞限制拖曳方向
    const axis = binding.modifiers.x ? 'x' : binding.modifiers.y ? 'y' : 'both';

    let startX, startY, initialLeft, initialTop;

    const onMouseDown = (e) => {
      e.preventDefault();
      startX = e.clientX;
      startY = e.clientY;
      const style = window.getComputedStyle(el);
      initialLeft = parseInt(style.left, 10) || 0;
      initialTop = parseInt(style.top, 10) || 0;
      document.addEventListener('mousemove', onMouseMove);
      document.addEventListener('mouseup', onMouseUp);
    };

    const onMouseMove = (e) => {
      const dx = e.clientX - startX;
      const dy = e.clientY - startY;
      if (axis === 'x' || axis === 'both') {
        el.style.left = `${initialLeft + dx}px`;
      }
      if (axis === 'y' || axis === 'both') {
        el.style.top = `${initialTop + dy}px`;
      }
    };

    const onMouseUp = () => {
      document.removeEventListener('mousemove', onMouseMove);
      document.removeEventListener('mouseup', onMouseUp);
    };

    // 必須先設定 position 為 relative 或 absolute
    if (!['relative', 'absolute', 'fixed'].includes(style.position)) {
      el.style.position = 'relative';
    }

    el.addEventListener('mousedown', onMouseDown);

    // 暫存以便於 unmounted 時移除
    el._draggable = { onMouseDown };
  },
  unmounted(el) {
    el.removeEventListener('mousedown', el._draggable.onMouseDown);
  }
});

app.mount('#app');

使用方式

<!-- 任意方向拖曳 -->
<div v-draggable style="width:100px;height:100px;background:#4caf50;"></div>

<!-- 只允許水平拖曳 -->
<div v-draggable.x style="width:100px;height:100px;background:#ff9800;"></div>

<!-- 只允許垂直拖曳 -->
<div v-draggable.y style="width:100px;height:100px;background:#2196f3;"></div>

說明:透過 binding.modifiers 判斷是否加上 .x.y,讓指令具備彈性。mounted 中完成事件綁定,unmounted 時釋放資源。


常見陷阱與最佳實踐

陷阱 說明 解決方式
忘記在 unmounted 清理 事件監聽或 IntersectionObserver 若不移除,會造成記憶體洩漏。 一定unmountedremoveEventListenerobserver.disconnect()
直接操作 Vue 狀態 在指令內直接改變組件的 data 會違背單向資料流。 透過 回呼函式binding.value)或 emit 事件讓組件自行處理狀態變更。
使用不支援的瀏覽器 API IntersectionObserver 在 IE 不支援。 在指令內加入 fallback(如直接載入圖片)或使用 polyfill。
指令內部改變樣式過於頻繁 大量 style 設定會導致重排(reflow)影響效能。 使用 requestAnimationFrameCSS class toggle,減少直接寫入 style。
指令名稱衝突 同時定義全域與局部指令,名稱相同會被局部覆蓋。 統一命名規則(如 v-app- 前綴)或只使用一種註冊方式。

最佳實踐

  1. 最小化指令功能:指令應只負責 DOM 操作,不應混入業務邏輯。
  2. 使用 binding.argbinding.modifiers:讓指令更具彈性,減少多個類似指令的重複程式碼。
  3. 保持指令純函式:盡可能在 mounted 時完成一次性設定,避免在 updated 中重複執行相同操作。
  4. 文件化:為每支自訂指令撰寫簡短說明與使用範例,方便團隊成員查閱。
  5. 測試:雖然指令本身較難單元測試,但可以透過 Jest + @vue/test-utils 測試指令在掛載與卸載時的行為。

實際應用場景

場景 為什麼適合使用自訂指令
彈窗或下拉選單的點擊外部關閉 多個元件都需要相同的「點擊外部」邏輯,指令可統一管理。
圖片懶載入 大型電商或新聞站點常有大量圖片,使用 v-lazy 減少首屏載入時間。
表單元件自動聚焦 登入、搜尋框等需要在頁面載入時立即取得焦點,提高使用者體驗。
拖曳排序列表 需要對多個列表項目加上拖曳功能,指令讓每個項目只需加上 v-draggable 即可。
自訂 Tooltip / Popover 複雜的提示資訊常出現在多處,指令可把樣式、事件與定位封裝起來。

總結

自訂指令是 Vue3 中 強大且彈性的工具,能夠將重複的 DOM 操作抽離、模組化,讓模板保持簡潔、程式碼更易維護。本文從概念、生命週期、實作範例、常見陷阱與最佳實踐,最後列出實務應用場景,提供了一套完整的學習與使用流程。

關鍵要點

  • 只在需要直接操作 DOM 時才考慮自訂指令;若能用組件解決,優先使用組件。
  • 記得在 unmounted 釋放所有資源,避免記憶體洩漏。
  • 利用 binding.valuebinding.argbinding.modifiers 提升指令的彈性與可重用性。

掌握了這些技巧後,你就能在 Vue3 專案中靈活運用自訂指令,解決各種 UI 行為需求,寫出既乾淨又高效的程式碼。祝開發順利! 🚀