Vue 自訂指令
除了 Vue 內建的指令(如 v-model、v-show),Vue 也允許你建立自訂指令(Custom Directives)。自訂指令主要用於需要對 DOM 元素進行底層操作的場景。
基本用法
在 <script setup> 中定義
以 v 開頭的駝峰命名變數會自動成為指令:
<script setup>
// 自動聚焦指令
const vFocus = {
mounted: (el) => el.focus()
}
</script>
<template>
<!-- 使用時用 kebab-case -->
<input v-focus>
</template>
全域註冊
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 全域註冊指令
app.directive('focus', {
mounted: (el) => el.focus()
})
app.mount('#app')
指令 Hooks
指令可以定義多個生命週期 hooks:
const myDirective = {
// 元素被插入 DOM 前呼叫
created(el, binding, vnode, prevVnode) {},
// 元素被插入 DOM 時呼叫
mounted(el, binding, vnode, prevVnode) {},
// 父元件更新前呼叫
beforeUpdate(el, binding, vnode, prevVnode) {},
// 父元件及子元件都更新後呼叫
updated(el, binding, vnode, prevVnode) {},
// 元素被移除前呼叫
beforeUnmount(el, binding, vnode, prevVnode) {},
// 元素被移除時呼叫
unmounted(el, binding, vnode, prevVnode) {}
}
Hook 參數
- el:指令綁定的 DOM 元素
- binding:包含指令資訊的物件
value:指令的值,如v-my-directive="1 + 1"中的2oldValue:之前的值(只在beforeUpdate和updated中可用)arg:指令的參數,如v-my-directive:foo中的"foo"modifiers:修飾符物件,如v-my-directive.foo.bar中的{ foo: true, bar: true }instance:使用該指令的元件實例dir:指令的定義物件
- vnode:代表綁定元素的 VNode
- prevVnode:之前的 VNode(只在
beforeUpdate和updated中可用)
簡寫形式
如果指令只需要在 mounted 和 updated 時執行相同邏輯,可以簡寫為函式:
<script setup>
const vColor = (el, binding) => {
el.style.color = binding.value
}
</script>
<template>
<p v-color="'red'">紅色文字</p>
<p v-color="textColor">動態顏色</p>
</template>
指令參數和修飾符
<script setup>
const vDemo = {
mounted(el, binding) {
console.log('值:', binding.value) // 表達式的值
console.log('參數:', binding.arg) // 冒號後的參數
console.log('修飾符:', binding.modifiers) // 點後的修飾符
}
}
</script>
<template>
<!-- v-demo:參數.修飾符="值" -->
<div v-demo:foo.bar.baz="123">Test</div>
<!--
值: 123
參數: 'foo'
修飾符: { bar: true, baz: true }
-->
</template>
動態參數
<script setup>
import { ref } from 'vue'
const direction = ref('left')
const vPin = {
mounted(el, binding) {
el.style.position = 'fixed'
el.style[binding.arg || 'top'] = binding.value + 'px'
},
updated(el, binding) {
el.style[binding.arg || 'top'] = binding.value + 'px'
}
}
</script>
<template>
<!-- 動態參數 -->
<div v-pin:[direction]="200">固定在左邊 200px</div>
</template>
實用範例
點擊外部關閉
<script setup>
const vClickOutside = {
mounted(el, binding) {
el._clickOutside = (event) => {
if (!el.contains(event.target)) {
binding.value(event)
}
}
document.addEventListener('click', el._clickOutside)
},
unmounted(el) {
document.removeEventListener('click', el._clickOutside)
delete el._clickOutside
}
}
</script>
<template>
<div v-click-outside="closeDropdown" class="dropdown">
<button @click="isOpen = !isOpen">選單</button>
<ul v-show="isOpen">
<li>選項 1</li>
<li>選項 2</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue'
const isOpen = ref(false)
function closeDropdown() {
isOpen.value = false
}
</script>
工具提示(Tooltip)
<script setup>
const vTooltip = {
mounted(el, binding) {
const tooltip = document.createElement('div')
tooltip.className = 'tooltip'
tooltip.textContent = binding.value
tooltip.style.cssText = `
position: absolute;
background: #333;
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
display: none;
z-index: 1000;
`
document.body.appendChild(tooltip)
el._tooltip = tooltip
el._showTooltip = () => {
const rect = el.getBoundingClientRect()
tooltip.style.display = 'block'
tooltip.style.top = rect.top - tooltip.offsetHeight - 5 + 'px'
tooltip.style.left = rect.left + (rect.width - tooltip.offsetWidth) / 2 + 'px'
}
el._hideTooltip = () => {
tooltip.style.display = 'none'
}
el.addEventListener('mouseenter', el._showTooltip)
el.addEventListener('mouseleave', el._hideTooltip)
},
updated(el, binding) {
if (el._tooltip) {
el._tooltip.textContent = binding.value
}
},
unmounted(el) {
el.removeEventListener('mouseenter', el._showTooltip)
el.removeEventListener('mouseleave', el._hideTooltip)
if (el._tooltip) {
el._tooltip.remove()
}
}
}
</script>
<template>
<button v-tooltip="'點擊送出'">送出</button>
</template>
圖片懶載入
<script setup>
const vLazyLoad = {
mounted(el, binding) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
el.src = binding.value
observer.unobserve(el)
}
})
},
{ rootMargin: '50px' }
)
// 設定佔位圖
el.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
el._observer = observer
observer.observe(el)
},
unmounted(el) {
if (el._observer) {
el._observer.disconnect()
}
}
}
</script>
<template>
<img v-lazy-load="'/path/to/image.jpg'" alt="懶載入圖片">
</template>
防抖(Debounce)
<script setup>
const vDebounce = {
mounted(el, binding) {
let timer = null
const delay = binding.arg ? parseInt(binding.arg) : 300
el._debounce = (event) => {
clearTimeout(timer)
timer = setTimeout(() => {
binding.value(event)
}, delay)
}
el.addEventListener('input', el._debounce)
},
unmounted(el) {
el.removeEventListener('input', el._debounce)
}
}
</script>
<template>
<!-- 500ms 防抖 -->
<input v-debounce:500="handleSearch" placeholder="搜尋...">
</template>
<script setup>
function handleSearch(event) {
console.log('搜尋:', event.target.value)
}
</script>
權限控制
<script setup>
import { ref } from 'vue'
// 假設這是從 store 或 API 取得的使用者權限
const userPermissions = ref(['read', 'write'])
const vPermission = {
mounted(el, binding) {
const permission = binding.value
const hasPermission = userPermissions.value.includes(permission)
if (!hasPermission) {
// 移除元素或顯示提示
el.style.display = 'none'
// 或者 el.remove()
}
}
}
</script>
<template>
<button v-permission="'read'">檢視</button>
<button v-permission="'write'">編輯</button>
<button v-permission="'admin'">刪除</button> <!-- 會被隱藏 -->
</template>
複製到剪貼簿
<script setup>
const vCopy = {
mounted(el, binding) {
el._copy = async () => {
try {
await navigator.clipboard.writeText(binding.value)
// 顯示成功提示
el.classList.add('copied')
setTimeout(() => el.classList.remove('copied'), 1500)
} catch (err) {
console.error('複製失敗:', err)
}
}
el.addEventListener('click', el._copy)
el.style.cursor = 'pointer'
},
updated(el, binding) {
// 更新要複製的值
},
unmounted(el) {
el.removeEventListener('click', el._copy)
}
}
</script>
<template>
<code v-copy="codeSnippet">{{ codeSnippet }}</code>
</template>
<style>
code.copied::after {
content: ' ✓ 已複製';
color: green;
}
</style>
拖曳
<script setup>
const vDraggable = {
mounted(el) {
el.style.position = 'absolute'
el.style.cursor = 'move'
let startX, startY, initialX, initialY
function onMouseDown(e) {
startX = e.clientX
startY = e.clientY
initialX = el.offsetLeft
initialY = el.offsetTop
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
function onMouseMove(e) {
const dx = e.clientX - startX
const dy = e.clientY - startY
el.style.left = initialX + dx + 'px'
el.style.top = initialY + dy + 'px'
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
el.addEventListener('mousedown', onMouseDown)
el._cleanup = () => {
el.removeEventListener('mousedown', onMouseDown)
}
},
unmounted(el) {
if (el._cleanup) el._cleanup()
}
}
</script>
<template>
<div v-draggable class="draggable-box">
拖曳我
</div>
</template>
<style>
.draggable-box {
padding: 20px;
background: #42b883;
color: white;
border-radius: 8px;
}
</style>
在元件上使用
指令也可以用在元件上,會作用於元件的根元素:
<template>
<!-- 指令會作用於 MyComponent 的根元素 -->
<MyComponent v-focus />
</template>
如果元件有多個根元素,指令會被忽略並發出警告。不建議在元件上使用指令。
最佳實踐
- 不要過度使用:大多數情況下,使用元件或 composables 更好
- 適合底層 DOM 操作:如聚焦、事件監聽、第三方函式庫整合
- 記得清理:在
unmounted中移除事件監聽器和定時器 - 保持簡單:複雜邏輯應該放在元件或 composables 中