Vue 内置的指令(v-model、v-if、v-show 等)覆盖了大部分常见的 DOM 操作需求。但当你需要在组件中复用某种 直接操作 DOM 的逻辑时,自定义指令就派上了用场。比如自动聚焦输入框、监听点击目标元素外部、或对一个按钮做防抖处理——这些需求更适合封装成指令,而不是在组件里写一堆 ref 和生命周期钩子。
自定义指令的本质
自定义指令本质上是一个对象,包含若干生命周期钩子函数,这些钩子会在绑定指令的元素上按特定顺序调用,并接收元素引用和绑定值等参数。你可以像使用内置指令一样在模板中通过 v-xxx 来应用它们。
全局注册与局部注册
局部注册(仅在当前组件可用):
<script setup>
// 在 script setup 中,所有以 v 开头的驼峰变量都会被视为自定义指令
const vFocus = {
mounted(el) {
el.focus()
}
}
</script>
<template>
<input v-focus />
</template>
如果是选项式 API,则在组件的 directives 选项中注册:
<template>
<input v-focus />
</template>
<script>
export default {
directives: {
focus: {
mounted(el) { el.focus() }
}
}
}
</script>
全局注册(整个应用可用):
// main.js
const app = createApp(App)
app.directive('focus', {
mounted(el) {
el.focus()
}
})
app.mount('#app')
全局注册的指令在任何组件内都能直接用 v-focus,但要注意命名冲突——尽量给指令起带前缀或明确含义的名字,避免覆盖内置指令或与其他库冲突。
生命周期钩子(Vue 3)
Vue 3 的自定义指令钩子与组件生命周期类似,但专门针对 DOM 绑定:
| 钩子函数 | 调用时机 | 常用操作 |
|---------|---------|--------|
| created | 在绑定元素的 attribute 或事件监听器应用之前调用 | 适合做一些初始设置,此时元素还未挂载 |
| beforeMount | 元素被插入到 DOM 之前调用 | - |
| mounted | 绑定元素的父组件及所有子节点都挂载完成后调用 | 最常用:操作 DOM、设置焦点、初始化第三方库 |
| beforeUpdate | 在元素自身更新之前调用,类似组件的 beforeUpdate | 适合在更新前移除已添加的监听器,但更推荐在 updated 中处理 |
| updated | 元素及其子节点更新完成后调用 | 基于新数据操作 DOM,但要避免无限更新循环 |
| beforeUnmount | 在元素被卸载之前调用 | 清理定时器、事件监听 |
| unmounted | 元素卸载后调用 | 最终清理 |
钩子参数:每个钩子函数都接收相同的参数集合:
el:指令绑定的 DOM 元素,可直接操作。binding:一个对象,包含value(传递给指令的值)、oldValue、arg(参数,如v-example:foo中的foo)、modifiers(修饰符对象)等。vnode:当前元素的虚拟节点。prevVnode:之前的虚拟节点(仅在beforeUpdate和updated中可用)。
简写形式:如果只想在 mounted 和 updated 时执行相同逻辑,可以直接传入一个函数:
app.directive('color', (el, binding) => {
el.style.color = binding.value
})
这等同于 { mounted(el, binding) { ... }, updated(el, binding) { ... } }。
典型应用场景
场景1:自动获取焦点
<script setup>
const vFocus = {
mounted: (el) => el.focus()
}
</script>
<template>
<input v-focus />
</template>
对话框中的第一个输入框自动聚焦,不再需要 ref 加生命周期调用。
场景2:点击外部关闭(常用于下拉菜单、模态框)
// 全局指令 v-click-outside
app.directive('click-outside', {
mounted(el, binding) {
el._clickOutside = (event) => {
if (!(el === event.target || el.contains(event.target))) {
binding.value(event) // 调用传递的回调函数
}
}
document.addEventListener('click', el._clickOutside)
},
unmounted(el) {
document.removeEventListener('click', el._clickOutside)
}
})
使用:
<div v-click-outside="closeMenu">点击外部我会关闭</div>
场景3:按钮防抖点击
app.directive('debounce', {
mounted(el, binding) {
let timer
el.addEventListener('click', (e) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
binding.value(e) // 真正的处理函数
}, binding.arg ? parseInt(binding.arg) : 300) // 可通过 v-debounce:500 指定延迟
})
}
})
使用:
<button v-debounce:500="handleSubmit">提交(500ms防抖)</button>
场景4:权限控制指令(v-permission)
// 根据用户权限决定元素是否展示
app.directive('permission', {
mounted(el, binding) {
const { value } = binding // 需要的权限标识
const permissions = store.state.user.permissions // 假设从全局状态获取
if (value && !permissions.includes(value)) {
el.parentNode?.removeChild(el) // 无权限则移除元素
}
}
})
使用:
<button v-permission="'delete'">删除</button> <!-- 用户无delete权限时此按钮不存在 -->
场景5:图片懒加载
app.directive('lazy', {
mounted(el, binding) {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
el.src = binding.value // 真实图片地址
observer.unobserve(el)
}
})
observer.observe(el)
}
})
什么时候该用自定义指令
当逻辑的核心是操作 DOM,并且需要在多个组件中复用时,自定义指令比 Hooks 或 Mixin 更合适。指令直接附着在元素上,代码位置靠近使用处,意图一目了然。但要注意:如果逻辑包含大量与业务状态相关的计算,应将这部分抽成 Hooks,在指令中只保留 DOM 操作部分,保持职责单一。
注意事项
- 指令中的函数如果使用了箭头函数,
this不指向组件实例;如果需要组件上下文,应使用binding.instance。 - 在
updated钩子中修改 DOM 时要小心,可能引起更新循环,尽量只做“读取”或基于binding.value的“设置”。 - 自定义指令不能像组件那样直接处理插槽或生成模板,它的领域是“对现有 DOM 元素的增强”。
自定义指令是 Vue 提供给开发者的“逃生舱”——当你需要越过声明式渲染直接触碰 DOM 时,它是最干净、最可维护的解决方案。