技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

分享 8 个实用的 Vue 自定义指令

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/69-分享8个非常实用的Vue自定义指令
本文目录12 个章节
  1. 一、什么时候应该使用自定义指令
  2. 二、Vue 2 和 Vue 3 指令钩子
  3. 三、复制指令 v-copy
  4. 四、长按指令 v-longpress
  5. 五、防抖指令 v-debounce
  6. 六、过滤表情或输入内容 v-sanitize
  7. 七、图片懒加载 v-lazy
  8. 八、权限指令 v-permission
  9. 九、页面水印 v-watermark
  10. 十、拖拽指令 v-draggable
  11. 十一、总结
  12. 参考资料

分享 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/unmountedupdate 不是“值一定变化”的钩子,需要比较 binding.valuebinding.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

原文使用 mousedownmouseouttouchstart,容易遗漏触摸取消、指针离开和多指场景。现代浏览器可以优先使用 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 删除元素,这样当权限异步加载完成后不容易恢复;使用 hiddendisabled 更适合响应式更新。若确实需要删除节点,应同时保存注释锚点并实现恢复逻辑。

九、页面水印 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
  },
}

Canvas 水印效果示意图(已本地化)

<div v-watermark="{ text: '内部资料', color: 'rgba(180, 180, 180, .35)' }">
  页面内容
</div>

Canvas 水印只是视觉层,用户可以通过开发者工具删除背景、截图或覆盖样式,不能当作防泄漏方案。高安全场景还需要服务端生成带用户标识的水印、权限控制和审计。

十、拖拽指令 v-draggable

原文使用 document.onmousemovedocument.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 选项,应优先使用组件能力,而不是重复写指令。

十一、总结

  1. 指令适合低层 DOM 行为,复杂 UI 应使用组件/composable;
  2. Vue 2 的 bind/inserted/componentUpdated/unbind 与 Vue 3 的 mounted/updated/unmounted 不能混写;
  3. 所有事件、timer、observer 都必须在 unmounted 时清理;
  4. Clipboard API、Pointer Events、IntersectionObserver 和原生 loading="lazy" 是现代浏览器的优先方案;
  5. 防抖、节流和提交锁是不同概念;
  6. 前端权限指令只负责展示,服务端必须再次鉴权;
  7. Canvas 水印可以被移除,不能作为绝对安全措施;
  8. 输入清洗要兼顾粘贴、输入法和服务端校验,不能依赖一个错误的正则。

参考资料

原文作者:lzg9527。原文 8 个指令的使用场景和实现思路予以保留,推广内容、推荐文章、抓取格式噪声、错误事件清理和不安全的绝对化说法已清理或修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS