很多同学用 Vue 写业务时,习惯了 v-model、v-show、v-if,却很少自己写自定义指令。一旦遇到「要在元素插入 DOM 后自动聚焦」「点击组件外部关闭弹层」「按权限动态隐藏元素」这类需求,才会意识到指令的价值。

本文用一张差异表 + 多个可运行示例,把 Vue2 与 Vue3 的自定义指令讲透,并给出从 Vue2 平滑迁移到 Vue3 的实战要点。建议收藏。
指令本质上是「带 v- 前缀的可复用 DOM 行为」。Vue 内置了 v-model、v-show、v-if 等;当内置指令不够用时,可以注册自己的指令。
一个指令对象包含若干「钩子函数」,Vue 在元素的不同生命周期阶段回调它们。
使用方式:
复制代码<input v-focus />
<div v-permission="'admin'" />
<p v-highlight="'yellow'">高亮文本</p>
Vue2 指令对象可包含以下 5 个钩子:
| 钩子 | 调用时机 |
|---|---|
bind | 指令第一次绑定到元素时调用,只调用一次。此时 DOM 还未插入父节点(无法获取 parentNode)。 |
inserted | 被绑定元素插入父节点时调用(仅保证父节点存在,不保证已在 document 中)。 |
update | 所在组件的 VNode 更新时调用,可能发生在其子 VNode 更新之前。指令的值可能改变,也可能没变。 |
componentUpdated | 指令所在组件的 VNode 及其子 VNode 全部更新后调用。 |
unbind | 指令与元素解绑时调用,只调用一次。 |
每个钩子接收相同的 4 个参数:
el:指令绑定的真实 DOM 元素,可直接操作。binding:一个对象,包含以下属性:
name:指令名(不含 v- 前缀)。value:指令的绑定值(例如 v-my-dir="1 + 1" 中 value 为 2)。oldValue:上一次的值,仅在 update / componentUpdated 中可用。expression:字符串形式的绑定表达式(如 "1 + 1")。arg:传给指令的参数(如 v-my-dir:foo 中 arg 为 "foo")。modifiers:修饰符对象(如 v-my-dir.a.b 中 modifiers 为 { a: true, b: true })。vnode:Vue 编译生成的虚拟节点。oldVnode:上一个虚拟节点,仅在 update / componentUpdated 中可用。全局注册:
复制代码// main.js
Vue.directive('focus', {
inserted(el) {
el.focus();
}
});
局部注册:
复制代码export default {
directives: {
focus: {
inserted(el) {
el.focus();
}
}
}
};
示例 1:自动聚焦
复制代码Vue.directive('focus', {
// 元素插入 DOM 后聚焦(bind 阶段 el 还没进文档,所以用 inserted)
inserted(el) {
el.focus();
}
});
示例 2:权限控制(函数简写形式)
如果指令在 bind 和 update 时触发相同行为,可传入函数代替对象:
复制代码Vue.directive('permission', function (el, binding) {
const userRole = store.getters.role; // 假设从全局状态读取
const required = binding.value; // 指令绑定值,如 'admin'
if (!required.includes(userRole)) {
el.parentNode && el.parentNode.removeChild(el);
}
});
示例 3:动态参数 + 修饰符(拖拽)
复制代码<div v-drag:horizontal.throttle="onDrag">拖我</div>
复制代码Vue.directive('drag', {
bind(el, binding) {
const onlyX = binding.arg === 'horizontal';
const throttle = binding.modifiers.throttle;
let startX = 0, startY = 0; const onMove = (e) => {
if (onlyX) {
el.style.left = e.clientX - startX + 'px';
} else {
el.style.left = e.clientX - startX + 'px';
el.style.top = e.clientY - startY + 'px';
}
};
const onDown = (e) => {
startX = e.clientX - el.offsetLeft;
startY = e.clientY - el.offsetTop;
document.addEventListener('mousemove', onMove);
};
const onUp = () => document.removeEventListener('mousemove', onMove); el.addEventListener('mousedown', onDown);
el.addEventListener('mouseup', onUp);
// 注意:真实场景应在 unbind 中清理事件监听,避免内存泄漏
}
});
Vue3 重命名并调整了指令钩子,与组件生命周期对齐:
| Vue2 钩子 | Vue3 钩子 | 说明 |
|---|---|---|
bind | beforeMount | 指令绑定到元素,但还没挂载到 DOM。 |
inserted | mounted | 元素被插入父 DOM 后调用。 |
| — | beforeUpdate | 新增:元素自身 VNode 更新前(Vue2 没有对应独立钩子)。 |
update | —(移除) | 被 beforeUpdate + updated 取代,语义更清晰。 |
componentUpdated | updated | 组件及子组件 VNode 全部更新后。 |
unbind | unmounted | 指令与元素解绑、元素卸载时调用。 |
Vue3 的钩子同样接收 4 个参数,但 binding 结构略有调整:
el:DOM 元素(同 Vue2)。binding:包含 instance(新增,使用指令的组件实例)、value、oldValue、arg、modifiers、dir(指令定义对象自身)。vnode:虚拟节点。prevVnode:上一个虚拟节点(updated 中可用,替代 Vue2 的 oldVnode)。全局注册(使用 app 实例):
复制代码// 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');
局部注册(<script setup> + Composition API):
复制代码<script setup>
const vFocus = {
mounted: (el) => el.focus()
};
</script><template>
<input v-focus />
</template>
对象写法简写(函数形式,等价于 mounted + updated):
复制代码app.directive('color', (el, binding) => {
el.style.color = binding.value;
});
示例 1:自动聚焦
复制代码app.directive('focus', {
mounted(el) {
el.focus();
}
});
示例 2:点击外部关闭(Click Outside)
复制代码app.directive('click-outside', {
mounted(el, binding) {
// 在 mounted 注册监听,binding.value 为回调函数
el.__clickOutside__ = (event) => {
if (!(el === event.target || el.contains(event.target))) {
binding.value(event);
}
};
document.addEventListener('click', el.__clickOutside__, true);
},
unmounted(el) {
document.removeEventListener('click', el.__clickOutside__, true);
delete el.__clickOutside__;
}
});
复制代码<template>
<div v-click-outside="closeMenu" class="menu">菜单内容</div>
</template>
示例 3:利用 binding.instance 读取组件状态
复制代码app.directive('role', {
mounted(el, binding) {
const vm = binding.instance; // 当前组件实例
const userRole = vm.userRole; // 直接读组件响应式数据
if (!binding.value.includes(userRole)) {
el.style.display = 'none';
}
},
updated(el, binding) {
const vm = binding.instance;
el.style.display = binding.value.includes(vm.userRole) ? '' : 'none';
}
});
| 对比项 | Vue2 | Vue3 |
|---|---|---|
| 全局注册 API | Vue.directive() | app.directive()(需 createApp 实例) |
钩子 bind | 改名 beforeMount | |
钩子 inserted | 改名 mounted | |
钩子 update | 移除,拆为 beforeUpdate + updated | |
钩子 componentUpdated | 改名 updated | |
钩子 unbind | 改名 unmounted | |
新增 beforeUpdate | ||
binding.instance | (可访问组件实例) | |
binding.dir | (可访问指令对象自身) | |
<script setup> 局部指令 | 不支持(无此语法) | 支持,vXxx 变量命名 |
oldVnode → prevVnode | oldVnode | prevVnode |
钩子改名:bind→beforeMount、inserted→mounted、componentUpdated→updated、unbind→unmounted。
拆分 update:若原 update 里依赖「子节点已就绪」,迁移后应放到 updated;若依赖「自身更新前」,用 beforeUpdate。
清理逻辑:把事件监听、定时器、观察者等的清理统一放到 unmounted(原 unbind)。
binding.instance 替代 hack:Vue2 常通过 vnode.context 拿组件实例,Vue3 直接用 binding.instance,更干净。
TypeScript 类型:Vue3 提供 DirectiveBinding、Directive 等类型,推荐声明:
复制代码import type { Directive, DirectiveBinding } from 'vue';
const vFocus: Directive<HTMLElement, void> = {
mounted(el) { el.focus(); }
};
unmounted / unbind 中清理副作用(事件监听、ResizeObserver、定时器、IntersectionObserver 等),否则内存泄漏。binding 对象,它是只读快照。binding.dir 存共享状态,或把状态挂在 el.__xxx__ 上(注意命名冲突,建议加前缀)。Mount 语义),并新增 beforeUpdate、暴露 binding.instance。update 拆分 + 清理逻辑归位。如果这篇文章帮你理清了 Vue 指令的迁移思路,欢迎点赞 ️、收藏 ⭐、关注 ,并在评论区聊聊你在项目中用指令解决过什么奇葩需求~