Vue3 教學:自訂指令(Custom Directive)
簡介
在 Vue3 中,**指令(Directive)**是 Vue 為了在模板(template)裡直接操作 DOM 而提供的特殊語法。雖然 Vue 已經內建了如 v-if、v-show、v-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) |
同上 | 卸載完成,釋放資源、移除事件監聽等。 |
小技巧:若只需要在掛載與卸載時做事,僅實作
mounted與unmounted即可,省去其他不必要的鉤子。
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.value、binding.arg、binding.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 若不移除,會造成記憶體洩漏。 |
一定在 unmounted 中 removeEventListener、observer.disconnect()。 |
| 直接操作 Vue 狀態 | 在指令內直接改變組件的 data 會違背單向資料流。 |
透過 回呼函式(binding.value)或 emit 事件讓組件自行處理狀態變更。 |
| 使用不支援的瀏覽器 API | 如 IntersectionObserver 在 IE 不支援。 |
在指令內加入 fallback(如直接載入圖片)或使用 polyfill。 |
| 指令內部改變樣式過於頻繁 | 大量 style 設定會導致重排(reflow)影響效能。 |
使用 requestAnimationFrame 或 CSS class toggle,減少直接寫入 style。 |
| 指令名稱衝突 | 同時定義全域與局部指令,名稱相同會被局部覆蓋。 | 統一命名規則(如 v-app- 前綴)或只使用一種註冊方式。 |
最佳實踐
- 最小化指令功能:指令應只負責 DOM 操作,不應混入業務邏輯。
- 使用
binding.arg與binding.modifiers:讓指令更具彈性,減少多個類似指令的重複程式碼。 - 保持指令純函式:盡可能在
mounted時完成一次性設定,避免在updated中重複執行相同操作。 - 文件化:為每支自訂指令撰寫簡短說明與使用範例,方便團隊成員查閱。
- 測試:雖然指令本身較難單元測試,但可以透過 Jest + @vue/test-utils 測試指令在掛載與卸載時的行為。
實際應用場景
| 場景 | 為什麼適合使用自訂指令 |
|---|---|
| 彈窗或下拉選單的點擊外部關閉 | 多個元件都需要相同的「點擊外部」邏輯,指令可統一管理。 |
| 圖片懶載入 | 大型電商或新聞站點常有大量圖片,使用 v-lazy 減少首屏載入時間。 |
| 表單元件自動聚焦 | 登入、搜尋框等需要在頁面載入時立即取得焦點,提高使用者體驗。 |
| 拖曳排序列表 | 需要對多個列表項目加上拖曳功能,指令讓每個項目只需加上 v-draggable 即可。 |
| 自訂 Tooltip / Popover | 複雜的提示資訊常出現在多處,指令可把樣式、事件與定位封裝起來。 |
總結
自訂指令是 Vue3 中 強大且彈性的工具,能夠將重複的 DOM 操作抽離、模組化,讓模板保持簡潔、程式碼更易維護。本文從概念、生命週期、實作範例、常見陷阱與最佳實踐,最後列出實務應用場景,提供了一套完整的學習與使用流程。
關鍵要點
- 只在需要直接操作 DOM 時才考慮自訂指令;若能用組件解決,優先使用組件。
- 記得在
unmounted釋放所有資源,避免記憶體洩漏。- 利用
binding.value、binding.arg、binding.modifiers提升指令的彈性與可重用性。
掌握了這些技巧後,你就能在 Vue3 專案中靈活運用自訂指令,解決各種 UI 行為需求,寫出既乾淨又高效的程式碼。祝開發順利! 🚀