Vue3自定义指令

Vue 3 自定义指令完全指南

本文档从零开始讲解 Vue 3 的自定义指令:什么是自定义指令、为什么需要、有哪些钩子、如何注册和使用、如何传参和用修饰符,以及多个完整示例(聚焦、颜色、权限、加载、防抖、点击外部、复制、长按等),适合新手系统学习。


一、什么是“自定义指令”?

1.1 回顾:内置指令

你已经用过 Vue 的内置指令v-ifv-forv-modelv-show 等。
它们都是“以 v- 开头的特殊属性”,用来直接操作 DOM 或绑定行为
例如 v-show 会改元素的 displayv-model 会绑定 value 和 input 事件。

1.2 自定义指令是什么?

自定义指令就是你自己定义的、以 v- 开头的指令
当 Vue 在模板里遇到你写的 v-xxx 时,会在特定时机(挂载、更新、卸载等)调用你提供的函数,你可以在这些函数里直接操作 DOM(改样式、聚焦、加事件监听等)。

1.3 什么时候用自定义指令?

  • 需要反复对 DOM 做同一类操作(如自动聚焦、高亮、权限隐藏、复制到剪贴板),又不想在每个组件里重复写逻辑时。
  • 操作是“针对这个元素本身”的(改这个 input 的 focus、这个 div 的颜色),而不是“渲染一块子结构”;后者更适合用组件

简单记:

  • 改结构、复用一块 UI → 用组件
  • 改某个已有元素的 DOM 行为/样式 → 用自定义指令更合适。

二、指令的“钩子”是什么?

Vue 会在元素的不同阶段调用你提供的函数,这些函数叫做钩子
你只要在“需要的时候”写对应钩子即可,不必全部写满。

钩子名 调用时机
created 元素创建后、挂载到 DOM 前(此时还拿不到父节点)
beforeMount 元素即将挂载到 DOM 前
mounted 元素已经挂载到 DOM 后(最常用:操作 DOM、加事件监听)
beforeUpdate 所在组件状态变化、即将重新渲染前
updated 重新渲染完成后(常用:依赖新数据再改 DOM)
beforeUnmount 元素即将从 DOM 移除前
unmounted 元素已从 DOM 移除后(常用:解绑事件、清定时器)

新手最常用:

  • mounted:第一次把元素放到页面上时执行,适合聚焦、设颜色、加监听。
  • updated:指令绑定的值变了,组件更新完后执行,适合根据新值再改 DOM。
  • unmounted:元素被移除时执行,适合清理。

三、钩子函数里能拿到什么?

每个钩子都会收到这几个参数:

(el, binding, vnode, prevVnode) => { }
  • el:指令绑定的 DOM 元素,可以直接操作(如 el.focus()el.style.color = ...)。
  • binding:一个对象,包含指令的值、参数、修饰符等(见下一节)。
  • vnode:当前元素的 VNode(Vue 内部用,一般少改)。
  • prevVnode:上一次的 VNode(只在 beforeUpdateupdated 里有)。

下面重点说 binding

3.1 binding 对象里有什么?

假设模板里这样写:

<div v-my-dir:arg.modifier="value"></div>

binding 里大致有:

属性 含义 上例中
value 等号右边的值(即指令的“值”) 就是 value 这个变量
oldValue 上一次的值(只在 update 相关钩子里有) 更新前的值
arg 冒号后面的“参数” 'arg'
modifiers 点后面的修饰符组成的对象 { modifier: true }
instance 当前组件实例 当前组件的 proxy
dir 指令定义对象本身 你写的那个 directive 对象

简单示例:

<p v-color="'red'">红色文字</p>
<p v-color:bg="'blue'">蓝色背景</p>

在指令里:

  • binding.value'red''blue'
  • 第二个有 argbinding.arg === 'bg'

四、如何定义和注册指令?

4.1 全局注册(整个应用都能用)

main.js(或创建 app 的地方)里:

import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

app.directive('focus', {
  mounted(el) {
    el.focus()
  }
})

app.mount('#app')

之后在任意组件的模板里都可以写 v-focus

4.2 局部注册(只在当前组件用)

在组件的 里,定义一个以 v 开头的变量,值为指令对象或函数:

<template>
  <input v-focus />
</template>

<script setup>
const vFocus = {
  mounted(el) {
    el.focus()
  }
}
</script>

变量名 vFocus 对应模板里的 v-focus(驼峰对应短横线)。

4.3 简写:只关心 mounted 和 updated

若你只在挂载更新时做同一件事,可以传一个函数,Vue 会把它同时当作 mountedupdated 的钩子:

app.directive('color', (el, binding) => {
  el.style.color = binding.value
})

等价于:

app.directive('color', {
  mounted(el, binding) {
    el.style.color = binding.value
  },
  updated(el, binding) {
    el.style.color = binding.value
  }
})

五、基础示例

5.1 v-focus:自动聚焦

需求: 页面打开或某个输入框显示时,自动获得焦点。

// main.js 或组件内
app.directive('focus', {
  mounted(el) {
    el.focus()
  }
})
<input v-focus placeholder="自动聚焦" />

注意: 若元素是 v-if 控制的,可能要在 nextTick 里再 focus,确保已经插入 DOM:

mounted(el) {
  setTimeout(() => el.focus(), 0)
  // 或
  // import { nextTick } from 'vue'
  // nextTick(() => el.focus())
}

5.2 v-color:根据值设置文字颜色

需求: 传一个颜色名或色值,把元素的文字颜色设为该值。

app.directive('color', {
  mounted(el, binding) {
    el.style.color = binding.value
  },
  updated(el, binding) {
    el.style.color = binding.value
  }
})
<p v-color="'red'">红色</p>
<p v-color="colorVar">动态颜色</p>

若希望支持“文字颜色”和“背景颜色”,可以用 参数 arg:

app.directive('color', {
  mounted(el, binding) {
    if (binding.arg === 'bg') {
      el.style.backgroundColor = binding.value
    } else {
      el.style.color = binding.value
    }
  },
  updated(el, binding) {
    if (binding.arg === 'bg') {
      el.style.backgroundColor = binding.value
    } else {
      el.style.color = binding.value
    }
  }
})
<p v-color="'blue'">蓝色文字</p>
<p v-color:bg="'lightblue'">浅蓝背景</p>

5.3 v-color 简写形式(函数写法)

app.directive('color', (el, binding) => {
  if (binding.arg === 'bg') {
    el.style.backgroundColor = binding.value
  } else {
    el.style.color = binding.value
  }
})

六、进阶示例

6.1 v-permission:根据权限显示/隐藏

需求: 传一个权限码,用户没有该权限时,把元素隐藏或移除。

// 假设从某处拿到当前用户权限列表
function getPermissions() {
  return ['user:view', 'user:edit']
}

app.directive('permission', {
  mounted(el, binding) {
    const permissions = getPermissions()
    const need = binding.value
    if (!permissions.includes(need)) {
      el.style.display = 'none'
      // 或直接移除:el.parentNode?.removeChild(el)
    }
  }
})
<button v-permission="'user:edit'">编辑</button>

实际项目中 getPermissions 可能从 store、composable 或 inject 里拿。

6.2 v-loading:加载中遮罩

需求: 传 true/false,为 true 时在元素上盖一层“加载中”的遮罩。

app.directive('loading', {
  mounted(el, binding) {
    if (!binding.value) return
    const mask = document.createElement('div')
    mask.className = 'v-loading-mask'
    mask.innerHTML = '<span>加载中...</span>'
    mask.style.cssText = `
      position: absolute; inset: 0; background: rgba(255,255,255,0.8);
      display: flex; align-items: center; justify-content: center;
    `
    el.style.position = 'relative'
    el.appendChild(mask)
    el._loadingMask = mask
  },
  updated(el, binding) {
    const mask = el._loadingMask
    if (!mask) return
    if (binding.value) {
      if (!el.contains(mask)) el.appendChild(mask)
      mask.style.display = 'flex'
    } else {
      mask.style.display = 'none'
    }
  },
  unmounted(el) {
    el._loadingMask?.remove()
  }
})
<div v-loading="loading" style="height: 200px;">
  内容区域
</div>
const loading = ref(true)

这里用 el._loadingMask 存了创建的 DOM,在 updated/unmounted 里复用或移除,避免重复创建。

6.3 v-debounce:防抖点击

需求: 点击后不要立即执行,而是等一段时间内没有再次点击才执行(防抖)。

app.directive('debounce', {
  mounted(el, binding) {
    const delay = binding.value ?? 300
    let timer = null
    el._debounceHandler = (e) => {
      clearTimeout(timer)
      timer = setTimeout(() => {
        binding.instance?.$emit?.('click', e)
        if (typeof binding.value === 'function') {
          binding.value(e)
        }
      }, delay)
    }
    el.addEventListener('click', el._debounceHandler)
  },
  unmounted(el) {
    el.removeEventListener('click', el._debounceHandler)
  }
})

更简单的一种是:指令只负责“节流/防抖”包装用户传的函数,例如传一个函数作为 value,在指令里对调用做防抖(上面示例混合了事件和函数,仅作思路参考)。
实际更推荐在组件里用 @click + 自己封装的 useDebounceFn 或工具函数,指令版可在“只想写 v-debounce 不改模板逻辑”时使用。

6.4 v-click-outside:点击元素外部时触发

需求: 用户点击到元素外面时,执行一个回调(如关闭下拉、关闭弹窗)。

app.directive('click-outside', {
  mounted(el, binding) {
    el._clickOutside = (e) => {
      if (!el.contains(e.target)) {
        binding.value?.(e)
      }
    }
    document.addEventListener('click', el._clickOutside)
  },
  unmounted(el) {
    document.removeEventListener('click', el._clickOutside)
  }
})
<div v-click-outside="close">点击我外部会触发 close</div>

close 在组件里定义为一个函数。
unmounted 里一定要移除监听,避免内存泄漏和误触发。

6.5 v-copy:点击复制到剪贴板

需求: 点击元素时,把某段文字复制到剪贴板,并可选提示“已复制”。

app.directive('copy', {
  mounted(el, binding) {
    el._copyHandler = async () => {
      const text = binding.value ?? el.textContent
      try {
        await navigator.clipboard.writeText(text)
        if (binding.modifiers.toast) {
          alert('已复制')
        }
      } catch (err) {
        console.error('复制失败', err)
      }
    }
    el.addEventListener('click', el._copyHandler)
  },
  unmounted(el) {
    el.removeEventListener('click', el._copyHandler)
  }
})
<span v-copy.toast="'要复制的文字'">点击复制</span>
<span v-copy>复制这段文字内容</span>
  • value 为要复制的字符串;不传则用 el.textContent
  • .toast 修饰符表示复制后 alert 提示(实际项目可换成你的 toast 组件)。

6.6 v-longpress:长按触发

需求: 按住超过一定时间才触发,而不是点一下就触发。

app.directive('longpress', {
  mounted(el, binding) {
    const duration = binding.value ?? 500
    let timer = null
    const start = () => {
      timer = setTimeout(() => {
        binding.value?.()
      }, duration)
    }
    const cancel = () => clearTimeout(timer)
    el._longpressStart = start
    el._longpressCancel = cancel
    el.addEventListener('mousedown', start)
    el.addEventListener('mouseup', cancel)
    el.addEventListener('mouseleave', cancel)
    el.addEventListener('touchstart', start)
    el.addEventListener('touchend', cancel)
  },
  unmounted(el) {
    el.removeEventListener('mousedown', el._longpressStart)
    el.removeEventListener('mouseup', el._longpressCancel)
    el.removeEventListener('mouseleave', el._longpressCancel)
    el.removeEventListener('touchstart', el._longpressStart)
    el.removeEventListener('touchend', el._longpressCancel)
  }
})
<button v-longpress="onLongPress">长按 500ms 触发</button>
<button v-longpress="800">长按 800ms(传的是数字,需在指令里区分类型或约定)</button>

这里约定 value 为函数或数字(数字为时长);若既要传时长又要传回调,可用对象:v-longpress="{ duration: 800, handler: onLongPress }",在指令里解析。


七、参数(arg)与修饰符(modifiers)小结

7.1 用参数区分“模式”

例如 v-color:bg 表示设背景色,v-color 表示设文字颜色:

  • binding.arg'bg'undefined
  • 在钩子里根据 arg 写不同逻辑(见 5.2、5.3)。

7.2 用修饰符做开关

例如 v-copy.toast 表示复制后提示:

  • binding.modifiers{ toast: true }
  • 在钩子里判断 binding.modifiers.toast 再决定是否弹提示。

7.3 值、参数、修饰符一起用

<div v-dir:arg.mod1.mod2="value"></div>
  • binding.value:value
  • binding.arg:’arg’
  • binding.modifiers:{ mod1: true, mod2: true }

八、易错点与注意点

8.1 一定要在 unmounted 里清理

若在 mounted 里加了全局或 document 的监听、定时器、创建的 DOM,要在 unmounted移除监听、清定时器、移除 DOM,否则组件销毁后仍会执行,造成内存泄漏或报错。

8.2 操作的是真实 DOM

指令钩子里的 el真实 DOM 元素,不是组件实例。
要改样式、属性、子节点,都用 el.xxx;不要指望在指令里拿到“子组件”的 ref,除非 el 是自定义组件的根 DOM。

8.3 值变化会触发 updated

binding.value 变化时(例如父组件传的变量变了),会先触发组件更新、再触发指令的 beforeUpdate / updated
若你的逻辑依赖“新值”,写在 updated 里即可。

8.4 何时用指令、何时用组件

  • 指令:对单个已有元素做 DOM 行为或样式(聚焦、颜色、权限隐藏、复制、长按、防抖点击、点击外部)。
  • 组件:一块可复用的 UI 结构 + 逻辑(按钮、卡片、表单)。
    若既要改 DOM 又要包一层复杂结构,优先考虑组件;若只是“给现有元素加一种行为”,用指令更合适。

九、自定义指令速查表

需求 做法
定义钩子 mounted(el, binding) { }updatedunmounted
拿指令的值 binding.value
拿参数 binding.arg(如 v-dir:arg 中的 arg)
拿修饰符 binding.modifiers(如 v-dir.mod 中 modifiers.mod === true)
全局注册 app.directive('name', { mounted, updated, ... })
局部注册 const vName = { mounted, ... }(script setup 中)
简写 只写一个函数,同时作为 mounted 和 updated

十、学习建议

  1. 先会写一个钩子(常用 mounted),在里边用 elbinding.value 做简单操作(如 focus、改 color)。
  2. 再练 updated(值变时再改 DOM)和 unmounted(移除监听、清理)。
  3. 需要“多种用法”时用 binding.arg;需要“开关”时用 binding.modifiers
  4. 凡是加了全局监听、定时器、自建 DOM,一定要在 unmounted 里清理。

把本文档里的 v-focusv-colorv-click-outsidev-copy 在项目里敲一遍,再试着改 value/arg/modifiers,会掌握得更牢。内置指令见《Vue3指令.md》。祝你学习顺利。

发表评论