Vue 自定义指令
除了 Vue 内置的核心指令(如 v-model 和 v-show),Vue 还允许你注册自己的自定义指令。自定义指令主要用于封装涉及底层 DOM 访问的可复用逻辑。
何时使用自定义指令
Vue 提供了两种代码复用形式:组件和组合式函数。组件是主要的构建块,而组合式函数专注于复用状态逻辑。自定义指令则主要用于复用涉及对普通元素进行底层 DOM 访问的逻辑。
指令 vs 组件
| 特性 | 组件 | 组合式函数 | 自定义指令 |
|---|---|---|---|
| 主要用途 | 构建 UI 结构 | 复用状态逻辑 | 底层 DOM 操作 |
| 模板中使用 | 标签形式 | 函数调用 | 指令形式 |
| 适用场景 | 可复用的 UI 块 | 跨组件的状态逻辑 | 直接操作 DOM |
| 示例 | <Modal /> | useCounter() | v-focus |
合适的使用场景
自定义指令应该仅在所需功能只能通过直接 DOM 操作实现时使用。例如:
<script setup>
// v-focus 指令:让输入框自动获取焦点
const vFocus = {
mounted: (el) => el.focus()
}
</script>
<template>
<input v-focus />
</template>
这个指令比 HTML 原生的 autofocus 属性更有用,因为它不仅在页面加载时有效,当元素被 Vue 动态插入时也会生效。
应该使用内置指令的情况
如果可以通过声明式模板配合内置指令(如 v-bind)实现,应该优先使用内置指令,因为它们更高效且对服务端渲染更友好:
<!-- 推荐:使用内置指令 -->
<div :class="{ active: isActive }"></div>
<!-- 不推荐:用自定义指令实现同样的功能 -->
<div v-active="isActive"></div>
创建自定义指令
在 <script setup> 中定义
在 <script setup> 中,任何以 v 前缀开头的 camelCase 变量都可以用作自定义指令。例如,vFocus 可以在模板中作为 v-focus 使用:
<script setup>
// 启用 v-focus 指令
const vFocus = {
mounted: (el) => el.focus()
}
</script>
<template>
<input v-focus />
</template>
在选项式 API 中定义
如果不使用 <script setup>,可以使用 directives 选项注册:
export default {
setup() {
// ...
},
directives: {
// 在模板中启用 v-focus
focus: {
mounted: (el) => el.focus()
}
}
}
全局注册
在应用级别全局注册自定义指令,使其在所有组件中可用:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 让 v-focus 在所有组件中可用
app.directive('focus', {
mounted: (el) => el.focus()
})
app.mount('#app')
指令钩子函数
一个指令定义对象可以提供以下钩子函数(都是可选的):
const myDirective = {
// 在绑定元素的属性前
// 或事件监听器应用前调用
created(el, binding, vnode) {
// 参数详情见下文
},
// 在元素被插入到 DOM 前调用
beforeMount(el, binding, vnode) {},
// 在绑定元素的父组件
// 及其所有子节点都挂载后调用
mounted(el, binding, vnode) {},
// 父组件更新前调用
beforeUpdate(el, binding, vnode, prevVnode) {},
// 在绑定元素的父组件及
// 所有子节点都更新后调用
updated(el, binding, vnode, prevVnode) {},
// 父组件卸载前调用
beforeUnmount(el, binding, vnode) {},
// 父组件卸载时调用
unmounted(el, binding, vnode) {}
}
钩子调用时机详解
┌─────────────────────────────────────────────────────────────────┐
│ 指令生命周期执行时机 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ created 元素创建,属性和事件监听器还未应用 │
│ │ │
│ ▼ │
│ beforeMount 元素即将插入 DOM │
│ │ │
│ ▼ │
│ [元素插入DOM] │
│ │ │
│ ▼ │
│ mounted 元素已挂载,可以安全地访问 DOM │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ beforeUpdate → 更新 → updated │ ← 响应式数据变化时循环 │
│ └─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ beforeUnmount 组件即将卸载 │
│ │ │
│ ▼ │
│ unmounted 组件已卸载,执行清理工作 │
│ │
└─────────────────────────────────────────────────────────────────┘
各钩子的典型用途
| 钩子 | 典型用途 |
|---|---|
created | 在属性应用前修改元素,或设置初始状态 |
beforeMount | 在元素插入 DOM 前执行准备工作 |
mounted | 访问 DOM、初始化第三方库、添加事件监听 |
beforeUpdate | 在更新前保存状态或执行清理 |
updated | 响应值变化更新 DOM 或状态 |
beforeUnmount | 卸载前的准备工作 |
unmounted | 清理副作用(移除事件监听、定时器等) |
钩子参数
指令钩子会被传入以下参数:
el:指令绑定到的元素。可以用于直接操作 DOM。binding:一个对象,包含以下属性。vnode:代表绑定元素的底层 VNode。prevVnode:代表上一个渲染中绑定元素的 VNode。仅在beforeUpdate和updated钩子中可用。
binding 对象详解
binding 对象包含以下属性:
{
// 传递给指令的值
// 例如 v-my-directive="1 + 1" 中,值是 2
value: any,
// 之前的值,仅在 beforeUpdate 和 updated 中可用
// 无论值是否改变都可用
oldValue: any,
// 传递给指令的参数,如果有的话
// 例如 v-my-directive:foo 中,arg 是 "foo"
arg: string | undefined,
// 包含修饰符的对象(如果有)
// 例如 v-my-directive.foo.bar 中,modifiers 是 { foo: true, bar: true }
modifiers: { [key: string]: boolean },
// 使用该指令的组件实例
instance: ComponentPublicInstance,
// 指令定义对象
dir: Directive
}
参数示例
<template>
<div v-example:foo.bar="someValue"></div>
</template>
binding 参数将是:
{
arg: 'foo',
modifiers: { bar: true },
value: /* someValue 的值 */,
oldValue: /* 上一次更新时的值 */
}
动态参数
与内置指令一样,自定义指令参数可以是动态的:
<template>
<div v-example:[dynamicArg]="value"></div>
</template>
<script setup>
import { ref } from 'vue'
const dynamicArg = ref('initial')
const value = ref('hello')
// 参数会根据 dynamicArg 响应式更新
</script>
除了 el 外,你应该将这些参数视为只读,永远不要修改它们。如果需要跨钩子共享信息,建议通过元素的 dataset 来实现。
函数简写
当自定义指令在 mounted 和 updated 时需要相同的行为,且不需要其他钩子时,可以将指令定义为函数:
// 简写形式
app.directive('color', (el, binding) => {
// 这会在 mounted 和 updated 时都调用
el.style.color = binding.value
})
等价于:
// 完整形式
app.directive('color', {
mounted(el, binding) {
el.style.color = binding.value
},
updated(el, binding) {
el.style.color = binding.value
}
})
对象字面量
如果指令需要多个值,可以传入 JavaScript 对象字面量。指令可以接受任何有效的 JavaScript 表达式:
<template>
<div v-demo="{ color: 'white', text: 'hello!' }"></div>
</template>
<script setup>
const vDemo = (el, binding) => {
console.log(binding.value.color) // => "white"
console.log(binding.value.text) // => "hello!"
}
</script>
实际应用示例
v-focus 自动聚焦
// directives/vFocus.js
export const vFocus = {
mounted(el) {
// 聚焦元素
el.focus()
}
}
<script setup>
import { vFocus } from './directives/vFocus'
</script>
<template>
<input v-focus type="text" placeholder="自动聚焦" />
</template>
v-click-outside 点击外部
检测点击是否在元素外部,常用于关闭下拉菜单、模态框等:
// directives/vClickOutside.js
export const vClickOutside = {
mounted(el, binding) {
// 在元素上存储处理函数,以便卸载时移除
el._clickOutside = (event) => {
// 如果点击的元素不在绑定元素内
if (!el.contains(event.target)) {
// 调用传入的回调函数
binding.value(event)
}
}
document.addEventListener('click', el._clickOutside)
},
unmounted(el) {
// 清理事件监听器
document.removeEventListener('click', el._clickOutside)
}
}
<script setup>
import { ref } from 'vue'
import { vClickOutside } from './directives/vClickOutside'
const showModal = ref(false)
function closeDropdown() {
showModal.value = false
}
</script>
<template>
<button @click="showModal = true">打开下拉菜单</button>
<div v-if="showModal" v-click-outside="closeDropdown" class="dropdown">
<p>点击外部会关闭此菜单</p>
</div>
</template>
<style scoped>
.dropdown {
position: absolute;
background: white;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
</style>
v-longpress 长按
实现长按触发事件,支持自定义时长:
// directives/vLongpress.js
export const vLongpress = {
mounted(el, binding) {
// 确保传入的是函数
if (typeof binding.value !== 'function') {
console.warn('v-longpress 需要一个函数作为值')
return
}
let pressTimer = null
const duration = parseInt(binding.arg) || 500 // 默认 500ms
const start = (e) => {
// 鼠标右键或触摸多点触控不触发
if (e.type === 'mousedown' && e.button !== 0) return
if (e.type === 'touchstart' && e.touches.length > 1) return
if (pressTimer === null) {
pressTimer = setTimeout(() => {
binding.value(e)
}, duration)
}
}
const cancel = () => {
if (pressTimer !== null) {
clearTimeout(pressTimer)
pressTimer = null
}
}
// 存储处理函数以便卸载时清理
el._longpressHandlers = { start, cancel }
// 鼠标事件
el.addEventListener('mousedown', start)
el.addEventListener('mouseup', cancel)
el.addEventListener('mouseleave', cancel)
// 触摸事件
el.addEventListener('touchstart', start, { passive: true })
el.addEventListener('touchend', cancel)
el.addEventListener('touchcancel', cancel)
},
unmounted(el) {
const { start, cancel } = el._longpressHandlers || {}
if (start) {
el.removeEventListener('mousedown', start)
el.removeEventListener('mouseup', cancel)
el.removeEventListener('mouseleave', cancel)
el.removeEventListener('touchstart', start)
el.removeEventListener('touchend', cancel)
el.removeEventListener('touchcancel', cancel)
}
}
}
<script setup>
import { vLongpress } from './directives/vLongpress'
function handleLongpress() {
alert('长按触发!')
}
function handleExtraLongpress() {
alert('超长按触发(2秒)!')
}
</script>
<template>
<button v-longpress="handleLongpress">
长按我(500ms)
</button>
<button v-longpress:2000="handleExtraLongpress">
超长按我(2000ms)
</button>
</template>
v-lazy 图片懒加载
使用 IntersectionObserver 实现图片懒加载:
// directives/vLazy.js
export const vLazy = {
mounted(el, binding) {
const options = {
rootMargin: binding.arg || '50px', // 提前加载的距离
threshold: 0.1
}
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 元素进入视口,设置 src
el.src = binding.value
// 停止观察
observer.unobserve(el)
}
})
}, options)
el._lazyObserver = observer
observer.observe(el)
},
unmounted(el) {
el._lazyObserver?.disconnect()
}
}
<script setup>
import { vLazy } from './directives/vLazy'
// 图片 URL 列表
const images = [
'https://picsum.photos/400/300?random=1',
'https://picsum.photos/400/300?random=2',
// ...更多图片
]
</script>
<template>
<div class="image-list">
<img
v-for="(src, index) in images"
:key="index"
v-lazy="src"
alt="懒加载图片"
width="400"
height="300"
/>
</div>
</template>
<style scoped>
.image-list img {
display: block;
margin-bottom: 20px;
background: #f0f0f0;
}
</style>
v-draggable 拖拽元素
实现元素拖拽功能:
// directives/vDraggable.js
export const vDraggable = {
mounted(el, binding) {
// 设置初始样式
el.style.cursor = 'move'
el.style.userSelect = 'none'
let isDragging = false
let startX = 0
let startY = 0
let initialLeft = 0
let initialTop = 0
const onMouseDown = (e) => {
// 只响应鼠标左键
if (e.button !== 0) return
isDragging = true
startX = e.clientX
startY = e.clientY
// 获取元素当前位置
const rect = el.getBoundingClientRect()
initialLeft = rect.left
initialTop = rect.top
// 设置为 fixed 定位
el.style.position = 'fixed'
el.style.left = `${initialLeft}px`
el.style.top = `${initialTop}px`
el.style.margin = '0'
el.style.zIndex = '1000'
// 添加全局事件监听
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
// 阻止默认行为
e.preventDefault()
}
const onMouseMove = (e) => {
if (!isDragging) return
const dx = e.clientX - startX
const dy = e.clientY - startY
el.style.left = `${initialLeft + dx}px`
el.style.top = `${initialTop + dy}px`
}
const onMouseUp = () => {
isDragging = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
// 调用回调(如果提供)
if (binding.value && typeof binding.value === 'function') {
binding.value({
left: parseInt(el.style.left),
top: parseInt(el.style.top)
})
}
}
// 存储处理函数以便清理
el._draggableHandlers = {
mousedown: onMouseDown,
mousemove: onMouseMove,
mouseup: onMouseUp
}
el.addEventListener('mousedown', onMouseDown)
},
unmounted(el) {
const handlers = el._draggableHandlers
if (handlers) {
el.removeEventListener('mousedown', handlers.mousedown)
document.removeEventListener('mousemove', handlers.mousemove)
document.removeEventListener('mouseup', handlers.mouseup)
}
}
}
<script setup>
import { vDraggable } from './directives/vDraggable'
function handleDragEnd(position) {
console.log('拖拽结束,位置:', position)
}
</script>
<template>
<div v-draggable="handleDragEnd" class="draggable-box">
拖拽我
</div>
</template>
<style scoped>
.draggable-box {
width: 100px;
height: 100px;
background: #42b983;
color: white;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
}
</style>
v-debounce 防抖输入
为输入框添加防抖功能:
// directives/vDebounce.js
export const vDebounce = {
mounted(el, binding) {
const delay = parseInt(binding.arg) || 300 // 默认 300ms
let timer = null
el._debounceHandler = (e) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
binding.value(e.target.value)
}, delay)
}
el.addEventListener('input', el._debounceHandler)
},
unmounted(el) {
if (el._debounceHandler) {
el.removeEventListener('input', el._debounceHandler)
}
}
}
<script setup>
import { vDebounce } from './directives/vDebounce'
function handleSearch(query) {
console.log('搜索:', query)
// 执行实际的搜索请求
}
</script>
<template>
<input
v-debounce:500="handleSearch"
type="text"
placeholder="输入搜索关键词(500ms 防抖)"
/>
</template>
v-permission 权限控制
根据用户权限控制元素显示:
// directives/vPermission.js
export const vPermission = {
mounted(el, binding) {
const requiredPermission = binding.value
const userPermissions = getUserPermissions() // 从 store 或其他地方获取
if (!userPermissions.includes(requiredPermission)) {
// 没有权限,移除元素
el.parentNode?.removeChild(el)
}
}
}
// 模拟获取权限的函数
function getUserPermissions() {
// 实际应用中从 Vuex/Pinia 或后端获取
return ['read', 'write'] // 示例权限
}
<script setup>
import { vPermission } from './directives/vPermission'
</script>
<template>
<button v-permission="'admin'">
管理员操作
</button>
<button v-permission="'write'">
写入操作
</button>
</template>
v-tooltip 悬浮提示
简单的工具提示指令:
// directives/vTooltip.js
export const vTooltip = {
mounted(el, binding) {
const tooltip = document.createElement('div')
tooltip.className = 'v-tooltip'
tooltip.textContent = binding.value
tooltip.style.cssText = `
position: absolute;
background: #333;
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 10000;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
`
el._tooltip = tooltip
const showTooltip = () => {
document.body.appendChild(tooltip)
const rect = el.getBoundingClientRect()
tooltip.style.left = `${rect.left + rect.width / 2 - tooltip.offsetWidth / 2}px`
tooltip.style.top = `${rect.top - tooltip.offsetHeight - 8}px`
tooltip.style.opacity = '1'
}
const hideTooltip = () => {
tooltip.style.opacity = '0'
setTimeout(() => {
tooltip.parentNode?.removeChild(tooltip)
}, 200)
}
el._tooltipHandlers = { showTooltip, hideTooltip }
el.addEventListener('mouseenter', showTooltip)
el.addEventListener('mouseleave', hideTooltip)
},
updated(el, binding) {
if (el._tooltip) {
el._tooltip.textContent = binding.value
}
},
unmounted(el) {
const handlers = el._tooltipHandlers
if (handlers) {
el.removeEventListener('mouseenter', handlers.showTooltip)
el.removeEventListener('mouseleave', handlers.hideTooltip)
}
el._tooltip?.parentNode?.removeChild(el._tooltip)
}
}
<script setup>
import { vTooltip } from './directives/vTooltip'
</script>
<template>
<button v-tooltip="'这是一个提示信息'">
悬浮显示提示
</button>
</template>
在组件上使用
在组件上使用自定义指令可能会导致意外行为,尤其是当组件有多个根节点时。不建议在组件上使用自定义指令。
当在组件上使用时,自定义指令会应用到组件的根节点,类似于透传 attribute:
<!-- 子组件 -->
<script setup>
// MyComponent 有一个根节点
</script>
<template>
<div class="my-component">
<slot />
</div>
</template>
<!-- 父组件 -->
<script setup>
import MyComponent from './MyComponent.vue'
import { vFocus } from './directives/vFocus'
</script>
<template>
<!-- v-focus 会应用到 MyComponent 的根 div 上 -->
<MyComponent v-focus />
</template>
多根节点组件的问题:
如果组件有多个根节点,指令会被忽略并抛出警告:
<!-- 这个组件有多个根节点 -->
<template>
<div>第一个根节点</div>
<div>第二个根节点</div>
</template>
<!-- 使用时会警告 -->
<MultiRootComponent v-example /> <!-- 警告:指令被忽略 -->
TypeScript 支持
为全局指令添加类型
如果你在全局注册了自定义指令,可以通过扩展 GlobalDirectives 接口来添加类型支持:
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 全局注册指令
app.directive('focus', {
mounted: (el) => el.focus()
})
// 扩展类型
declare module 'vue' {
interface GlobalDirectives {
focus: {} // 无参数
color: { value: string } // 需要颜色字符串
permission: { value: string } // 需要权限字符串
}
}
app.mount('#app')
使用时会获得类型提示:
<script setup lang="ts">
// 现在有类型检查
</script>
<template>
<input v-focus /> <!-- OK -->
<div v-color="'red'" /> <!-- OK,参数类型正确 -->
<div v-color="123" /> <!-- 错误:类型不匹配 -->
</template>
指令类型定义
import type { Directive, DirectiveBinding } from 'vue'
// 定义指令类型
type FocusDirective = Directive<HTMLElement, void>
const vFocus: FocusDirective = {
mounted(el) {
el.focus()
}
}
// 带参数的指令类型
type ColorDirective = Directive<HTMLElement, string>
const vColor: ColorDirective = (el, binding) => {
el.style.color = binding.value
}
// 复杂类型的指令
interface TooltipValue {
text: string
position?: 'top' | 'bottom' | 'left' | 'right'
}
const vTooltip: Directive<HTMLElement, TooltipValue> = {
mounted(el, binding) {
const { text, position = 'top' } = binding.value
// ...
}
}
最佳实践
1. 保持简单
指令应该专注于单一职责:
// 好:单一职责
const vFocus = {
mounted: (el) => el.focus()
}
// 不好:做太多事情
const vDoTooManyThings = {
mounted(el, binding) {
el.focus()
el.style.color = 'red'
el.addEventListener('click', () => { /* ... */ })
// 太多了...
}
}
2. 正确清理副作用
在 unmounted 钩子中清理所有副作用:
const vExample = {
mounted(el) {
el._handler = () => console.log('clicked')
el.addEventListener('click', el._handler)
el._timer = setInterval(() => {
console.log('tick')
}, 1000)
},
unmounted(el) {
// 清理事件监听
el.removeEventListener('click', el._handler)
// 清理定时器
clearInterval(el._timer)
}
}
3. 使用元素 dataset 共享数据
如果需要跨钩子共享数据,使用元素的 dataset:
const vExample = {
created(el, binding) {
// 存储数据
el.dataset.exampleValue = binding.value
},
mounted(el) {
// 读取数据
const value = el.dataset.exampleValue
// 使用数据...
},
updated(el, binding) {
// 更新数据
el.dataset.exampleValue = binding.value
}
}
4. 提供有意义的错误信息
const vExample = {
mounted(el, binding) {
if (typeof binding.value !== 'function') {
console.warn('[v-example] 指令需要一个函数作为值')
return
}
// 正常逻辑...
}
}
小结
本章我们详细学习了 Vue 自定义指令的完整内容:
- 何时使用:仅用于需要底层 DOM 操作的场景,优先使用内置指令
- 注册方式:
<script setup>、选项式 API、全局注册 - 指令钩子:
created到unmounted的完整生命周期 - 钩子参数:
el、binding、vnode、prevVnode的详细说明 - binding 对象:
value、arg、modifiers、instance、dir等属性 - 函数简写:当
mounted和updated行为相同时的简化写法 - 对象字面量:传递多个值的技巧
- 实际应用:focus、click-outside、longpress、lazy、draggable、debounce、permission、tooltip
- 组件使用:不推荐,会应用到根节点,多根节点会被忽略
- TypeScript:为全局指令添加类型支持
练习
- 创建一个
v-resize指令,监听元素大小变化并触发回调 - 创建一个
v-copy指令,点击元素时复制文本到剪贴板 - 创建一个
v-ellipsis指令,当文本溢出时显示省略号和悬浮提示