跳到主要内容

Vue 自定义指令

除了 Vue 内置的核心指令(如 v-modelv-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。仅在 beforeUpdateupdated 钩子中可用。

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 来实现。

函数简写

当自定义指令在 mountedupdated 时需要相同的行为,且不需要其他钩子时,可以将指令定义为函数:

// 简写形式
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 自定义指令的完整内容:

  1. 何时使用:仅用于需要底层 DOM 操作的场景,优先使用内置指令
  2. 注册方式<script setup>、选项式 API、全局注册
  3. 指令钩子createdunmounted 的完整生命周期
  4. 钩子参数elbindingvnodeprevVnode 的详细说明
  5. binding 对象valueargmodifiersinstancedir 等属性
  6. 函数简写:当 mountedupdated 行为相同时的简化写法
  7. 对象字面量:传递多个值的技巧
  8. 实际应用:focus、click-outside、longpress、lazy、draggable、debounce、permission、tooltip
  9. 组件使用:不推荐,会应用到根节点,多根节点会被忽略
  10. TypeScript:为全局指令添加类型支持

练习

  1. 创建一个 v-resize 指令,监听元素大小变化并触发回调
  2. 创建一个 v-copy 指令,点击元素时复制文本到剪贴板
  3. 创建一个 v-ellipsis 指令,当文本溢出时显示省略号和悬浮提示

参考资源