一、啥是自定义指令?凭啥要用它?
Vue 自带了一些指令,比如 v-model、v-if、v-show、v-for,这些大家天天用。
但有时候,你想给元素加一个"自动聚焦"的行为,可能会在 onMounted 里写 input.focus()。如果有好几个页面都要这个功能,你就得把那段代码复制来复制去,又丑又容易漏。
这时候,自定义指令就派上用场了。你可以自己造一个 v-focus,然后往任何元素上一写,它就自动聚焦,一句废话都没有。
一句话总结: 自定义指令就是让你把"操作 DOM 的逻辑"封装成一个个可复用的"标记"。
二、先写一个最基础的局部自定义指令
局部指令就是只在当前组件里能用,定义在 <script setup> 里面。我们用自动聚焦来开篇。
案例1:页面打开,输入框自动获得焦点
vue
<template>
<div>
<h3>自动聚焦输入框</h3>
<!--
v-focus 是我们自定义的指令
名字前面有个 v,但定义的时候只叫 focus
-->
<input v-focus type="text" placeholder="我一出来就聚焦" />
</div>
</template>
<script setup>
// 定义一个局部自定义指令,名字叫 focus
// 注意:定义时用小写 v 开头,但要写成 vFocus 这种驼峰,模板里用 v-focus
const vFocus = {
// mounted 是钩子函数,当元素被挂载到 DOM 上时调用
mounted(el) {
// el 就是被绑定了这个指令的原生 DOM 元素,这里就是 input
el.focus() // 直接调用原生 focus 方法
}
}
</script>
拆开来讲:
-
指令对象里可以定义多个钩子函数 ,最常用的就是
mounted,它在元素插入 DOM 后触发。 -
mounted(el)里的el就是绑定了这个指令的原生 DOM 元素,你可以对它做任何原生操作。 -
为啥叫
vFocus?因为 Vue 会自动把模板里的v-focus和脚本里的vFocus关联起来。这是 Vue 的约定。
三、全局自定义指令:一次定义,到处都能用
局部指令只能在当前组件用,很多实用功能我们希望在整个应用里都能直接用,这就需要全局指令。
在 main.js 里用 app.directive 注册。
案例2:全局自动聚焦指令
main.js
javascript
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 注册全局指令,名字叫 focus
app.directive('focus', {
mounted(el) {
el.focus()
}
})
app.mount('#app')
之后,任何组件 里都可以直接写 <input v-focus />,不用再单独引入。
四、指令的钩子函数和参数
指令有好几个钩子,跟组件的生命周期有点像,但专门针对绑定了指令的这个元素。
常用钩子:
-
created:元素创建后,但还没插入 DOM(很少用)。 -
mounted:元素插入 DOM 后(最常用)。 -
updated:组件更新导致 DOM 变化后。 -
beforeUnmount:元素被移除前。
钩子函数可以拿到三个参数:
-
el:绑定的元素本身。 -
binding:一个对象,包含指令相关的信息,比如value(指令的值)、arg(参数)、modifiers(修饰符)。 -
vnode:虚拟节点(一般用不到)。
用一张表记住 binding:
| 属性 | 说明 | 示例 v-demo:arg.modifier="value" |
|---|---|---|
binding.value |
指令的值 | "value" |
binding.arg |
冒号后面的参数 | "arg" |
binding.modifiers |
修饰符对象 | { modifier: true } |
我们用几个案例把这三个东西玩明白。
五、带参数和值的指令
案例3:改变背景色指令 v-bg
需求:给元素加一个背景色,颜色通过指令值传入。还可以用参数指定是背景色还是文字颜色。
vue
<template>
<div>
<!--
使用 v-bg:background="'lightblue'"
参数 background 表示改背景色
值 'lightblue' 是颜色
-->
<p v-bg:background="'lightblue'">我的背景是浅蓝色</p>
<!-- 参数 color 表示改文字颜色 -->
<p v-bg:color="'red'">我的文字是红色</p>
<!-- 不写参数,默认改背景色 -->
<p v-bg="'yellow'">默认改背景色为黄色</p>
</div>
</template>
<script setup>
const vBg = {
mounted(el, binding) {
// binding.arg 是参数(冒号后面的东西)
// binding.value 是指令的值(等号后面的东西)
const arg = binding.arg || 'background' // 没传参数就默认改背景色
if (arg === 'background') {
el.style.backgroundColor = binding.value
} else if (arg === 'color') {
el.style.color = binding.value
}
}
}
</script>
关键点: binding.arg 拿到参数(background 或 color),binding.value 拿到颜色值。这样你就能用一个指令干两件事。
六、带修饰符的指令
修饰符就是点后面的东西,比如 v-on:click.prevent 里的 .prevent。
案例4:防抖点击指令 v-debounce
需求:有些按钮不能猛点(比如支付按钮),我们需要点击后一定时间内不能再次点击。用修饰符 .lock 控制是否"锁定"按钮。
vue
<template>
<div>
<!--
使用 v-debounce.lock="handlePay"
.lock 修饰符表示点击后锁定按钮 1 秒,防止重复点击
值 handlePay 是点击后要执行的回调函数
-->
<button v-debounce.lock="handlePay">支付(锁定1秒)</button>
<!-- 不加 .lock 修饰符,就是普通点击,不做防抖 -->
<button v-debounce="handleNormal">普通按钮</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const vDebounce = {
mounted(el, binding) {
// 防抖时间,写死 1 秒,也可以做成指令参数
const delay = 1000
let timer = null
// 检查修饰符里有没有 lock
const needLock = binding.modifiers.lock
// 给元素绑定点击事件
el.addEventListener('click', () => {
if (needLock && timer) {
// 如果有 lock 修饰符且定时器还在,说明还没解禁,直接返回
return
}
// 执行传进来的回调函数
if (typeof binding.value === 'function') {
binding.value()
}
if (needLock) {
// 按钮禁用样式
el.disabled = true
// 设置定时器,1 秒后解禁
timer = setTimeout(() => {
el.disabled = false
timer = null
}, delay)
}
})
}
}
function handlePay() {
console.log('支付请求已发送,按钮已锁定1秒')
// 实际项目这里发请求
}
function handleNormal() {
console.log('普通点击')
}
</script>
解析:
-
binding.modifiers.lock是true时,我们用setTimeout锁定按钮。 -
binding.value是传进来的函数,我们直接调用它。 -
这个指令把"防抖"逻辑完全封装了起来,模板里看起来非常干净。
七、指令也能接收对象类型的值
上面传的都是简单值,其实 binding.value 可以是任意类型,包括对象。这样就能传多个参数。
案例5:权限控制指令 v-permission
需求:根据用户权限决定某个元素是否显示。我们假设当前用户角色是 'editor',只有角色匹配时才显示元素。
vue
<template>
<div>
<!--
传一个对象给指令,包含角色列表和是否移除
roles: ['admin', 'editor'] 表示这两个角色可见
remove: true 表示没有权限时直接移除DOM(否则只是隐藏)
-->
<button v-permission="{ roles: ['admin'], remove: false }">
只有管理员可见(隐藏方式)
</button>
<button v-permission="{ roles: ['admin'], remove: true }">
只有管理员可见(移除方式)
</button>
<!-- 当前用户是 editor,所以上面两个按钮都不可见,下面这个可见 -->
<button v-permission="{ roles: ['admin', 'editor'] }">
管理员和编辑可见
</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
// 假设当前用户角色是 editor
const currentRole = ref('editor')
const vPermission = {
mounted(el, binding) {
const { roles = [], remove = false } = binding.value || {}
// 检查当前角色是否在允许的角色列表里
const hasPermission = roles.includes(currentRole.value)
if (!hasPermission) {
if (remove) {
// 直接移除 DOM 元素
el.parentNode?.removeChild(el)
} else {
// 或者隐藏
el.style.display = 'none'
}
}
}
}
</script>
说明:
-
binding.value拿到了整个对象{ roles: [...], remove: true }。 -
我们可以解构出来,然后根据逻辑决定显示、隐藏还是移除。
八、综合实战案例:图片懒加载指令 v-lazy
这是前端里非常实用的一个功能:页面上的图片先不加载,等它滚到可视区域了再加载,节省带宽和提升性能。
vue
<template>
<div>
<h3>往下滚,图片会懒加载</h3>
<!-- 占位高度,让页面能滚 -->
<div style="height: 800px; background: #f5f5f5; text-align: center; line-height: 800px;">
请向下滚动
</div>
<!--
v-lazy 指令,值传图片的真实地址
初始时先显示一张占位图(可以是透明的或 loading 图)
-->
<img v-lazy="'https://picsum.photos/id/1/400/300'" alt="懒加载图片1" />
<br />
<img v-lazy="'https://picsum.photos/id/2/400/300'" alt="懒加载图片2" />
<br />
<img v-lazy="'https://picsum.photos/id/3/400/300'" alt="懒加载图片3" />
</div>
</template>
<script setup>
const vLazy = {
mounted(el, binding) {
// 先设置占位图(一张灰色的小图或 loading 图)
el.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiB4MT0iMCUiIHkxPSIwJSIgeDI9IjEwMCUiIHkyPSIxMDAlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjZGRkIiAvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iI2VlZSIgLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+'
// 真实图片地址存起来
const realSrc = binding.value
// 创建 IntersectionObserver 监听元素是否进入可视区域
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 进入可视区域了,加载真实图片
el.src = realSrc
// 加载完就停止监听
observer.unobserve(el)
}
})
}, {
rootMargin: '0px 0px 100px 0px' // 提前 100px 就开始加载
})
// 开始观察这个元素
observer.observe(el)
// 组件卸载时也清理 observer(这里简化处理,一般会存下来在 unmounted 里断开)
el._observer = observer
},
unmounted(el) {
// 清理观察器
if (el._observer) {
el._observer.disconnect()
}
}
}
</script>
核心原理:
-
利用浏览器原生的
IntersectionObserverAPI,监听元素是否进入视口。 -
进入时把占位图的
src换成真实地址。 -
同时做了卸载时的清理工作。
这个指令可以直接用到任何项目里,非常实用。
九、总结一下自定义指令的写法
| 步骤 | 说明 |
|---|---|
| 1. 定义 | const vXxx = { mounted(el, binding) { ... } } |
| 2. 使用 | <div v-xxx="值"></div> |
| 3. 参数 | 通过 binding.arg 拿 |
| 4. 修饰符 | 通过 binding.modifiers 拿 |
| 5. 值 | 通过 binding.value 拿,可以是任意类型 |
局部注册: 在 <script setup> 里定义 vXxx 对象就行,模板自动识别。
全局注册: 在 main.js 里 app.directive('xxx', { ... })。
十、什么时候用组件,什么时候用指令?
这个问题很多新手会纠结,简单给个判断标准:
-
如果需要一段 HTML 模板 + 逻辑 + 样式 ,用组件。
-
如果只是对某个 DOM 元素做一点操作 (改样式、加事件、调焦点),用自定义指令更轻量。
比如权限隐藏,用组件包装也可以,但如果只是加个判断,一个指令就搞定,不用多套一层 div。