前言
在 Vue 开发中,我们通常遵循 数据驱动视图 的理念,很少直接操作 DOM。但有些场景却绕不开 DOM 操作,比如:输入框自动获取焦点、点击外部区域关闭下拉框、图片懒加载、权限控制等。这些场景如果写在组件内部,会导致代码冗余、逻辑分散。
自定义指令正是为解决这类问题而生,它提供了一种优雅的方式来封装 DOM 操作逻辑,让代码更加简洁、可复用。本文将深入探讨自定义指令的生命周期、钩子参数,并通过大量实战案例,帮我们掌握这一强大的抽象工具。
什么时候需要自定义指令?
直接操作 DOM 的场景
虽然 Vue 团队官方推崇数据驱动,而且建议不要在 Vue 项目中直接操作 DOM ,但有些操作又必须直接操作 DOM:
html
<template>
<input ref="inputRef" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
const inputRef = ref(null)
onMounted(() => {
// 每次都要写这个逻辑
inputRef.value?.focus()
})
</script>
此时,如果用自定义指令,就可以优雅地解决这个问题:
html
<template>
<!-- ✅ 声明式的指令 -->
<input v-focus />
</template>
<script setup>
// 指令只定义一次,到处使用
</script>
与第三方库集成
当我们需要集成非 Vue 的第三方库时,自定义指令是理想的桥梁:
typescript
// 集成 Clipboard.js 复制功能
app.directive('clipboard', {
mounted(el, binding) {
const clipboard = new ClipboardJS(el, {
text: () => binding.value
})
clipboard.on('success', () => {
alert('复制成功')
})
}
})
复用 DOM 相关的逻辑
有些 DOM 操作逻辑需要在多个组件中重复使用,比如:
- 点击外部关闭:下拉菜单、模态框
- 滚动加载更多:无限滚动列表
- 拖拽调整大小:可调整尺寸的面板
- 权限控制:根据权限隐藏元素
将这些逻辑封装成指令,就可以实现 一次定义,到处使用。
指令的生命周期钩子
七个钩子函数详解
自定义指令提供了七个钩子函数,覆盖了指令从创建到销毁的完整过程:
typescript
const myDirective = {
// 在绑定元素的 attribute 或事件监听器被应用之前调用
created(el, binding, vnode) {
console.log('created')
},
// 在元素被插入到 DOM 前调用
beforeMount(el, binding, vnode) {
console.log('beforeMount')
},
// 在绑定元素的父组件及他自己的所有子节点都挂载完成后调用
mounted(el, binding, vnode) {
console.log('mounted')
},
// 在包含组件的 VNode 更新之前调用
beforeUpdate(el, binding, vnode, prevVnode) {
console.log('beforeUpdate')
},
// 在包含组件的 VNode 及其子组件的 VNode 更新之后调用
updated(el, binding, vnode, prevVnode) {
console.log('updated')
},
// 在绑定元素的父组件卸载前调用
beforeUnmount(el, binding, vnode) {
console.log('beforeUnmount')
},
// 在绑定元素的父组件卸载后调用
unmounted(el, binding, vnode) {
console.log('unmounted')
}
}
每个钩子的适用场景
| 钩子函数 | 适用场景 | 示例 |
|---|---|---|
created |
初始化只在 JS 层面工作的内容 | 添加事件监听前的准备 |
beforeMount |
第一次 DOM 渲染前的修改 | 设置初始样式 |
mounted |
最常用,操作真实 DOM | 获取焦点、监听事件、初始化第三方库 |
beforeUpdate |
基于更新的响应式数据修改 DOM | 更新前的数据验证 |
updated |
响应式数据更新后的操作 | 更新第三方库的配置 |
beforeUnmount |
清理前的最后操作 | 保存状态、执行动画 |
unmounted |
清理工作 | 移除事件监听、销毁实例 |
钩子函数的参数详解
每个钩子函数都接收相同的参数:
typescript
interface Binding {
value: any // 传递给指令的值
oldValue: any // 之前的值,仅在 beforeUpdate 和 updated 中可用
arg: string // 传递给指令的参数
modifiers: { [key: string]: boolean } // 修饰符对象
instance: ComponentPublicInstance // 使用该指令的组件实例
dir: Object // 指令的定义对象
}
function hook(
el: HTMLElement, // 指令绑定的元素
binding: Binding, // 包含指令所有信息的对象
vnode: VNode, // 元素的虚拟节点
prevVnode: VNode | null // 上一个虚拟节点,仅在 update 钩子中可用
) {}
钩子函数的参数使用示例
html
<template>
<div
v-demo:foo.bar.baz="value"
@click="value++"
>
点击增加
</div>
</template>
<script setup>
const value = ref(1)
</script>
<!-- 指令实现 -->
<script>
app.directive('demo', {
mounted(el, binding) {
console.log(binding.value) // 1
console.log(binding.arg) // 'foo'
console.log(binding.modifiers) // { bar: true, baz: true }
console.log(binding.instance) // 当前组件实例
},
updated(el, binding) {
console.log(binding.value) // 更新后的值
console.log(binding.oldValue) // 更新前的值
}
})
</script>
实用自定义指令实战
v-focus:自动获取焦点
最简单的实用指令:
typescript
// focus.ts
export const vFocus = {
mounted: (el: HTMLElement) => {
el.focus()
}
}
// 或者支持延迟获取焦点
export const vFocus = {
mounted(el: HTMLElement, binding: { value?: number }) {
if (binding.value) {
setTimeout(() => el.focus(), binding.value)
} else {
el.focus()
}
}
}
使用:
html
<template>
<!-- 立即获取焦点 -->
<input v-focus />
<!-- 延迟500ms获取焦点 -->
<input v-focus="500" />
</template>
v-click-outside:点击外部关闭
这是最常用的指令之一,用于下拉菜单、模态框等:
typescript
// click-outside.ts
export const vClickOutside = {
mounted(el: HTMLElement, binding: { value: () => void }) {
// 使用自定义属性保存处理函数,方便移除
el._clickOutsideHandler = (event: Event) => {
// 点击的元素不是目标元素本身也不是它的子元素
if (!el.contains(event.target as Node)) {
binding.value()
}
}
// 使用 setTimeout 确保不捕获触发绑定的事件
setTimeout(() => {
document.addEventListener('click', el._clickOutsideHandler)
}, 0)
},
unmounted(el: HTMLElement) {
document.removeEventListener('click', el._clickOutsideHandler)
delete el._clickOutsideHandler
}
}
增强版:支持排除特定元素
typescript
export const vClickOutside = {
mounted(el: HTMLElement, binding: {
value: () => void,
arg?: string // 排除的选择器
}) {
el._clickOutsideHandler = (event: Event) => {
const target = event.target as HTMLElement
// 检查是否点击了排除元素
if (binding.arg) {
const excludeEl = document.querySelector(binding.arg)
if (excludeEl?.contains(target)) {
return
}
}
if (!el.contains(target)) {
binding.value()
}
}
setTimeout(() => {
document.addEventListener('click', el._clickOutsideHandler)
}, 0)
},
unmounted(el: HTMLElement) {
document.removeEventListener('click', el._clickOutsideHandler)
}
}
使用:
html
<template>
<div class="dropdown">
<button ref="toggleBtn">菜单</button>
<div
v-click-outside="closeDropdown"
v-click-outside:".toggle-btn"="closeDropdown"
class="dropdown-menu"
>
<ul>
<li>选项1</li>
<li>选项2</li>
</ul>
</div>
</div>
</template>
v-debounce:输入防抖
处理输入框的高频事件:
typescript
// debounce.ts
export const vDebounce = {
mounted(el: HTMLInputElement, binding: {
value: (...args: any[]) => void,
arg?: string // 事件类型,默认 'input'
}) {
const eventType = binding.arg || 'input'
let timer: ReturnType<typeof setTimeout> | null = null
el._debounceHandler = (event: Event) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
binding.value(event)
timer = null
}, 300) // 固定300ms,也可以从 binding.value 获取
}
el.addEventListener(eventType, el._debounceHandler)
},
unmounted(el: HTMLInputElement) {
const eventType = binding?.arg || 'input'
el.removeEventListener(eventType, el._debounceHandler)
delete el._debounceHandler
}
}
可配置版本:
typescript
interface DebounceBinding {
value: (...args: any[]) => void
arg?: 'input' | 'change' | 'keyup'
modifiers?: {
[key: string]: boolean // 使用修饰符指定延迟时间
}
}
export const vDebounce = {
mounted(el: HTMLInputElement, binding: DebounceBinding) {
const eventType = binding.arg || 'input'
// 从修饰符中获取延迟时间,如 v-debounce:input.500
let delay = 300 // 默认
for (const mod in binding.modifiers) {
const num = parseInt(mod)
if (!isNaN(num)) {
delay = num
break
}
}
el._debounceHandler = (event: Event) => {
if (el._debounceTimer) clearTimeout(el._debounceTimer)
el._debounceTimer = setTimeout(() => {
binding.value(event)
el._debounceTimer = null
}, delay)
}
el.addEventListener(eventType, el._debounceHandler)
},
unmounted(el: HTMLInputElement) {
const eventType = binding?.arg || 'input'
el.removeEventListener(eventType, el._debounceHandler)
if (el._debounceTimer) {
clearTimeout(el._debounceTimer)
}
}
}
使用:
html
<template>
<input
v-debounce:input.500="handleSearch"
placeholder="搜索..."
/>
</template>
<script setup>
function handleSearch(event) {
console.log('搜索:', event.target.value)
// 调用 API
}
</script>
v-permission:权限控制
根据用户权限显示/隐藏元素:
typescript
// permission.ts
import { useUserStore } from '@/stores/user'
export const vPermission = {
mounted(el: HTMLElement, binding: {
value: string | string[], // 需要的权限
modifiers?: {
and?: boolean // 需要同时满足多个权限
}
}) {
const userStore = useUserStore()
const permissions = userStore.permissions || []
const requiredPermissions = Array.isArray(binding.value)
? binding.value
: [binding.value]
let hasPermission = false
if (binding.modifiers?.and) {
// 需要同时拥有所有权限
hasPermission = requiredPermissions.every(p => permissions.includes(p))
} else {
// 拥有任意一个权限即可
hasPermission = requiredPermissions.some(p => permissions.includes(p))
}
if (!hasPermission) {
el.style.display = 'none'
// 或者移除元素
// el.parentNode?.removeChild(el)
}
},
// 当权限更新时重新检查(如用户切换角色)
updated(el: HTMLElement, binding: { value: string | string[] }) {
const userStore = useUserStore()
const permissions = userStore.permissions || []
const requiredPermissions = Array.isArray(binding.value)
? binding.value
: [binding.value]
const hasPermission = requiredPermissions.some(p => permissions.includes(p))
if (!hasPermission) {
el.style.display = 'none'
} else {
el.style.display = ''
}
}
}
使用:
html
<template>
<!-- 需要 admin 权限 -->
<button v-permission="'admin'">管理用户</button>
<!-- 需要 admin 或 manager 权限 -->
<button v-permission="['admin', 'manager']">高级操作</button>
<!-- 需要同时拥有 admin 和 finance 权限 -->
<button v-permission:and="['admin', 'finance']">财务操作</button>
</template>
v-lazy:图片懒加载
图片懒加载是性能优化的常用手段:
typescript
// lazy.ts
export const vLazy = {
mounted(el: HTMLImageElement, binding: { value: string }) {
// 保存原始图片地址
el.dataset.src = binding.value
// 创建 IntersectionObserver
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 图片进入视口,加载真实图片
el.src = el.dataset.src!
observer.unobserve(el)
// 图片加载完成后添加淡入效果
el.classList.add('loaded')
}
})
}, {
rootMargin: '50px' // 提前50px加载
})
observer.observe(el)
// 保存 observer 以便清理
el._lazyObserver = observer
},
unmounted(el: HTMLImageElement) {
if (el._lazyObserver) {
el._lazyObserver.unobserve(el)
el._lazyObserver.disconnect()
}
}
}
增强版:支持加载中、加载失败占位
typescript
interface LazyBinding {
value: string // 真实图片地址
arg?: string // 加载中占位图
modifiers?: {
error?: string // 加载失败占位图
}
}
export const vLazy = {
mounted(el: HTMLImageElement, binding: LazyBinding) {
// 设置加载中占位图
if (binding.arg) {
el.src = binding.arg
}
// 处理加载失败
el.onerror = () => {
if (binding.modifiers?.error) {
el.src = binding.modifiers.error
}
}
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 创建新图片对象预加载
const img = new Image()
img.src = binding.value
img.onload = () => {
el.src = binding.value
el.classList.add('fade-in')
}
observer.unobserve(el)
}
})
}, {
rootMargin: '50px'
})
observer.observe(el)
el._lazyObserver = observer
},
unmounted(el: HTMLImageElement) {
if (el._lazyObserver) {
el._lazyObserver.unobserve(el)
el._lazyObserver.disconnect()
}
}
}
使用:
html
<template>
<img
v-lazy:loading.gif.error="'error.png'"
:data-src="imageUrl"
alt="懒加载图片"
/>
</template>
<style>
img.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
指令的参数修饰符和值
使用 binding.arg 传递参数
参数让指令更加灵活:
typescript
// 示例:滚动指令
app.directive('scroll', {
mounted(el, binding) {
const handler = () => {
// 根据参数执行不同逻辑
switch (binding.arg) {
case 'bottom':
if (el.scrollTop + el.clientHeight >= el.scrollHeight) {
binding.value()
}
break
case 'top':
if (el.scrollTop === 0) {
binding.value()
}
break
case 'direction':
// 检测滚动方向
break
}
}
el.addEventListener('scroll', handler)
el._scrollHandler = handler
},
unmounted(el) {
el.removeEventListener('scroll', el._scrollHandler)
}
})
使用:
html
<template>
<div
v-scroll:bottom="loadMore"
v-scroll:top="refresh"
class="scroll-container"
>
<!-- 内容 -->
</div>
</template>
使用 binding.modifiers 处理修饰符
修饰符是布尔值,适合开关型配置:
typescript
// 拖拽指令
app.directive('draggable', {
mounted(el, binding) {
el.style.position = 'absolute'
el.style.cursor = 'move'
const handlers = {
mousedown: (e: MouseEvent) => {
e.preventDefault()
const startX = e.clientX - el.offsetLeft
const startY = e.clientY - el.offsetTop
const onMouseMove = (e: MouseEvent) => {
// 根据修饰符限制移动方向
if (!binding.modifiers?.horizontal) {
el.style.top = (e.clientY - startY) + 'px'
}
if (!binding.modifiers?.vertical) {
el.style.left = (e.clientX - startX) + 'px'
}
// 边界限制
if (binding.modifiers?.boundary) {
const parent = el.parentElement
if (parent) {
const left = parseInt(el.style.left)
const top = parseInt(el.style.top)
el.style.left = Math.max(0, Math.min(left, parent.clientWidth - el.clientWidth)) + 'px'
el.style.top = Math.max(0, Math.min(top, parent.clientHeight - el.clientHeight)) + 'px'
}
}
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
}
el.addEventListener('mousedown', handlers.mousedown)
el._dragHandlers = handlers
},
unmounted(el) {
el.removeEventListener('mousedown', el._dragHandlers.mousedown)
}
})
使用:
html
<template>
<!-- 只能水平移动 -->
<div v-draggable.horizontal class="draggable">水平拖拽</div>
<!-- 只能垂直移动 -->
<div v-draggable.vertical class="draggable">垂直拖拽</div>
<!-- 边界限制 -->
<div v-draggable.boundary class="draggable">带边界</div>
<!-- 自由拖拽 -->
<div v-draggable class="draggable">自由拖拽</div>
</template>
动态更新指令的逻辑
当指令的值变化时,可以在 updated 钩子中响应:
typescript
// 颜色指令
app.directive('color', {
mounted(el, binding) {
el.style.color = binding.value
},
updated(el, binding) {
// 当值变化时更新颜色
el.style.color = binding.value
}
})
使用动态值:
html
<template>
<div v-color="color">颜色会变化</div>
<button @click="color = 'red'">红色</button>
<button @click="color = 'blue'">蓝色</button>
</template>
<script setup>
const color = ref('black')
</script>
在 Composition API 中使用指令
使用 v-bind 动态绑定指令
可以通过 v-bind 动态决定是否应用指令:
html
<template>
<input
v-bind="directives"
v-model="searchText"
/>
</template>
<script setup>
import { computed } from 'vue'
const isDisabled = ref(false)
const directives = computed(() => {
const dirs = {}
// 根据条件添加指令
if (!isDisabled.value) {
dirs.focus = {} // 应用 v-focus 指令
}
dirs.debounce = {
value: handleSearch,
arg: 'input',
modifiers: { 500: true }
}
return dirs
})
</script>
组合式函数中封装指令逻辑
有时我们需要在组合式函数中封装指令相关的逻辑:
typescript
// composables/useDraggable.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useDraggable(options: {
horizontal?: boolean
vertical?: boolean
boundary?: boolean
} = {}) {
const element = ref<HTMLElement | null>(null)
const startDrag = (e: MouseEvent) => {
if (!element.value) return
e.preventDefault()
const el = element.value
const startX = e.clientX - el.offsetLeft
const startY = e.clientY - el.offsetTop
const onMouseMove = (e: MouseEvent) => {
if (!options.horizontal) {
el.style.top = (e.clientY - startY) + 'px'
}
if (!options.vertical) {
el.style.left = (e.clientX - startX) + 'px'
}
if (options.boundary && el.parentElement) {
const parent = el.parentElement
const left = parseInt(el.style.left)
const top = parseInt(el.style.top)
el.style.left = Math.max(0, Math.min(left, parent.clientWidth - el.clientWidth)) + 'px'
el.style.top = Math.max(0, Math.min(top, parent.clientHeight - el.clientHeight)) + 'px'
}
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
onMounted(() => {
if (element.value) {
element.value.addEventListener('mousedown', startDrag)
}
})
onUnmounted(() => {
if (element.value) {
element.value.removeEventListener('mousedown', startDrag)
}
})
return {
element,
// 可以返回一个指令对象
draggable: {
mounted(el: HTMLElement) {
element.value = el
el.style.position = 'absolute'
el.style.cursor = 'move'
}
}
}
}
在组件中使用:
html
<template>
<div v-draggable class="box">拖拽我</div>
</template>
<script setup>
import { useDraggable } from './composables/useDraggable'
// 方式1:直接使用指令
const { draggable } = useDraggable({ horizontal: true })
// 方式2:使用组合式函数控制
const { element } = useDraggable({ boundary: true })
</script>
TypeScript 类型支持
为自定义指令定义类型
typescript
// directives/types.ts
import { Directive } from 'vue'
// 权限指令的类型
export interface PermissionDirective {
(el: HTMLElement, binding: {
value: string | string[] // 权限列表
modifiers?: {
and?: boolean // 是否需要同时满足
}
}): void
}
// 防抖指令的类型
export interface DebounceDirective {
(el: HTMLElement, binding: {
value: (event: Event) => void // 回调函数
arg?: 'input' | 'change' // 事件类型
modifiers?: { // 延迟时间,如 v-debounce.500
[key: string]: boolean
}
}): void
}
// 定义指令对象类型
export type AppDirectives = {
'permission': Directive<HTMLElement, string | string[]>
'debounce': Directive<HTMLElement, (event: Event) => void>
'click-outside': Directive<HTMLElement, () => void>
'focus': Directive<HTMLElement, number | undefined>
'lazy': Directive<HTMLImageElement, string>
}
扩展 Vue 的类型声明
为了让 TypeScript 识别自定义指令,需要扩展 Vue 的类型:
typescript
// directives/index.ts
import { App } from 'vue'
import { vFocus } from './focus'
import { vClickOutside } from './click-outside'
import { vDebounce } from './debounce'
import { vPermission } from './permission'
import { vLazy } from './lazy'
export function setupDirectives(app: App) {
app.directive('focus', vFocus)
app.directive('click-outside', vClickOutside)
app.directive('debounce', vDebounce)
app.directive('permission', vPermission)
app.directive('lazy', vLazy)
}
// types/vue.d.ts
import { Directive } from 'vue'
declare module '@vue/runtime-core' {
export interface ComponentCustomProperties {
// 如果有全局属性可以添加
}
// 扩展全局指令类型
export interface DirectiveBinding {
// 可以添加自定义属性
}
}
// 为导入的指令提供类型
declare module '@/directives' {
export const vFocus: Directive<HTMLElement, number>
export const vClickOutside: Directive<HTMLElement, () => void>
export const vDebounce: Directive<HTMLElement, (event: Event) => void>
export const vPermission: Directive<HTMLElement, string | string[]>
export const vLazy: Directive<HTMLImageElement, string>
}
在组件中局部注册并获取类型
html
<!-- 局部注册指令并获取类型支持 -->
<script setup lang="ts">
import { vFocus } from '@/directives/focus'
import { vDebounce } from '@/directives/debounce'
// 局部注册
defineProps<{
modelValue: string
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
// vDebounce 现在有类型提示了
function handleSearch(event: Event) {
const value = (event.target as HTMLInputElement).value
// ...
}
</script>
<template>
<input
v-focus
v-debounce:input.300="handleSearch"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
自定义指令的设计模式
自定义指令的使用决策树
指令设计的最佳实践清单
- 单一职责:一个指令只做一件事
- 命名清晰 :如
v-focus、v-click-outside - 提供默认行为:在无参数时也能工作
- 及时清理 :在
unmounted中移除事件监听 - 使用 TypeScript:提供完整的类型定义
- 考虑边界情况:空值、异常情况处理
- 性能优化:避免不必要的 DOM 操作
指令模板
typescript
import { Directive } from 'vue'
// 定义参数类型
interface DirectiveBindingType {
value: string // 主要值
arg?: 'option1' | 'option2' // 参数
modifiers?: { // 修饰符
[key: string]: boolean
}
}
// 指令实现
const myDirective: Directive<HTMLElement, DirectiveBindingType> = {
created(el, binding, vnode) {
// 初始化
},
mounted(el, binding) {
// DOM 已挂载,执行主要逻辑
// 保存处理函数以便清理
el._handler = (event: Event) => {
// 处理逻辑
}
el.addEventListener('click', el._handler)
},
updated(el, binding) {
// 当 binding.value 变化时更新
},
unmounted(el) {
// 清理
el.removeEventListener('click', el._handler)
delete el._handler
}
}
export default myDirective
最终建议
自定义指令是 Vue 提供的一个强大但容易被忽视的特性。它最适合:
- 原生 DOM 操作:需要直接访问 DOM 的场景
- 跨组件逻辑复用:多个组件共享的 DOM 相关逻辑
- 声明式 API:让模板更加声明式、可读性更好
结语
当我们在组件中频繁使用 ref 配合生命周期钩子操作 DOM 时,不妨考虑一下,是否应该将其封装成自定义指令。这不仅能让组件代码更加简洁,还能让这些逻辑在项目中被轻松复用。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!