Vue2 到 Vue3,自定义指令的差异远比“改个钩子名”要大。指令本质上是一种面向 DOM 的副作用封装,但在两个版本的实现里,它的生命周期模型、注册方式、获取组件实例的途径都换了思路。很多人在迁移老项目时只把bind改成beforeMount、把unbind改成unmounted,结果功能看着正常,一到组件销毁就发现事件监听没有清理,或者拿不到组件实例,才意识到问题没有表面那么简单。
写这篇内容的目标很明确:把 Vue2 和 Vue3 自定义指令的差异讲透,同时给出全套从注册到模板使用的实操代码。我打算先聊指令在设计上应该解决什么问题,再分别拆解两个版本里的钩子函数和注册细节,然后用同一个业务需求做两版实现的对照,最后把迁移时容易踩的坑和排查方法拿出来逐条说清楚。不管你是刚接触指令的新手,还是正在做版本升级的开发者,都可以直接照着这里的方法动手改代码。
1. 自定义指令的定位:什么该用指令,什么不该用
1.1 指令解决的是哪类问题
自定义指令最适合处理的,是那些“和 DOM 强相关、又不好塞进组件模板逻辑”的重复行为。举个最直白的例子:一个搜索页里有十个输入框,希望在页面打开时自动聚焦到第一个输入框,这种需求你当然可以在每个组件的mounted里用ref手动调一次focus(),但代码会散落得到处都是。把“自动聚焦”抽成一个v-focus指令,任何一个输入框只要挂上这个指令就有同样的行为,复用成本降到最低。
我比较推荐用指令解决的问题类型大概有这么几类:第一是 DOM 属性或样式的批量处理,比如水印、拖拽、拉伸;第二是事件的快捷绑定,比如防抖、节流、点击外部区域关闭;第三是需要直接操作原生节点的行为,比如自动聚焦、复制文本、滚轮翻页;第四是配合权限状态控制节点的显隐或可用性,比如没有某个权限的按钮直接移除。
这段定义其实很重要,因为很多人把指令当通用工具函数用,在指令里写了一大堆组件业务逻辑,最后发现又要拿组件实例又要改响应式状态,两头不讨好。指令的最佳姿势是“只关心它绑定的那个元素和 binding 参数”,业务判断留在调用方,指令负责执行副作用。
1.2 用指令之前先问自己三个问题
我每次在代码评审里看到有人想新增一个自定义指令,都会让他先回答三个问题:这个逻辑是否会在两个以上组件里出现?这个逻辑是否主要作用在单个 DOM 节点上?这个逻辑是否能通过组件封装更自然地表达?
第三个问题最容易让人纠结。比如一个按钮的加载状态,用v-loading指令能实现,但把按钮封装成带 loading 属性的业务组件往往更直观。指令更适合那些“组件封装反而笨重”的场景,比如直接监听 document 的点击事件、修改目标元素在文档流里的位置这类操作。组件有生命周期和模板,指令没有模板,它直接面对原生节点,这是它的边界,也是它存在的理由。
2. Vue2 中的自定义指令:钩子模型与实现细节
2.1 全局注册与局部注册
Vue2 的自定义指令有两种注册方式,全局用Vue.directive,局部写在组件的directives选项里。全局注册的代码通常是这样的:
Vue.directive('focus', { bind(el) { el.focus() } })局部注册就是把指令对象塞进组件配置:
export default { directives: { focus: { bind(el) { el.focus() } } } }模板里的用法一致,都是<input v-focus>。全局注册适合一个应用里到处都要用的指令,比如权限指令、防抖指令;局部注册适合某个页面私有的交互行为,避免全局污染命名空间。
有一个细节很多老项目里容易忽略:全局注册的指令在所有组件实例里都生效,如果你在一个公共组件里顺手注册了一个指令,它会悄悄影响所有层级的模板解析,所以全局指令的命名最好加一个项目前缀,比如v-app-focus,降低冲突概率。
2.2 五大钩子函数与触发时机
Vue2 的指令钩子一共五个,名称和触发时机都有明确界定:
| 钩子函数 | 触发时机 |
|---|---|
| bind | 指令第一次绑定到元素时调用,此时父组件尚未挂载 |
| inserted | 被绑定元素插入到父节点时调用,此时父组件已挂载 |
| update | 所在组件的 VNode 更新时调用,但可能发生在子组件更新之前 |
| componentUpdated | 所在组件及子组件的 VNode 全部更新后调用 |
| unbind | 指令与元素解绑时调用,通常元素正在被销毁 |
每个钩子都能拿到el、binding、vnode、oldVnode四个参数。el是被绑定的真实 DOM 节点,binding里包含指令的值、参数、修饰符和旧值。vnode是当前虚拟节点,通过vnode.context可以拿到所在的组件实例。
这里有个非常重要的实操认知:bind不等于“页面能看到元素了”。它只是指令与元素建立绑定关系,真实 DOM 可能还没插入文档,所以如果你在bind里读取元素的offsetWidth、调用scrollIntoView,大概率拿到的不是预期结果。需要依赖元素已经出现在文档里的操作,应该放到inserted里做。这个顺序理解清楚了,后面看 Vue3 的钩子会更轻松。
2.3 一个完整的自动聚焦指令实现
写一个实际能跑的 Vue2 自动聚焦指令,需要区分简单场景和复杂场景。简单场景是页面初始化时让元素聚焦:
Vue.directive('focus', { inserted(el) { el.focus() } })复杂场景是元素本身是动态渲染的,比如弹窗里的输入框,弹窗打开时输入框才可能被插入。此时依旧用inserted,因为每次元素插入父节点都会触发一次,动态渲染的输入框同样能拿到焦点。如果换成bind,动态渲染场景就会失效。
指令内访问组件实例的场景也很常见。比如要实现一个“输入框聚焦时自动选中内容”的指令,需要拿到组件的某个数据,Vue2 的标准写法是这样:
Vue.directive('select-on-focus', { inserted(el, binding, vnode) { const vm = vnode.context el.addEventListener('focus', () => { el.select() if (typeof binding.value === 'function') { binding.value.call(vm) } }) } })vnode.context在 Vue2 里几乎是拿组件实例的唯一推荐方式,而且它一定指向被绑定元素所属的组件。不过要注意:指令的钩子函数里this并不可靠,不要试图通过this拿实例,Vue2 文档里并没有保证this指向组件实例。
3. Vue3 中的自定义指令:生命周期重构与应用
3.1 钩子命名变化的背后逻辑
Vue3 把自定义指令的生命周期和组件生命周期对齐了,这是一次很根本的模型调整。Vue2 的bind、inserted、update、componentUpdated、unbind这套命名是从“元素绑定过程”视角出发的;Vue3 的beforeMount、mounted、beforeUpdate、updated、beforeUnmount、unmounted则是从“组件挂载过程”视角出发的。
用一张表可以直接看到对应关系:
| Vue2 钩子 | Vue3 钩子 | 触发时机 |
|---|---|---|
| bind | beforeMount | 指令第一次绑定到元素,父组件挂载前 |
| inserted | mounted | 父组件挂载完成后 |
| update | beforeUpdate | 所在组件的 VNode 更新前 |
| componentUpdated | updated | 所在组件及子组件 VNode 更新后 |
| unbind | beforeUnmount | 元素所在组件卸载前 |
| unbind | unmounted | 指令与元素解绑,组件已卸载 |
Vue3 还多了一个created钩子,在元素的 attribute 和事件监听器应用之前调用,平时用得少,一般用于需要提前截断默认绑定的场景。
命名变化背后还有一个习惯变化:Vue2 里我习惯在bind里做所有初始化,因为bind触发早;Vue3 里要先想清楚操作是否需要 DOM 已经挂载,再决定用beforeMount还是mounted。比如事件监听放在mounted,清理放在unmounted,和组件的生命周期心智模型完全一致。
3.2 通过 app.directive 与组件的 directives 选项注册
Vue3 不再有全局Vue.directive,而是通过应用实例注册:
import { createApp } from 'vue' import App from './App.vue' const app = createApp(App) app.directive('focus', { mounted(el) { el.focus() } }) app.mount('#app')局部注册和 Vue2 的 Options API 形式上差别不大:
export default { directives: { focus: { mounted(el) { el.focus() } } } }但 Vue3 的组合式 API 时代,很多人不再写 Options API,于是出现了一种新的指令注册写法:在<script setup>里定义一个以v开头的变量,模板里可以直接把驼峰名当成指令使用:
<script setup> const vFocus = { mounted(el) { el.focus() } } </script> <template> <input v-focus /> </template>注意这里变量名必须是vFocus这种驼峰形式,模板里写v-focus才能识别。这种写法非常适合局部功能指令,不用在组件配置里单独声明directives,代码位置也更集中。缺点是它只能在当前组件使用,想跨组件复用还是得放到公共模块里或者用app.directive全局注册。
3.3 指令参数与组件实例访问的变化
Vue3 的指令钩子参数比 Vue2 更清晰,每个钩子接收el、binding、vnode、prevVnode。其中binding对象里包含value、oldValue、arg、modifiers、instance。
binding.instance是 Vue3 新增的属性,正式替代了 Vue2 里vnode.context的常见用法。代码切起来非常直接:
// Vue2 bind(el, binding, vnode) { const vm = vnode.context } // Vue3 mounted(el, binding) { const vm = binding.instance }binding.instance在指令执行时一定能拿到组件实例,但要注意时机。beforeMount阶段组件实例还没挂载,部分数据可能还没有初始化完成;如果指令内部需要调用组件方法,尽量放到mounted之后。这个问题我在迁移一个输入框自动补全指令时踩过,指令想在组件某个方法里读数据,写成beforeMount后拿到的数据是空的,改成mounted后一切正常。
动态指令参数在 Vue3 里也依然可用。比如一个给元素设置主题色的指令,颜色值通过binding.value传,主题类型通过binding.arg传:
<div v-theme:dark="colorText">内容</div>app.directive('theme', { mounted(el, binding) { applyTheme(el, binding.arg, binding.value) }, updated(el, binding) { applyTheme(el, binding.arg, binding.value) } })当dark这个参数变化时,updated钩子会触发,binding.arg能拿到最新的参数,binding.oldValue能拿到旧值。如果你需要在参数变化时做额外的清理工作,旧值判断就在这里做。
4. 同一个业务需求的两版实现对比
4.1 权限指令的实现差异
权限控制是我在后台管理系统里几乎必用的指令场景,很适合用来对比两版实现的差异。需求是:按钮绑定的值对应当前用户不具备的权限码时,直接把这个按钮移除。Vue2 版本长这样:
Vue.directive('permission', { inserted(el, binding) { const required = binding.value if (!hasPermission(required)) { el.parentNode && el.parentNode.removeChild(el) } } })Vue3 版本长这样:
app.directive('permission', { mounted(el, binding) { const required = binding.value if (!hasPermission(required)) { el.remove() } } })表面差异很小,就是inserted变mounted、removeChild变remove。但有个业务变化值得注意:权限往往不是静态的,用户在同一个页面里可能因为角色切换、Token 刷新导致权限变化。Vue3 版本需要额外处理更新逻辑:
app.directive('permission', { mounted(el, binding) { checkAndRemove(el, binding.value) }, updated(el, binding) { checkAndRemove(el, binding.value) } })updated钩子会在组件更新时触发,这样权限码变化后按钮的显隐也能同步刷新。Vue2 时代很多人只写了inserted,导致权限变化后需要手动刷新页面才生效,这是一个隐藏较深的坑。如果你正在维护老项目,建议把这类指令补上componentUpdated或update钩子。
4.2 防抖指令与事件清理
防抖指令是事件类和清理逻辑的典型代表。给按钮挂一个v-debounce,点击后会等一段时间再执行传入的方法。Vue2 的写法:
Vue.directive('debounce', { bind(el, binding) { let timer = null const handler = () => { clearTimeout(timer) timer = setTimeout(() => { binding.value() }, Number(binding.arg) || 300) } el.addEventListener('click', handler) el.__debounceHandler = handler }, unbind(el) { clearTimeout(el.__debounceTimer) el.removeEventListener('click', el.__debounceHandler) } })Vue3 的写法:
app.directive('debounce', { mounted(el, binding) { let timer = null const handler = () => { clearTimeout(timer) timer = setTimeout(() => { binding.value() }, Number(binding.arg) || 300) } el.addEventListener('click', handler) el.__debounceCleanup = () => { clearTimeout(timer) el.removeEventListener('click', handler) } }, unmounted(el) { el.__debounceCleanup && el.__debounceCleanup() } })两个版本的核心不一样在于清理时机的明确性。Vue2 的unbind只在元素与指令解绑时触发,如果元素本身还挂着其他监听器,需要把清理函数完整写在unbind里;Vue3 的unmounted同样承担这个职责,但我习惯在el上挂一个__debounceCleanup兜底函数,这样即使同一个元素绑了多条逻辑,也能集中清理。这是我在实际项目中养成的习惯,后期排查内存泄漏会轻松很多。
4.3 点击外部关闭的指令差异
点击元素外部关闭下拉面板或弹窗,是另一个常用的指令场景。它需要往 document 上挂一个全局监听,所以清理逻辑的重要性更高。Vue2 实现:
Vue.directive('click-outside', { bind(el, binding, vnode) { const vm = vnode.context el.__clickOutsideHandler = (event) => { if (!el.contains(event.target)) { binding.value.call(vm) } } document.addEventListener('click', el.__clickOutsideHandler) }, unbind(el) { document.removeEventListener('click', el.__clickOutsideHandler) } })Vue3 实现差异点是组件实例的获取方式换成binding.instance,同时新增了动态参数支持,可以让指令只在用户点击特定区域时才触发回调。核心逻辑不变,但命名从bind/unbind变成了mounted/unmounted。
这个例子我拿出来讲,是因为它最能体现“同一个需求,两版生命周期心智模型的不一致”。Vue2 里我们习惯把所有初始化集中放在bind,因为当时不太区分元素是否挂载;Vue3 里每个阶段都有明确名称,写代码时你会主动思考“document 监听应该等 mounted 之后再挂吗”,这种思考对长期维护是有益的。
5. 常见问题排查与避坑笔记
5.1 钩子不触发的几个原因
指令完全没有反应,是迁移时最常遇到的问题。我总结几个高危原因:
- Vue3 项目里还在写
bind、inserted、unbind,这些钩子不会被调用,指令自然没有任何效果。 - 全局注册时用了
Vue.directive,但项目里已经改成createApp模式,Vue.directive在新版本中已经不可用。 - 组件局部注册的指令名是驼峰,模板里却写了全大写的开头,比如注册
myFocus模板写v-MyFocus,这种拼写不会报错,但指令不会生效。 - 在
<script setup>里定义了vFocus变量,但把它写成了普通函数而不是包含生命周期钩子的对象。
排查方法很简单:在指令钩子第一行加一个console.log,如果日志不打印,问题大概率在注册或命名上;如果打印了但行为不对,再去检查钩子时机和参数值。
5.2 事件清理与内存泄漏
指令里挂事件监听是最容易产生内存泄漏的路径。组件销毁后,如果指令挂的document.addEventListener没有被移除,回调里就可能引用一个已经销毁的组件实例,轻则内存泄漏,重则报错。
一个稳健的写法是:在挂监听时把清理函数挂到el上,命名带__前缀避免和业务属性冲突,然后在unmounted里兜底清理:
app.directive('outside', { mounted(el, binding) { const handler = (e) => { if (!el.contains(e.target)) { binding.value() } } el.__outsideHandler = handler document.addEventListener('click', handler) }, unmounted(el) { document.removeEventListener('click', el.__outsideHandler) } })这里有一个额外的小细节:不要直接用el.remove()把权限不足的元素移除后,还指望它在unmounted里做清理。因为元素已经从 DOM 移除,组件再销毁时指令依然会执行unmounted,但el.__outsideHandler可能已经被垃圾回收前的某个流程清掉了。稳妥的做法是在beforeUnmount里统一做事件解绑,unmounted只做兜底。
5.3 指令与生命周期顺序:Vue2 和 Vue3 的执行优先级
同一个元素上挂多条指令时,执行顺序在两个版本里也有差异。Vue3 里指令的钩子执行顺序遵循从外到内,也就是父级元素上的指令先执行,子元素上的指令后执行。Vue2 则是相反的。这意味着如果你写了一组互相依赖的指令,在迁移时不能只改钩子名,还要确认执行顺序是否符合预期。
这种依赖情况平时不多见,但一旦出现就非常难排查。我的建议是:指令之间不要设计成相互依赖的形态,让每个指令只私有地管理自己的副作用。如果确实需要按顺序执行,可以考虑把它们合并成一条指令,在内部按顺序调用各自的初始化逻辑。
5.4 SSR 与指令注意事项
服务端渲染场景下,自定义指令的挂载阶段钩子不会执行,因为 SSR 阶段没有真实 DOM。也就是说,任何依赖el做 DOM 操作的逻辑在服务端都是不生效的,这不只是 Vue3 的问题,Vue2 同样如此。
所以涉及 SSR 的项目,指令内部逻辑必须允许“在客户端阶段才初始化”。如果指令里要访问window或document,需要先做环境判断。还有一个容易被忽略的点:指令内使用的binding.instance在 SSR 服务端渲染阶段拿到的组件实例并不完整,不要在指令里依赖实例上的某些仅客户端才存在的属性和方法。
6. 从 Vue2 迁移到 Vue3 的实操路径
6.1 钩子映射表
迁移的第一步永远是建立新旧钩子的映射关系,照着改不容易漏:
| Vue2 | Vue3 | 说明 |
|---|---|---|
| bind | beforeMount | 指令绑定到元素,父组件挂载前 |
| inserted | mounted | 父组件挂载完成后 |
| update | beforeUpdate | 组件 VNode 更新前 |
| componentUpdated | updated | 组件及子组件 VNode 更新后 |
| unbind | beforeUnmount | 组件卸载前,适合解绑事件 |
| unbind | unmounted | 指令与元素解绑后,兜底清理 |
update和beforeUpdate在触发时机上有一个容易混淆的点:Vue2 的update可能在子组件更新前触发,而 Vue3 的beforeUpdate是在所在组件 VNode 更新前触发。如果你在update里读取了子组件的状态,迁移后要调整成updated才能保证读取到的是更新后的值。
6.2 在项目里稳妥引入自定义指令的步骤
如果你的项目是从零开始接入指令体系,我的建议是按下面五步走。第一步,把所有指令整理成独立模块,默认导出指令对象,不在组件文件里散落定义。第二步,全局通用指令放进一个directives/index.js统一注册,在createApp后循环调用app.directive。第三步,页面私有指令用<script setup>的v变量形式写在组件内部。第四步,每个指令维护一份钩子清单,注明何时挂载监听、何时移除监听。第五步,加一个冒烟测试页面,覆盖指令的创建、更新、销毁三个阶段。
这个流程看起来繁琐,但它能提前帮你发现误用钩子、事件没清理、注册失效三类问题。我在实际项目里按这个流程做过一次,后来团队其他人接手时基本不需要额外解释指令代码的来龙去脉。
6.3 几条长期有效的设计经验
最后分享几条我在写过大量指令之后沉淀下来的经验。
指令名称不要起得太通用。focus、click、tooltip这类名字很容易和第三方库冲突,也容易让后来的人误解它到底是内置行为还是项目自定义。建议加业务前缀,比如permission、app-ripple、track-click。
指令对象里的逻辑要尽量保持“无状态”。指令最常见的失败模式就是内部维护了一个复杂状态变量,更新钩子来了以后不知道该用旧值还是新值。把状态放到binding.value或者组件的响应式数据里,比放在闭包变量里好维护得多。
清理函数一定要兜底。不管你觉得某个指令多简单,都应该在unmounted或beforeUnmount里把能想到的清理都做了。我见过很多“看起来没问题”的指令,组件销毁后定时器还在跑,接口回调还在改一个已经卸载的 DOM,这类问题不会立刻报错,但会让页面内存持续增长。
我在实际项目里最受益的一件事,是把所有指令的调试信息集中在binding.modifiers上统一开关。开发环境里给指令加一个.debug修饰符,钩子内部就会打印触发顺序和值的变化,上线时去掉这个修饰符即可。这种方式让你不用在每个指令里写死日志,排查问题时打开开关就能看到完整链路,日常运行又不会产生多余输出。
如果你正在迁移一个 Vue2 老项目,我的建议是先跑通一个高频指令的迁移,比如权限按钮或自动聚焦,再逐步铺开。一次性把所有指令都改完,遇到问题时会很难定位是钩子命名的问题还是执行时机的问题。好在 Vue2 和 Vue3 的自定义指令在核心心智上没有翻天覆地的变化,理解了生命周期对齐这件事,剩下的就是按步骤替换了。