分享 8 个实用的 Vue 自定义指令
自定义指令适合封装低层 DOM 行为,例如复制、长按、懒加载和拖拽。它不是组件的替代品,也不是权限安全边界。原文以 Vue 2 为背景,下面保留 8 个例子的思路,并同时给出 Vue 3.5 写法和 Vue 2.7 迁移说明。
一、什么时候应该使用自定义指令
Vue 3 全局注册指令:
import { createApp } from 'vue'
import App from './App.vue'
import copy from './directives/copy'
const app = createApp(App)
app.directive('copy', copy)
app.mount('#app')
Vue 2.7:
import Vue from 'vue'
import copy from './directives/copy'
Vue.directive('copy', copy)
局部注册:
<script setup lang="ts">
const vFocus = {
mounted: (el: HTMLInputElement) => el.focus(),
}
</script>
<template>
<input v-focus />
</template>
适合指令的场景:
- 需要直接访问元素、监听底层事件或调用浏览器 API;
- 行为不值得抽成完整组件;
- 需要在绑定、更新和卸载时统一管理资源。
如果一个功能有复杂模板、多个状态、键盘无障碍语义和插槽,优先使用组件或 composable。指令中注册的每一个事件、observer、timer 都必须在卸载时清理。
二、Vue 2 和 Vue 3 指令钩子
Vue 2 常用钩子:
bind:第一次绑定时;inserted:绑定元素插入父节点后;update:所在组件更新时,不保证绑定值变化;componentUpdated:组件及子节点更新完成后;unbind:解绑时。
Vue 3 钩子名称与元素生命周期更接近:
created;beforeMount;mounted;beforeUpdate;updated;beforeUnmount;unmounted。
例如,Vue 2 的 bind/inserted/componentUpdated/unbind 通常迁移为 Vue 3 的 beforeMount/mounted/updated/unmounted。update 不是“值一定变化”的钩子,需要比较 binding.value 和 binding.oldValue,或者使用 updated。
下文示例统一使用 Vue 3 写法;Vue 2 只需把钩子名和全局注册方式换回旧 API,并注意 Vue 2 的 directive 参数签名。
三、复制指令 v-copy
1. 现代实现
优先使用 Clipboard API。它通常要求安全上下文(HTTPS 或 localhost)和用户手势;不支持或失败时再使用兼容性 fallback。document.execCommand('copy') 已废弃,不应作为唯一方案。
// directives/copy.ts
const states = new WeakMap<HTMLElement, {
value: string
handler: () => void
}>()
function fallbackCopy(text: string) {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.readOnly = true
textarea.style.position = 'fixed'
textarea.style.top = '-9999px'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
const success = document.execCommand('copy')
textarea.remove()
return success
}
async function copyText(text: string) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return true
}
return fallbackCopy(text)
}
export default {
mounted(el: HTMLElement, binding: { value: unknown }) {
const state = {
value: String(binding.value ?? ''),
handler: async () => {
if (!state.value) return
try {
const success = await copyText(state.value)
if (!success) console.warn('复制失败')
} catch (error) {
console.error('复制失败', error)
}
},
}
states.set(el, state)
el.addEventListener('click', state.handler)
},
updated(el: HTMLElement, binding: { value: unknown }) {
const state = states.get(el)
if (state) state.value = String(binding.value ?? '')
},
unmounted(el: HTMLElement) {
const state = states.get(el)
if (!state) return
el.removeEventListener('click', state.handler)
states.delete(el)
},
}
示例:
<button v-copy="copyText">复制</button>
__copyValue、__copyHandler 只是教学代码中挂在元素上的自定义字段,正式 TypeScript 项目应扩展 HTMLElement 类型,或使用 WeakMap<HTMLElement, State> 保存状态。
四、长按指令 v-longpress
原文使用 mousedown、mouseout 和 touchstart,容易遗漏触摸取消、指针离开和多指场景。现代浏览器可以优先使用 Pointer Events:
// directives/longpress.ts
const states = new WeakMap<HTMLElement, {
timer: number | undefined
fired: boolean
pointerId?: number
}>()
export default {
mounted(el: HTMLElement, binding: { value: ((event: PointerEvent) => void) | undefined }) {
if (typeof binding.value !== 'function') {
throw new TypeError('v-longpress 的值必须是函数')
}
const state = {
timer: undefined as number | undefined,
fired: false,
pointerId: undefined as number | undefined,
}
states.set(el, state)
el.style.touchAction = 'manipulation'
const start = (event: PointerEvent) => {
if (event.pointerType === 'mouse' && event.button !== 0) return
state.fired = false
state.pointerId = event.pointerId
el.setPointerCapture?.(event.pointerId)
state.timer = window.setTimeout(() => {
state.timer = undefined
state.fired = true
binding.value?.(event)
}, 600)
}
const cancel = () => {
if (state.timer !== undefined) {
window.clearTimeout(state.timer)
state.timer = undefined
}
}
const end = (event: PointerEvent) => {
cancel()
if (state.fired) {
event.preventDefault()
}
}
el.addEventListener('pointerdown', start)
el.addEventListener('pointerup', end)
el.addEventListener('pointercancel', cancel)
el.addEventListener('pointerleave', cancel)
Object.assign(state, { start, end, cancel })
},
unmounted(el: HTMLElement) {
const state = states.get(el) as any
if (!state) return
state.cancel()
el.removeEventListener('pointerdown', state.start)
el.removeEventListener('pointerup', state.end)
el.removeEventListener('pointercancel', state.cancel)
el.removeEventListener('pointerleave', state.cancel)
states.delete(el)
},
}
使用:
<button v-longpress="handleLongpress">长按</button>
这是一个教学版本。实际项目还应考虑长按触发后是否阻止 click、键盘可访问性、移动端滚动冲突和回调更新。对按钮来说,键盘 Enter/Space 也应提供等价操作,不能只依赖指针事件。
五、防抖指令 v-debounce
原文的实现是“延迟执行的 trailing debounce”,不是“规定时间内只能点击一次”。两者需要区分:
- debounce:连续触发时不断推迟,停止触发一段时间后执行;
- throttle:时间窗口内最多执行一次;
- 提交锁:请求进行中禁用按钮,并由服务端幂等兜底。
// directives/debounce.ts
const states = new WeakMap<HTMLElement, {
timer?: number
handler: (event: MouseEvent) => void
}>()
export default {
mounted(el: HTMLElement, binding: {
value: ((event: MouseEvent) => void) | { handler: (event: MouseEvent) => void, delay?: number }
}) {
const options = typeof binding.value === 'function'
? { handler: binding.value, delay: 300 }
: binding.value
if (!options || typeof options.handler !== 'function') {
throw new TypeError('v-debounce 需要函数或 { handler, delay }')
}
const delay = options.delay ?? 300
const state = {
timer: undefined as number | undefined,
handler: (event: MouseEvent) => {
if (state.timer !== undefined) window.clearTimeout(state.timer)
state.timer = window.setTimeout(() => {
state.timer = undefined
options.handler(event)
}, delay)
},
}
states.set(el, state)
el.addEventListener('click', state.handler)
},
unmounted(el: HTMLElement) {
const state = states.get(el)
if (!state) return
if (state.timer !== undefined) window.clearTimeout(state.timer)
el.removeEventListener('click', state.handler)
states.delete(el)
},
}
<button v-debounce="{ handler: save, delay: 500 }">保存</button>
表单提交不能只依赖前端防抖:请求重试、网络重复提交和多个客户端仍可能产生重复数据,后端应使用幂等键或业务唯一约束。
六、过滤表情或输入内容 v-sanitize
原文的正则包含 \a、多余的 | 和不严谨的字符范围,而且只监听 keyup,无法覆盖粘贴、输入法和移动端输入。指令更适合提供一个可配置的清洗函数:
// directives/sanitize.ts
const emojiPattern = /[\p{Extended_Pictographic}\p{Emoji_Presentation}]/gu
const handlers = new WeakMap<HTMLInputElement, (event: Event) => void>()
export default {
mounted(el: HTMLInputElement, binding: {
value?: { pattern?: RegExp, sanitize?: (value: string) => string }
}) {
const sanitize = binding.value?.sanitize
?? ((value: string) => value.replace(binding.value?.pattern ?? emojiPattern, ''))
const input = () => {
const nextValue = sanitize(el.value)
if (nextValue === el.value) return
const start = el.selectionStart ?? nextValue.length
el.value = nextValue
el.setSelectionRange(
Math.min(start, nextValue.length),
Math.min(start, nextValue.length),
)
el.dispatchEvent(new Event('input', { bubbles: true }))
}
handlers.set(el, input)
el.addEventListener('input', input)
},
unmounted(el: HTMLInputElement) {
const input = handlers.get(el)
if (!input) return
el.removeEventListener('input', input)
handlers.delete(el)
},
}
使用:
<input v-model="note" v-sanitize />
\p{...} Unicode 属性转义需要现代浏览器支持;如果项目需要兼容旧浏览器,应在构建阶段使用可靠的兼容方案,或传入业务自己的 sanitize 函数。输入过滤不能替代服务端校验,也不能把“特殊字符”定义成一个适用于所有语言的固定正则。
七、图片懒加载 v-lazy
现代浏览器原生支持 loading="lazy" 的场景应优先使用它:
<img src="placeholder.png" data-src="real-image.jpg" loading="lazy" alt="示例图片">
需要自定义占位图、加载回调或兼容旧环境时,可以使用 IntersectionObserver。原文中直接使用 if (IntersectionObserver) 可能在不支持的浏览器中抛出 ReferenceError,应通过 window.IntersectionObserver 判断;同时必须保存并清理 observer 和 scroll listener。
// directives/lazy.ts
const states = new WeakMap<HTMLElement, {
observer?: IntersectionObserver
onScroll?: () => void
}>()
function loadImage(el: HTMLImageElement) {
const source = el.dataset.src
if (!source) return
el.src = source
delete el.dataset.src
}
export default {
mounted(el: HTMLImageElement, binding: { value: string }) {
el.dataset.src = binding.value
el.src = el.getAttribute('src') || ''
const state: { observer?: IntersectionObserver, onScroll?: () => void } = {}
if ('IntersectionObserver' in window) {
state.observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) {
loadImage(el)
state.observer?.disconnect()
}
}, { rootMargin: '200px' })
state.observer.observe(el)
} else {
const check = () => {
const rect = el.getBoundingClientRect()
if (rect.top < window.innerHeight && rect.bottom > 0) {
loadImage(el)
window.removeEventListener('scroll', check)
}
}
state.onScroll = check
window.addEventListener('scroll', check, { passive: true })
check()
}
states.set(el, state)
},
unmounted(el: HTMLImageElement) {
const state = states.get(el)
state?.observer?.disconnect()
if (state?.onScroll) window.removeEventListener('scroll', state.onScroll)
states.delete(el)
},
}
使用:
<img v-lazy="imageUrl" src="/images/placeholder.png" alt="文章配图">
八、权限指令 v-permission
按钮权限指令只能改善前端展示,不能保护 API。真正的权限判断必须在服务端完成;隐藏按钮也不能阻止用户直接调用接口。
// directives/permission.ts
function hasPermission(value: unknown, permissions: Set<string>) {
const required = Array.isArray(value) ? value : [value]
return required.some(item => permissions.has(String(item)))
}
export function createPermissionDirective(getPermissions: () => Set<string>) {
return {
mounted(el: HTMLElement, binding: { value: unknown }) {
update(el, binding.value)
},
updated(el: HTMLElement, binding: { value: unknown }) {
update(el, binding.value)
},
}
function update(el: HTMLElement, value: unknown) {
const allowed = hasPermission(value, getPermissions())
el.hidden = !allowed
el.setAttribute('aria-hidden', String(!allowed))
}
}
注册:
app.directive('permission', createPermissionDirective(() => authStore.permissions))
使用:
<button v-permission="'user:create'">新建用户</button>
<button v-permission="['user:edit', 'admin']">编辑</button>
原文在无权限时直接从 DOM 删除元素,这样当权限异步加载完成后不容易恢复;使用 hidden 或 disabled 更适合响应式更新。若确实需要删除节点,应同时保存注释锚点并实现恢复逻辑。
九、页面水印 v-watermark
水印可以使用 Canvas 生成重复背景:
// directives/watermark.ts
function createWatermark(text: string, color = 'rgba(0, 0, 0, .12)') {
const canvas = document.createElement('canvas')
canvas.width = 240
canvas.height = 160
const context = canvas.getContext('2d')
if (!context) return ''
context.rotate((-20 * Math.PI) / 180)
context.font = '16px sans-serif'
context.fillStyle = color
context.textAlign = 'left'
context.textBaseline = 'middle'
context.fillText(text, 20, 100)
return `url(${canvas.toDataURL('image/png')})`
}
export default {
mounted(el: HTMLElement, binding: { value: string | { text: string, color?: string } }) {
const value = typeof binding.value === 'string'
? { text: binding.value }
: binding.value
el.style.backgroundImage = createWatermark(value.text, value.color)
el.dataset.watermark = 'true'
},
updated(el: HTMLElement, binding: { value: string | { text: string, color?: string } }) {
const value = typeof binding.value === 'string'
? { text: binding.value }
: binding.value
el.style.backgroundImage = createWatermark(value.text, value.color)
},
unmounted(el: HTMLElement) {
el.style.backgroundImage = ''
delete el.dataset.watermark
},
}

<div v-watermark="{ text: '内部资料', color: 'rgba(180, 180, 180, .35)' }">
页面内容
</div>
Canvas 水印只是视觉层,用户可以通过开发者工具删除背景、截图或覆盖样式,不能当作防泄漏方案。高安全场景还需要服务端生成带用户标识的水印、权限控制和审计。
十、拖拽指令 v-draggable
原文使用 document.onmousemove 和 document.onmouseup,多个元素同时拖拽时会互相覆盖处理器,且触摸设备体验较差。可以使用 Pointer Events,并把监听器绑定在 document 后在卸载/结束时移除:
// directives/draggable.ts
const states = new WeakMap<HTMLElement, {
move: (event: PointerEvent) => void
up: (event: PointerEvent) => void
}>()
export default {
mounted(el: HTMLElement) {
const parent = el.offsetParent as HTMLElement | null
if (!parent) return
if (getComputedStyle(parent).position === 'static') {
parent.style.position = 'relative'
}
if (getComputedStyle(el).position === 'static') {
el.style.position = 'absolute'
}
let startX = 0
let startY = 0
let startLeft = 0
let startTop = 0
const move = (event: PointerEvent) => {
const maxLeft = Math.max(0, parent.clientWidth - el.offsetWidth)
const maxTop = Math.max(0, parent.clientHeight - el.offsetHeight)
const left = Math.min(maxLeft, Math.max(0, startLeft + event.clientX - startX))
const top = Math.min(maxTop, Math.max(0, startTop + event.clientY - startY))
el.style.left = `${left}px`
el.style.top = `${top}px`
}
const up = (event: PointerEvent) => {
document.removeEventListener('pointermove', move)
document.removeEventListener('pointerup', up)
el.releasePointerCapture?.(event.pointerId)
}
const down = (event: PointerEvent) => {
if (event.pointerType === 'mouse' && event.button !== 0) return
const rect = el.getBoundingClientRect()
startX = event.clientX
startY = event.clientY
startLeft = rect.left - parent.getBoundingClientRect().left + parent.scrollLeft
startTop = rect.top - parent.getBoundingClientRect().top + parent.scrollTop
el.setPointerCapture?.(event.pointerId)
document.addEventListener('pointermove', move)
document.addEventListener('pointerup', up, { once: true })
event.preventDefault()
}
states.set(el, { move, up })
el.style.cursor = 'move'
el.style.touchAction = 'none'
el.addEventListener('pointerdown', down)
;(el as any).__draggableDown = down
},
unmounted(el: HTMLElement) {
const state = states.get(el)
const down = (el as any).__draggableDown
if (down) el.removeEventListener('pointerdown', down)
if (state) {
document.removeEventListener('pointermove', state.move)
document.removeEventListener('pointerup', state.up)
}
states.delete(el)
},
}
使用:
<div class="dialog-wrapper">
<div v-draggable class="dialog">可拖拽内容</div>
</div>
真实项目还要考虑滚动容器、RTL、缩放、键盘可访问性、拖拽手柄和边界计算。Element Plus Dialog 等组件如果已经提供可靠的 draggable 选项,应优先使用组件能力,而不是重复写指令。
十一、总结
- 指令适合低层 DOM 行为,复杂 UI 应使用组件/composable;
- Vue 2 的
bind/inserted/componentUpdated/unbind与 Vue 3 的 mounted/updated/unmounted 不能混写; - 所有事件、timer、observer 都必须在 unmounted 时清理;
- Clipboard API、Pointer Events、IntersectionObserver 和原生
loading="lazy"是现代浏览器的优先方案; - 防抖、节流和提交锁是不同概念;
- 前端权限指令只负责展示,服务端必须再次鉴权;
- Canvas 水印可以被移除,不能作为绝对安全措施;
- 输入清洗要兼顾粘贴、输入法和服务端校验,不能依赖一个错误的正则。
参考资料
- Vue 3 自定义指令
- Vue 2 自定义指令
- Vue 3 自定义指令迁移
- MDN Clipboard.writeText
- MDN Pointer Events
- MDN IntersectionObserver
- MDN lazy loading
原文作者:lzg9527。原文 8 个指令的使用场景和实现思路予以保留,推广内容、推荐文章、抓取格式噪声、错误事件清理和不安全的绝对化说法已清理或修正。