Vue 基础体系 · 第 30/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 自定义指令:生命周期、DOM 行为、清理和组件替代边界
Vue 自定义指令用于封装“直接操作 DOM”的行为。典型例子包括:
- 元素挂载后自动获得焦点;
- 监听元素外部的点击或指针事件;
- 初始化第三方 DOM 插件;
- 根据元素位置创建观察器;
- 在元素销毁时移除事件监听器、观察器和插件实例。
指令的价值不在于替代组件,而在于把一个与元素本身强相关、没有独立视图结构的 DOM 行为封装起来。要正确使用它,需要同时理解四个问题:
- 指令生命周期何时执行;
- 指令如何读取绑定值、参数和修饰符;
- 直接修改 DOM 与 Vue 渲染器之间如何相互影响;
- 资源如何清理,以及什么时候应该改用组件。
本文基于 Vue 3、Composition API、TypeScript 和现代 Vite 工具链。
一、自定义指令到底是什么
自定义指令是一个对象,或者一个简写函数。对象中的钩子会在 Vue 处理目标 DOM 元素的不同阶段被调用。
最小示例:
// src/directives/focus.ts
import type { Directive } from 'vue'
export const vFocus: Directive<HTMLInputElement, boolean | undefined> = {
mounted(el, binding) {
if (binding.value !== false) {
el.focus()
}
},
}
模板中使用:
<script setup lang="ts">
import { ref } from 'vue'
import { vFocus } from '@/directives/focus'
const enabled = ref(true)
</script>
<template>
<input v-focus="enabled" />
</template>
这里有三个角色:
vFocus:指令定义对象;v-focus:模板中的指令名称;el:指令实际绑定的 DOM 元素,本例中是<input>。
在 <script setup> 中,名称为 vFocus 的变量可以直接对应模板中的 v-focus。普通组件中也可以局部注册:
import { createApp } from 'vue'
import App from './App.vue'
import { vFocus } from './directives/focus'
createApp(App)
.directive('focus', vFocus)
.mount('#app')
全局注册后,应用内所有模板都可以使用 v-focus,但全局注册会扩大名称和行为的影响范围。仅被少数组件使用的指令,通常更适合局部注册或直接在 <script setup> 中导入。
二、指令的生命周期
Vue 3 自定义指令支持以下生命周期钩子:
| 钩子 | 发生时机 |
|---|---|
created |
元素属性和事件监听器应用之前 |
beforeMount |
元素即将插入 DOM 之前 |
mounted |
元素已插入 DOM 之后 |
beforeUpdate |
包含该元素的组件更新、DOM patch 之前 |
updated |
组件更新以及相关子节点更新完成后 |
beforeUnmount |
元素即将从 DOM 中移除之前 |
unmounted |
元素已经从 DOM 中移除之后 |
这些名称与组件生命周期相似,但对象不同:
- 组件生命周期围绕组件实例和组件树;
- 指令生命周期围绕某一个具体 DOM 元素;
- 指令钩子中的
el是被绑定的元素,而不是组件实例。
2.1 created
created 是指令生命周期中最早的钩子之一。此时元素已经被创建,但 Vue 还没有完成该元素属性和事件监听器的应用。
const vInspect: Directive<HTMLElement> = {
created(el) {
console.log('元素已经创建,但还没有完成所有属性应用', el)
},
}
它适合做不依赖最终 DOM 状态的初始化,例如准备内部数据结构。它不适合读取最终的布局信息:
const vWrong: Directive<HTMLElement> = {
created(el) {
// 不应依赖这里的布局结果
console.log(el.getBoundingClientRect())
},
}
此时元素可能还没有插入文档,布局结果通常不能代表最终显示状态。
2.2 beforeMount
beforeMount 发生在元素即将挂载之前。它仍然不适合依赖“元素已经在页面上”这一事实。
const vPrepare: Directive<HTMLElement> = {
beforeMount(el) {
el.setAttribute('data-directive-ready', 'true')
},
}
如果目标是调用需要真实 DOM 的 API,例如:
focus();getBoundingClientRect();ResizeObserver.observe(el);- 第三方插件初始化;
通常应优先使用 mounted。
2.3 mounted
mounted 是最常用的指令钩子。此时元素已经插入 DOM,可以进行依赖真实页面结构的操作。
const vAutofocus: Directive<HTMLInputElement> = {
mounted(el) {
el.focus()
},
}
不过“已插入 DOM”不等于“已经完成所有异步数据加载”或“已经稳定显示”。例如元素所在的父组件可能还会在后续异步更新中改变尺寸,因此初始化布局相关插件时,必要时还需要等待浏览器下一帧:
import { nextTick } from 'vue'
import type { Directive } from 'vue'
export const vMeasure: Directive<HTMLElement> = {
async mounted(el) {
await nextTick()
const rect = el.getBoundingClientRect()
console.log({
width: rect.width,
height: rect.height,
})
},
}
nextTick() 等待的是 Vue 当前更新队列完成,不是任意异步任务完成,也不保证浏览器已经完成所有动画和字体加载。
2.4 beforeUpdate 和 updated
当使用该指令的元素所在组件发生更新时,指令可以收到更新钩子。
const vLogUpdate: Directive<HTMLElement, string> = {
beforeUpdate(el, binding) {
console.log('更新前', {
oldValue: binding.oldValue,
newValue: binding.value,
text: el.textContent,
})
},
updated(el, binding) {
console.log('更新后', {
oldValue: binding.oldValue,
newValue: binding.value,
text: el.textContent,
})
},
}
更新过程可以抽象为:
响应式状态变化
↓
组件重新渲染,生成新的 VNode
↓
Vue 比较旧 VNode 与新 VNode
↓
更新属性、文本、子节点和指令绑定
↓
updated
因此,指令的 updated 不是“绑定表达式变化事件”的简单别名。即使指令值没有发生变化,只要相关组件执行了更新,指令仍可能参与更新阶段。指令内部如果只需要在值真正变化时处理逻辑,应显式比较:
const vReadonly: Directive<HTMLInputElement, boolean> = {
updated(el, binding) {
if (binding.value === binding.oldValue) {
return
}
el.readOnly = binding.value
},
}
这里的比较是值相等比较。如果绑定对象每次渲染都重新创建,即使对象内容相同,引用也可能不同:
<div v-example="{ enabled: true }" />
// 两次渲染可能得到两个不同的对象引用
binding.value !== binding.oldValue
如果需要稳定比较,应使用稳定引用,或者比较具体字段,而不是盲目进行深比较。
2.5 beforeUnmount 和 unmounted
beforeUnmount 发生在元素即将销毁之前,适合做仍然需要访问元素的清理工作。unmounted 发生在元素已经移除后,也可以执行清理,但不应再假设元素仍在文档中。
const vObserve: Directive<HTMLElement> = {
mounted(el) {
const observer = new ResizeObserver(() => {
console.log('尺寸变化')
})
observer.observe(el)
},
beforeUnmount(el) {
console.log('元素即将销毁', el)
},
unmounted(el) {
console.log('元素已经移除', el)
},
}
实际工程中,清理通常需要保存 observer、事件处理函数或第三方实例,否则无法在卸载时传入同一个对象完成移除。
三、指令钩子的参数:el、binding、vnode 和 prevNode
指令钩子通常可以接收四个参数:
const directive: Directive<HTMLElement, string> = {
mounted(el, binding, vnode, prevNode) {
// ...
},
}
3.1 el
el 是指令绑定的真实 DOM 元素。
<button v-demo>保存</button>
mounted(el) {
// el 就是这个 button 元素
el.setAttribute('data-ready', 'true')
}
指令的核心工作对象就是 el。但是,不能把 el 当作永远由指令独占的 DOM。Vue 仍然会根据模板重新更新它的属性、文本和子节点。
3.2 binding.value
指令表达式的结果称为 binding.value:
<div v-permission="user.role" />
如果:
const user = {
role: 'admin',
}
那么:
binding.value === 'admin'
指令可以定义泛型约束:
type Permission = 'admin' | 'editor' | 'viewer'
const vPermission: Directive<HTMLElement, Permission> = {
mounted(el, binding) {
if (binding.value === 'viewer') {
el.setAttribute('aria-disabled', 'true')
}
},
}
泛型只提供 TypeScript 检查,不会在运行时自动验证传入值。若指令边界面向外部调用者,仍应在运行时处理非法输入。
3.3 binding.oldValue
oldValue 是更新前的值。首次挂载时没有旧值,通常为 undefined。
const vToggle: Directive<HTMLElement, boolean | undefined> = {
updated(el, binding) {
const enabled = binding.value === true
const wasEnabled = binding.oldValue === true
if (enabled === wasEnabled) {
return
}
el.hidden = !enabled
},
}
需要注意:如果合法值本身也可能是 undefined,就不能单靠 oldValue 判断“是否首次调用”,应在自己的状态表中记录初始化状态。
3.4 binding.arg
参数是指令名称后面的冒号部分:
<div v-style:color="color" />
这里的 binding.arg 是字符串 "color"。
type StyleValue = string | number
const vStyle: Directive<HTMLElement, StyleValue> = {
updated(el, binding) {
const property = binding.arg
if (!property) {
return
}
el.style.setProperty(property, String(binding.value))
},
}
动态参数也是允许的:
<div v-style:[property]="value" />
参数可能在更新过程中变化,因此不能只在 mounted 中设置一次而完全忽略 updated。
3.5 binding.modifiers
修饰符会以对象形式出现:
<div v-scroll.lock.passive />
对应:
binding.modifiers.lock === true
binding.modifiers.passive === true
可以利用它配置行为:
const vClickOutside: Directive<HTMLElement, (event: PointerEvent) => void> = {
mounted(el, binding) {
const eventName = binding.modifiers.click ? 'click' : 'pointerdown'
const handler = (event: Event) => {
if (!(event instanceof PointerEvent)) {
return
}
if (!el.contains(event.target as Node)) {
binding.value(event)
}
}
document.addEventListener(eventName, handler)
},
}
这个示例还不完整,因为它没有保存 handler,所以无法在卸载时移除监听器。生命周期与清理必须一起设计,不能把 mounted 当作独立代码片段。
3.6 binding.instance
binding.instance 是使用该指令的组件实例。它可以用于极少数确实需要访问组件实例的场景,但不应作为指令与组件内部通信的主要方式。
原因是:
- 指令因此依赖组件实现细节;
- 组件实例类型和内部结构更难维护;
- 指令可能被复用到不同组件或普通元素上;
- 直接修改组件实例状态会绕过清晰的数据流。
通常优先通过 binding.value、参数和修饰符传入行为所需的数据。
四、一个完整的可运行示例:点击元素外部关闭菜单
“点击外部关闭”同时涉及 DOM 事件、事件传播、生命周期、资源清理和 TypeScript 类型,是理解自定义指令的一个完整例子。
4.1 指令实现
// src/directives/clickOutside.ts
import type { Directive } from 'vue'
export type ClickOutsideHandler = (event: PointerEvent) => void
interface ClickOutsideState {
listener: (event: PointerEvent) => void
documentRef: Document
}
const states = new WeakMap<HTMLElement, ClickOutsideState>()
export const vClickOutside: Directive<
HTMLElement,
ClickOutsideHandler | undefined
> = {
mounted(el, binding) {
if (typeof binding.value !== 'function') {
return
}
const documentRef = el.ownerDocument
const listener = (event: PointerEvent) => {
const path = event.composedPath()
// composedPath 比 event.target 更适合处理 Shadow DOM 场景。
const clickedInside = path.includes(el)
if (!clickedInside) {
binding.value?.(event)
}
}
states.set(el, {
listener,
documentRef,
})
documentRef.addEventListener('pointerdown', listener, true)
},
updated(el, binding) {
const state = states.get(el)
if (!state || typeof binding.value !== 'function') {
return
}
// listener 通过闭包读取 binding.value 并不可靠:
// 指令对象中的 binding 不是专门为长期状态设计的。
// 因此生产实现通常会把当前回调也存进状态,见下方改进版。
},
unmounted(el) {
const state = states.get(el)
if (!state) {
return
}
state.documentRef.removeEventListener(
'pointerdown',
state.listener,
true,
)
states.delete(el)
},
}
上面展示了清理结构,但 updated 还存在一个设计问题:mounted 创建的监听函数捕获了初始的 binding.value。如果父组件更新了回调函数,监听器可能继续调用旧回调。
更完整的实现应将当前回调存入状态:
// src/directives/clickOutside.ts
import type { Directive } from 'vue'
export type ClickOutsideHandler = (event: PointerEvent) => void
interface ClickOutsideState {
listener: (event: PointerEvent) => void
documentRef: Document
handler?: ClickOutsideHandler
}
const states = new WeakMap<HTMLElement, ClickOutsideState>()
export const vClickOutside: Directive<
HTMLElement,
ClickOutsideHandler | undefined
> = {
mounted(el, binding) {
const documentRef = el.ownerDocument
const state: ClickOutsideState = {
documentRef,
handler:
typeof binding.value === 'function' ? binding.value : undefined,
listener: () => {
// 先占位,下面会替换为真实函数
},
}
state.listener = (event: PointerEvent) => {
const clickedInside = event.composedPath().includes(el)
if (!clickedInside) {
state.handler?.(event)
}
}
states.set(el, state)
documentRef.addEventListener('pointerdown', state.listener, true)
},
updated(el, binding) {
const state = states.get(el)
if (!state) {
return
}
state.handler =
typeof binding.value === 'function' ? binding.value : undefined
},
unmounted(el) {
const state = states.get(el)
if (!state) {
return
}
state.documentRef.removeEventListener(
'pointerdown',
state.listener,
true,
)
states.delete(el)
},
}
这个实现中的关键因果关系是:
mounted创建一个稳定的listener;listener不需要被反复注册;updated只更新状态中的handler;unmounted使用同一个listener引用移除事件;WeakMap以元素为键保存状态,清理后删除映射。
removeEventListener 要求以下条件匹配:
- 事件类型相同;
- 处理函数引用相同;
capture参数匹配。
因此下面的代码不能正确移除上面的监听器:
document.removeEventListener('pointerdown', () => {}, true)
因为这里创建了一个新的函数对象,不是注册时的那个函数。
4.2 使用指令
<!-- src/components/DropdownMenu.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { vClickOutside } from '@/directives/clickOutside'
const open = ref(false)
function close() {
open.value = false
}
</script>
<template>
<div v-click-outside="close" class="dropdown">
<button type="button" @click="open = !open">
菜单
</button>
<div v-if="open" class="menu">
菜单内容
</div>
</div>
</template>
当组件挂载时:
mounted
↓
document 注册 pointerdown 监听器
↓
用户在元素外按下指针
↓
listener 判断 composedPath 是否包含目标元素
↓
调用当前 handler
↓
close 修改响应式状态
↓
Vue 更新 v-if
↓
菜单从 DOM 中移除
↓
组件或指令 unmounted 时移除 document 监听器
如果 v-if 只销毁内部菜单,而外层 <div> 仍然存在,指令不会卸载,因为指令绑定的元素仍然存在。只有绑定指令的元素本身被移除,或者其所在组件被卸载,才会触发 unmounted。
五、DOM 行为:指令修改的 DOM 会不会被 Vue 覆盖
Vue 使用虚拟 DOM 描述模板,再将新旧虚拟 DOM 的差异应用到真实 DOM。指令直接修改真实 DOM 后,Vue 仍然拥有模板中声明的那部分 DOM 的更新权。
可以把一次更新抽象成:
响应式状态 S
↓ render(S)
虚拟 DOM V
↓ patch(Vold, Vnew)
真实 DOM D
↓
指令 mounted / updated 中的直接修改
如果下一次渲染产生的新 VNode 明确描述了同一个属性,Vue 可能再次把该属性写回模板值。
5.1 冲突示例:Vue 状态与指令同时管理 class
<script setup lang="ts">
import { ref } from 'vue'
import { vMark } from '@/directives/mark'
const active = ref(false)
</script>
<template>
<div v-mark :class="{ active }">
内容
</div>
</template>
const vMark: Directive<HTMLElement> = {
mounted(el) {
el.classList.add('active')
},
}
初始挂载后,元素具有 active 类。但如果 active 为 false,后续组件更新时,Vue 可能按照 :class="{ active }" 的结果移除这个类。
根本原因不是“指令失效”,而是两个系统同时声明了同一属性的所有权:
- 指令认为自己负责
class; - 模板也认为自己负责
class; - 后续 patch 只能按照渲染结果继续修正 DOM。
因此,指令应尽量管理一个不会与模板冲突的 DOM 侧效果,或者把状态归还给 Vue:
const vMark: Directive<HTMLElement, boolean> = {
updated(el, binding) {
el.dataset.marked = binding.value ? 'true' : 'false'
},
}
<div v-mark="active" :class="{ active }">
内容
</div>
此时:
active类由模板管理;data-marked属性由指令管理;- 两者不再争夺同一个 DOM 属性。
5.2 DOM 属性与 DOM 状态不是一回事
输入框的 value 是常见边界:
<input :value="text" />
const vSetValue: Directive<HTMLInputElement, string> = {
mounted(el, binding) {
el.value = binding.value
},
}
如果 Vue 后续根据 text 更新输入框,指令直接设置的 el.value 可能被模板值覆盖。更重要的是,输入框当前显示值属于 DOM 状态,而 text 属于应用状态。两者同时作为写入源时,更新顺序会影响结果。
更稳定的方式是:
<script setup lang="ts">
import { ref } from 'vue'
const text = ref('初始值')
</script>
<template>
<input v-model="text" />
</template>
指令可以增强输入框行为,例如控制焦点、选择范围或接入第三方控件,但不应在没有明确协议的情况下同时接管 value 的全部生命周期。
5.3 直接修改 DOM 的正确边界
直接修改 DOM 通常适合以下类型:
el.focus()
el.scrollIntoView()
el.setPointerCapture(pointerId)
el.addEventListener(...)
new ResizeObserver(...).observe(el)
这些操作描述的是“行为”或“外部资源”,而不是模板应持续声明的内容。
下列做法风险更高:
el.innerHTML = '...'
el.textContent = '...'
el.className = '...'
el.style.cssText = '...'
因为它们可能覆盖 Vue 管理的子节点、类名或样式。尤其是 innerHTML,会绕过 Vue 对子树的管理,下一次 patch 还可能试图更新已经被替换的节点,导致状态与 DOM 不一致。
六、指令中的状态管理与资源清理
一个指令只要注册了外部资源,就必须定义对应的释放路径。
常见资源包括:
addEventListener注册的事件;setTimeout、setInterval;ResizeObserver、MutationObserver、IntersectionObserver;- 第三方库返回的实例;
- 订阅、WebSocket 或其他外部连接。
可以使用 WeakMap 将 DOM 元素与资源状态关联:
interface State {
timer: number
observer: ResizeObserver
}
const states = new WeakMap<HTMLElement, State>()
完整例子:
import type { Directive } from 'vue'
const states = new WeakMap<
HTMLElement,
{
timer: number
observer: ResizeObserver
}
>()
export const vWatchSize: Directive<HTMLElement> = {
mounted(el) {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
console.log('尺寸变化', entry.contentRect)
}
})
observer.observe(el)
const timer = window.setInterval(() => {
el.dataset.lastCheckedAt = String(Date.now())
}, 5000)
states.set(el, {
observer,
timer,
})
},
unmounted(el) {
const state = states.get(el)
if (!state) {
return
}
state.observer.disconnect()
window.clearInterval(state.timer)
states.delete(el)
},
}
这里的释放是对称的:
| 创建 | 清理 |
|---|---|
new ResizeObserver |
observer.disconnect() |
observer.observe(el) |
通过 disconnect() 停止观察 |
setInterval |
clearInterval |
states.set(el, state) |
states.delete(el) |
如果忘记清理,可能出现三类故障:
- 重复响应:组件反复挂载后,同一个外部对象上存在多个监听器;
- 内存保留:闭包仍然引用元素、组件状态或大对象;
- 卸载后更新:页面上已经没有元素,但定时器或观察器仍然执行逻辑。
6.1 为什么不能只依赖 WeakMap
WeakMap 可以避免键本身阻止垃圾回收,但它不会自动清理外部资源。
例如:
const states = new WeakMap<HTMLElement, () => void>()
states.set(el, handler)
window.addEventListener('resize', handler)
即使 el 被移除,window 仍然持有 handler。而 handler 的闭包可能又持有 el。因此,WeakMap 只是方便管理状态,不是事件监听器的自动清理机制。
6.2 更新时重新注册监听器还是更新闭包状态
有两种常见策略。
第一种是每次值变化时移除旧监听器,再注册新监听器:
updated(el, binding) {
// remove old
// add new
}
这种方式逻辑直观,但必须准确保存旧处理函数和选项。
第二种是只注册一次,将可变回调保存在状态中:
interface State {
currentHandler?: (event: Event) => void
listener: (event: Event) => void
}
这种方式减少了反复注册,但要求状态设计正确。前面的 vClickOutside 就采用了第二种方法。
如果事件监听器的配置本身发生变化,例如事件类型、捕获阶段或 passive 选项变化,则仍然需要重新注册,因为这些参数不是简单更新回调就能改变的。
七、指令与组件的状态数据流
指令通常应该遵守单向数据流:
组件状态
↓
指令 binding.value / arg / modifiers
↓
DOM 行为或外部资源
↓
用户事件
↓
回调或事件通知组件
↓
组件状态更新
例如:
<div v-tooltip="tooltipText" />
合理的数据流是:
tooltipText
↓
指令显示或更新提示
而不应让指令偷偷修改组件中的任意状态:
binding.instance.someInternalState = ...
指令需要向组件传递结果时,可以通过传入回调:
<div v-click-outside="close" />
也可以传入对象配置:
<div
v-intersection="{
onEnter: loadMore,
threshold: 0.8,
}"
/>
对应类型:
interface IntersectionOptions {
onEnter: (entry: IntersectionObserverEntry) => void
threshold?: number
}
const vIntersection: Directive<
HTMLElement,
IntersectionOptions | undefined
> = {
mounted(el, binding) {
const options = binding.value
if (!options) {
return
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
options.onEnter(entry)
}
},
{
threshold: options.threshold ?? 0,
},
)
observer.observe(el)
// 生产代码还需要把 observer 保存到 WeakMap,
// 并在 unmounted 中 disconnect。
},
}
如果行为需要大量状态、多个 DOM 区域或复杂交互,继续把数据和逻辑塞进指令会使数据流变得隐晦,这通常意味着应该改用组件。
八、指令、组件与组合式函数的替代边界
三者解决的问题不同。
8.1 适合自定义指令的情况
指令适合以下条件:
- 行为绑定到一个明确的真实 DOM 元素;
- 行为不需要额外的模板结构;
- 行为主要涉及浏览器 API 或第三方 DOM 库;
- 资源生命周期可以与该元素一一对应;
- 配置可以通过值、参数和修饰符表达。
例如:
<input v-focus />
<div v-click-outside="close" />
<canvas v-chart="chartOptions" />
<div v-resize="onResize" />
这些行为的共同点是:它们围绕某个已有元素工作,而不是产生一套独立的视图。
8.2 适合组件的情况
如果功能具有自己的:
- DOM 结构;
- 显示与隐藏逻辑;
- 插槽;
- 键盘交互;
- 无障碍语义;
- 加载和错误状态;
- 多个内部元素之间的协调;
就更适合组件。
例如,tooltip 不仅是“给元素加一个浮层”:
触发元素
↓
定位逻辑
↓
浮层 DOM
↓
Teleport 到 body
↓
键盘关闭、焦点管理、ARIA 关联
↓
异步内容和错误状态
这已经包含独立视图结构和状态机。用指令强行实现,通常会出现:
- 指令创建额外 DOM;
- 指令自行管理全局事件;
- 指令需要维护浮层状态;
- 指令还要处理插槽或动态内容;
- 调试时真实 DOM 与组件树脱节。
此时组件更容易表达结构和数据流。
8.3 适合组合式函数的情况
组合式函数适合封装响应式状态和非 DOM 专属逻辑:
const { data, loading, error, reload } = useRequest(...)
如果逻辑需要在多个组件中复用,但并不要求操作某一个特定元素,组合式函数通常优于指令。
指令与组合式函数也可以配合:
// 组合式函数负责业务状态
const { enabled } = useFeatureFlag()
// 指令负责将 enabled 映射为某个元素的 DOM 行为
判断方法可以写成一个约束:
若逻辑的核心输入是“某个具体 DOM 元素”
且输出主要是“该元素的浏览器行为”
→ 倾向使用指令
若逻辑的核心是响应式状态、请求、业务规则
且不依赖某个具体 DOM 元素
→ 倾向使用组合式函数
若逻辑需要独立视图、插槽和组件内部状态
→ 倾向使用组件
这不是 Vue 的硬性规则,而是维护边界的工程判断。
九、在组件上使用自定义指令的边界
Vue 3 允许在组件上使用指令,但它不是普通元素指令的简单替代。
<MyButton v-focus />
组件上的指令需要最终落到真实 DOM 元素上。对于单根组件,Vue 通常会将指令应用到组件的根元素;但这种行为依赖组件的根节点和属性透传结构。
例如:
<!-- MyButton.vue -->
<template>
<button type="button">
<slot />
</button>
</template>
这里根节点明确是 <button>,因此 v-focus 有机会作用于这个根元素。
但多根组件没有唯一根元素:
<template>
<button>按钮</button>
<span>提示</span>
</template>
此时指令无法自然地决定应该操作哪个元素。Vue 会对这类情况给出警告或产生不符合预期的行为,具体表现与版本和组件编译结果有关。不要把“给组件加指令”当作组件内部任意元素的访问机制。
如果组件本身需要支持某种行为,更清晰的接口通常是:
<MyButton autofocus />
或:
<MyButton @focus="onFocus" />
然后由 MyButton 自己决定内部哪个元素获得焦点。这样组件保留了内部 DOM 结构的控制权。
9.1 组件根节点与属性透传
组件接收到的非声明属性可能透传到根元素。自定义指令在组件上的行为也受到这一机制影响。
如果组件使用:
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
})
</script>
<template>
<div class="wrapper">
<button v-bind="$attrs">
<slot />
</button>
</div>
</template>
组件就改变了属性的落点。此时调用方传入的指令是否能作用到期望元素,不能靠调用方猜测,必须由组件明确设计和验证。
生产代码中,如果组件需要暴露 DOM 行为,优先把它设计成显式的 props、事件或组件方法,而不是依赖调用者把指令“穿透”到内部结构。
十、服务端渲染和水合边界
自定义指令的大多数生命周期钩子依赖浏览器 DOM:
mounted(el) {
el.focus()
}
服务端渲染阶段没有真实浏览器元素,因此不能在服务端执行 focus()、window.addEventListener() 或 ResizeObserver 等操作。
如果项目使用 SSR,应把浏览器专属逻辑放在客户端钩子中,并避免在模块顶层直接访问浏览器对象:
const vClientOnly: Directive<HTMLElement> = {
mounted(el) {
if (typeof window === 'undefined') {
return
}
el.scrollIntoView()
},
}
Vue 还提供了指令的 getSSRProps 能力,用于在 SSR 输出阶段生成与指令相关的属性:
import type { ObjectDirective } from 'vue'
const vColor: ObjectDirective<HTMLElement, string> = {
getSSRProps(binding) {
return {
'data-color': binding.value,
}
},
mounted(el, binding) {
el.dataset.color = binding.value
},
}
这里需要区分两件事:
getSSRProps只能生成服务端 HTML 属性;- 它不能在服务端执行浏览器 DOM 行为;
- 客户端
mounted仍然需要完成浏览器侧初始化。
如果服务端输出与客户端首次渲染结果不一致,可能产生水合不匹配。例如服务端输出:
<div data-color="red"></div>
而客户端首次渲染期望的是:
<div data-color="blue"></div>
这类问题不能依靠 mounted 事后修复,因为水合检查发生在客户端接管已有 HTML 的过程中。SSR 指令需要确保服务端属性与客户端首次渲染的预期一致。
十一、异步操作、竞态和卸载后的回调
指令中的异步逻辑还要处理两个问题:
- 元素可能在异步任务完成前被卸载;
- 指令值可能在异步任务完成前发生变化。
例如:
const vLoad: Directive<HTMLElement, string> = {
mounted(el, binding) {
fetch(binding.value)
.then(async (response) => {
const text = await response.text()
el.textContent = text
})
.catch((error) => {
console.error('加载失败', error)
})
},
}
如果请求期间元素被移除,回调仍可能尝试修改已经失效的元素。虽然修改一个已脱离文档的元素不一定立即抛错,但可能写入不再被使用的节点,或者与后续 Vue patch 产生冲突。
可以使用 AbortController,并在卸载时取消请求:
import type { Directive } from 'vue'
interface State {
controller: AbortController
}
const states = new WeakMap<HTMLElement, State>()
export const vLoadText: Directive<HTMLElement, string | undefined> = {
mounted(el, binding) {
if (!binding.value) {
return
}
const controller = new AbortController()
states.set(el, { controller })
fetch(binding.value, {
signal: controller.signal,
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.text()
})
.then((text) => {
// 请求完成时,指令可能已经被卸载。
// abort 通常会阻止正常完成,但仍需保留状态检查习惯。
if (states.get(el)?.controller !== controller) {
return
}
el.textContent = text
})
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
console.error('v-load-text 请求失败', error)
})
},
unmounted(el) {
const state = states.get(el)
state?.controller.abort()
states.delete(el)
},
}
这里还使用了“当前控制器身份检查”:
states.get(el)?.controller !== controller
它防止旧请求完成后覆盖新请求的结果。仅取消请求并不总能替代竞态保护,因为请求可能已经进入不可取消的阶段,或者数据来源并非 fetch。
十二、常见误解和失败表现
12.1 误以为 mounted 只执行一次就能覆盖所有更新
mounted 只对应一次挂载。以下值发生变化时,指令不会自动重新执行 mounted:
<div v-tooltip="message" />
如果 message 改变,需要在 updated 中更新提示内容,或者通过状态对象让外部监听器读取最新值。
12.2 误以为 updated 只在指令值变化时执行
updated 属于元素更新阶段,不是一个专门的 watch(binding.value)。如果只关心值变化,应该显式比较 binding.value 与 binding.oldValue,必要时还要比较参数和修饰符。
12.3 误以为从 DOM 移除元素会自动移除全局监听器
下面的监听器不会因为 el 被移除而自动消失:
window.addEventListener('resize', handler)
必须在 unmounted 中主动调用:
window.removeEventListener('resize', handler)
DOM 节点的生命周期和 window、document 等全局对象的监听器生命周期不是同一个系统。
12.4 误以为指令可以安全替换整个子树
el.innerHTML = '<strong>新的内容</strong>'
如果 el 的子树由 Vue 管理,这种操作会破坏虚拟 DOM 与真实 DOM 的对应关系。下一次更新时可能出现:
- 内容被 Vue 恢复;
- 事件监听器丢失;
- 第三方节点被替换;
- 调试时看到 DOM 与模板不一致。
如果确实需要第三方库完全接管某个区域,应使用清晰的隔离边界,让 Vue 不再同时管理该子树,或者直接把第三方区域封装成组件。
12.5 误以为指令能访问任意组件内部元素
指令绑定到哪里,就操作哪里。它不会自动穿透组件、插槽或 Teleport 去查找“真正想操作的元素”。
如果需要操作组件内部的按钮,应由组件提供:
ref对应的方法;- 明确的 prop;
- 事件;
- 可访问的组件 API。
通过 document.querySelector 全局搜索内部元素,会引入选择器冲突、时序依赖和多实例问题。
十三、诊断指令问题的方法
13.1 先确认指令是否真正绑定到目标元素
可以在各个钩子中输出元素、参数和旧值:
const vDebug: Directive<HTMLElement, unknown> = {
created(el, binding) {
console.log('created', el, binding)
},
mounted(el, binding) {
console.log('mounted', el, binding)
},
updated(el, binding) {
console.log('updated', {
value: binding.value,
oldValue: binding.oldValue,
arg: binding.arg,
modifiers: binding.modifiers,
})
},
unmounted(el) {
console.log('unmounted', el)
},
}
如果 mounted 没有执行,优先检查:
- 指令是否正确注册;
- 模板名称是否匹配;
- 元素是否被条件渲染;
- 是否误把指令绑定到了组件;
- 组件是否存在多根节点;
- 指令是否被另一个同名局部注册覆盖。
13.2 检查事件是否重复注册
在开发者工具中观察一次用户操作是否触发多次回调。如果组件反复进入和离开 v-if 后,回调次数不断增加,通常是:
unmounted没有清理;- 清理时使用了不同的函数引用;
- 监听器选项不匹配;
- 清理逻辑被异常路径跳过。
可以在注册和清理处分别记录唯一标识:
console.debug('register', el)
console.debug('unregister', el)
正常情况下,每次注册都应有对应的清理。
13.3 检查 DOM 是否被 Vue 覆盖
如果指令设置了属性,但下一次响应式更新后属性恢复,先检查模板是否也声明了同一属性:
<div :class="classes" />
el.className = 'manual-class'
这不是生命周期失效,而是 DOM 所有权冲突。解决方法是明确由一方管理该属性,或者让指令使用独立的数据属性、外部插件状态或事件行为。
十四、设计一个指令前的完整约束
一个可维护的指令,至少应该回答下面这些具体问题:
14.1 目标元素是谁
如果指令需要绑定在组件上才能工作,必须确认组件最终是否有稳定的单根元素,以及该指令是否真的会落到目标 DOM。
14.2 初始化发生在哪个钩子
- 只需创建内部状态:可以考虑
created; - 需要真实 DOM:通常用
mounted; - 需要响应绑定值变化:实现
updated; - 需要外部资源释放:实现
unmounted。
14.3 更新的触发条件是什么
如果只在值变化时处理,比较:
binding.value !== binding.oldValue
如果参数或修饰符也影响行为,还要把它们纳入更新判断。
14.4 外部资源如何释放
对每一个创建操作写出反操作:
addEventListener → removeEventListener
setTimeout → clearTimeout
setInterval → clearInterval
observe → disconnect / unobserve
插件初始化 → 插件销毁方法
订阅 → unsubscribe
fetch → AbortController.abort
如果无法回答清理方式,说明这个资源不应直接在指令中创建,或者需要重新设计生命周期边界。
14.5 Vue 和指令分别管理什么
建议将职责拆开:
Vue 模板:
文本、属性、类名、样式、条件渲染、列表和组件结构
自定义指令:
focus、滚动、观察器、第三方 DOM 插件、全局事件桥接
这不是绝对限制,但可以减少 patch 与直接 DOM 操作之间的冲突。
十五、结论:指令是 DOM 行为适配层,不是隐藏的组件系统
Vue 自定义指令的生命周期提供了一个明确的元素级资源边界:
created / beforeMount
↓
mounted:初始化真实 DOM 行为
↓
beforeUpdate / updated:同步绑定值或配置变化
↓
beforeUnmount / unmounted:释放外部资源
指令直接修改的是真实 DOM,但真实 DOM 仍然受到 Vue 虚拟 DOM patch 的影响。只要模板和指令同时管理同一个属性,就存在被覆盖或状态不一致的可能。稳定的做法是明确 DOM 所有权,让 Vue 管理声明式视图,让指令管理元素行为和外部资源。
当功能只围绕一个已有元素展开时,指令是合适的抽象;当功能需要独立结构、复杂状态、插槽、无障碍语义或组件间协作时,组件才是更清晰的边界。能够正确选择这条边界,比记住某一个指令钩子的名称更重要。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 编译宏:defineProps、defineEmits、defineModel 与泛型组件
- 下一篇:Vue Plugin 与 Provide/Inject:依赖注入、类型和作用域
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论