注意
我注意到章节标题「自定义指令与插件」和额外上下文(app.directive、app.use、v-loading/v-permission)都是 Vue.js 的概念,但开头要求写「Python 教程」。
我先按 Vue3 自定义指令与插件的内容来写(采用您要求的教学风格),如果需要的是 Python 对应主题的教程请告知。
第3章 3.3 自定义指令与插件——让 Vue 默认功能不够用的时候,你自己来造!
🎯 开场:为什么 Vue 内置的不够用?
想象你开了一家奶茶店,总部发了一套标准操作流程(Vue 内置指令),但你发现:
- 客人问「我的奶茶做好了没」,总部没给「查询进度」的流程
- 有个常客是 VIP,你得给他单独加个优先通道,但总部没这个功能
- 每天有 100 个客人要点「去冰三分糖」,你每次都要手写一堆重复指令
痛点来了:
1. 内置指令只解决 80% 的问题,剩下 20% 你需要自己造
2. 同样一段逻辑在多个组件里复制粘贴,改一次要改 N 个地方
3. 有些功能横\n\n
\n\n
\n\n跨多个不相关的组件,你想「复用」但不知道怎么搞
学完这章你能:
- 自己写一个 v-focus 指令,让输入框自动聚焦
- 做一个 v-permission 指令,控制按钮「谁能点」
- 把多个指令和组件封装成一个插件,一行代码全项目生效
🧱 基础:核心概念(小白视角)
3.3.1 指令是什么?你天天在用,只是不知道名字
先回忆一下你用过的指令:
<input v-model="name" />
<button v-on:click="submit">提交</button>
<div v-if="show">显示我</div>
这些 v-xxx 都是指令。说白了,指令就是:
指令 = 一套预设好的「如果...那么...」的动作剧本
v-model:如果用户打字,就更新数据v-if:如果条件为真,就显示这个元素v-on:click:如果点击,就触发函数
Vue 内置了这些常用指令,但——总有不够用的时候。
3.3.2 自定义指令:你自己写的「如果...那么...」
假设你有这个需求:「页面加载后,这个输入框要自动聚焦(光标跳进去)」。
Vue 没内置这个功能,怎么办?
用自定义指令自己造!
<script setup>
// 定义一个叫 vFocus 的指令
const vFocus = {
mounted(el) {
// mounted 是指令的一个「生命周期钩子」
// el 是这个指令所在的 DOM 元素(也就是那个 input)
el.focus() // 让他聚焦!
}
}
</script>
<template>
<!-- 使用方式:vFocus(会自动转成 v-focus) -->
<input v-focus placeholder="点我自动聚焦" />
</template>
解释一下:
- vFocus 这个名字会自动变成 v-focus 在模板里用
- mounted(el) 是一个钩子函数,当指令挂载到 DOM 上时自动触发
- el 就是那个 input 的原生 DOM 对象,所以你能调 el.focus()
3.3.3 指令的完整生命周期——不只是 mounted
指令有 5 个生命周期钩子(可以理解成「人生的 5 个关键节点」):
const vMyDirective = {
// 1. 创建时(元素本身创建了,但还没挂到页面上)
created(el, binding, vnode) {
console.log('出生了!')
},
// 2. 挂载前(元素要显示但还没显示)
beforeMount(el, binding, vnode) {
console.log('要显示了...')
},
// 3. 挂载时(已经显示在页面上了)⭐最常用
mounted(el, binding, vnode) {
console.log('显示了!')
},
// 4. 更新时(组件数据变了,指令重新执行)
updated(el, binding, vnode) {
console.log('数据变了,我也跟着变')
},
// 5. 卸载时(指令不用了,清理资源)
unmounted(el, binding, vnode) {
console.log('退休了,清理一下')
}
}
类比一下:就像一个人出生、上学、工作、退休、去世的过程。
3.3.4 binding 参数——指令的「配置文件」
你可能注意到了 binding 这个参数。它是啥?
<script setup>
const vHighlight = {
mounted(el, binding) {
// binding.value 是指令的值,比如 v-highlight="'yellow'" 里的 'yellow'
el.style.backgroundColor = binding.value || 'yellow'
// binding.arg 是指令的参数,比如 v-highlight:click 里的 'click'
console.log('触发的参数是:', binding.arg)
// binding.modifiers 是修饰符,比如 v-highlight.left 里的 left
console.log('修饰符有:', binding.modifiers)
}
}
</script>
<template>
<!-- value: 'pink' -->
<!-- arg: 'click' -->
<!-- modifiers: { left: true } -->
<div v-highlight:click.left="'pink'">点我试试</div>
</template>
用人话讲:
- binding.value = 指令等号后面的值(v-highlight="'pink'" 里的 'pink')
- binding.arg = 冒号后面的参数(v-highlight:click 里的 click)
- binding.modifiers = 点后面的修饰符(v-highlight.left 里的 left)
3.3.5 全局注册 vs 局部注册——用一次还是用 N 次?
局部注册(只在当前组件用):
<script setup>
const vFocus = { /* ... */ }
</script>
<template>
<input v-focus />
</template>
全局注册(所有组件都能用)——在 main.js 里:
import { createApp } from 'vue'
const app = createApp(App)
// 造一个全局可用的 v-loading 指令
app.directive('loading', {
mounted(el, binding) {
if (binding.value) {
el.classList.add('loading-overlay')
}
}
})
app.mount('#app')
什么时候用哪个?
- 指令只在一个组件用 → 局部
- 指令在整个项目都要用 → 全局
3.3.6 插件是什么?一箱子工具打包卖
你有了很多自定义指令,还有几个工具函数,还有几个通用组件……
每次在新项目用,你是一个个复制粘贴吗?
插件就是用来打包这些的。
一个插件本质上就是一个带有 install 方法的对象:
// myPlugin.js
export default {
install(app, options) {
// app 是 Vue 实例
// options 是你传入的配置参数
// 1. 注册全局指令
app.directive('focus', { /* ... */ })
app.directive('permission', { /* ... */ })
// 2. 注册全局组件
app.component('MyButton', { /* ... */ })
// 3. 给 app 添加全局属性
app.config.globalProperties.$myUtils = {
formatDate: (date) => { /* ... */ },
validateEmail: (email) => { /* ... */ }
}
}
}
在 main.js 里使用插件:
import { createApp } from 'vue'
import App from './App.vue'
import myPlugin from './plugins/myPlugin'
const app = createApp(App)
// 一行代码,插件里所有东西都能用了!
app.use(myPlugin, { someOption: 'value' })
app.mount('#app')
类比:插件就像一个「工具箱套餐」,你不用一个个买工具,一次性把整套解决方案搬回家。
🔥 实战:3 个递进小项目
项目 1:5 分钟——做一个自动聚焦 + 滚动到顶部指令
需求:做一个 v-focus 指令让输入框自动聚焦,再做一个 v-scroll-top 让页面滚到顶部。
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
// 局部注册 v-focus 指令
const vFocus = {
mounted(el) {
el.focus()
}
}
// 局部注册 v-scroll-top 指令
const vScrollTop = {
mounted(el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
const searchText = ref('')
</script>
<template>
<div style="height: 500px; background: #f0f0f0; padding: 20px;">
<h2>页面顶部</h2>
<p>往下滑,页面很长...</p>
</div>
<!-- 这个输入框会自己聚焦 -->
<div style="padding: 20px;">
<input
v-focus
v-model="searchText"
placeholder="页面一加载我就聚焦了!"
style="padding: 10px; font-size: 16px; width: 300px;"
/>
</div>
<!-- 点这个会滚到顶部 -->
<button
v-scroll-top
style="position: fixed; bottom: 20px; right: 20px; padding: 10px 20px;"
>
⬆️ 回到顶部
</button>
</template>
预期输出:页面一加载,输入框就有光标;点按钮页面会平滑滚动到顶部。
一句话解释:mounted 钩子在指令挂载时触发,正好用来做「自动做某事」的场景。
项目 2:15 分钟——做一个权限指令 v-permission
需求:根据用户角色显示/隐藏按钮(admin 能看全部,普通用户只能看部分)。
先准备一个模拟的用户数据:
// userStore.js
import { reactive } from 'vue'
export const userState = reactive({
name: '张三',
role: 'admin' // 可改成 'user' 试试不同效果
})
然后是我们的指令文件:
// directives/permission.js
export const vPermission = {
mounted(el, binding) {
const userRole = binding.value // 需要的角色
const currentUserRole = 'admin' // 模拟当前用户角色(实际从 store 拿)
// 如果当前角色不在允许列表里,就隐藏这个元素
const allowedRoles = Array.isArray(userRole) ? userRole : [userRole]
if (!allowedRoles.includes(currentUserRole)) {
el.style.display = 'none'
}
}
}
在组件里用:
<!-- SomeComponent.vue -->
<script setup>
import { vPermission } from './directives/permission'
import { userState } from './userStore'
</script>
<template>
<h1>欢迎,{{ userState.name }}(角色:{{ userState.role }})</h1>
<!-- 只有 admin 能看到删除按钮 -->
<button v-permission="'admin'" style="background: red; color: white;">
🗑️ 删除用户(仅 admin)
</button>
<!-- admin 和 editor 都能看到编辑按钮 -->
<button v-permission="['admin', 'editor']" style="background: blue; color: white;">
✏️ 编辑内容(admin 或 editor)
</button>
<!-- 所有人都能看到查看按钮 -->
<button v-permission="'user'" style="background: green; color: white;">
👁️ 查看内容(所有用户)
</button>
</template>
预期输出:
- 当前用户 role='admin',三个按钮都显示
- 把 userStore 里改成 role='user',只有绿色按钮显示
一句话解释:指令在 mounted 时检查权限,不符合就 el.style.display = 'none' 藏起来。
项目 3:15 分钟——封装一个 Loading 插件
需求:做一个 v-loading 指令显示加载状态,再做成插件让全项目复用。
第一步:写指令逻辑
// loading.js
export const vLoading = {
mounted(el, binding) {
if (binding.value) {
// 创建一个遮罩层
const overlay = document.createElement('div')
overlay.className = 'loading-overlay'
overlay.innerHTML = '<div class="spinner">⏳ 加载中...</div>'
overlay.style.cssText = `
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
border-radius: 4px;
`
// 父元素要 relative 才能放遮罩
el.style.position = 'relative'
el.appendChild(overlay)
// 把 overlay 存到 el 上,后面 unmounted 要用到
el._loadingOverlay = overlay
}
},
updated(el, binding) {
// 如果 loading 从 true 变成 false,移除遮罩
if (!binding.value && el._loadingOverlay) {
el._loadingOverlay.remove()
el._loadingOverlay = null
}
},
unmounted(el) {
// 清理工作,防止内存泄漏
if (el._loadingOverlay) {
el._loadingOverlay.remove()
el._loadingOverlay = null
}
}
}
第二步:做成插件
// loadingPlugin.js
import { vLoading } from './loading'
export default {
install(app) {
// 全局注册 v-loading 指令
app.directive('loading', vLoading)
// 顺便加一个全局方法 this.$showLoading()
app.config.globalProperties.$loading = {
show: (el) => {
el._manualLoading = true
// 手动控制加载状态
},
hide: (el) => {
el._manualLoading = false
}
}
}
}
第三步:在 main.js 启用
import { createApp } from 'vue'
import App from './App.vue'
import loadingPlugin from './plugins/loadingPlugin'
const app = createApp(App)
app.use(loadingPlugin) // 一行激活!
app.mount('#app')
第四步:在任何组件里用
<script setup>
import { ref } from 'vue'
const isLoading = ref(false)
const loadData = async () => {
isLoading.value = true
await new Promise(resolve => setTimeout(resolve, 2000)) // 模拟请求
isLoading.value = false
}
</script>
<template>
<div>
<!-- 给整个 div 加 loading 遮罩 -->
<div
v-loading="isLoading"
style="width: 300px; height: 200px; background: #f5f5f5; padding: 20px;"
>
<p>这里是内容区域</p>
<p>加载时会显示遮罩</p>
</div>
<button @click="loadData" style="margin-top: 20px; padding: 10px;">
模拟加载数据
</button>
</div>
</template>
预期输出:点按钮后 2 秒内,灰色区域显示「⏳ 加载中...」遮罩,2 秒后消失。
一句话解释:app.directive() 注册全局指令,app.use() 激活插件,一行代码全项目生效。
💪 进阶:常见坑 + 性能小贴士
坑 1:指令名大小写搞混
<!-- ❌ 错误:会自动转成 kebab-case -->
<script setup>
const vMyDirective = { /* ... */ }
</script>
<template>
<!-- 这个写的是 camelCase,不会生效 -->
<div vMyDirective>内容</div>
</template>
<!-- ✅ 正确:模板里用 kebab-case -->
<script setup>
const vMyDirective = { /* ... */ }
</script>
<template>
<div v-my-directive>内容</div>
</template>
原因:HTML 属性不区分大小写,Vue 自动把 camelCase 转成 kebab-case。
坑 2:binding.value 不是响应式的
// ❌ 错误写法:binding 在指令创建时捕获,之后不会更新
const vBad = {
mounted(el, binding) {
// 这里的 doSomething(binding.value) 只会在 mounted 时执行一次
doSomething(binding.value)
}
}
// ✅ 正确写法:用 updated 钩子监听变化
const vGood = {
mounted(el, binding) {
doSomething(binding.value)
},
updated(el, binding) {
// 当 binding.value 变化时,重新执行
if (binding.value !== binding.oldValue) {
doSomething(binding.value)
}
}
}
原因:mounted 只执行一次,updated 会在每次数据更新时触发。
坑 3:全局指令注册顺序
// ❌ 错误:在 createApp 之后注册全局指令
const app = createApp(App)
app.mount('#app')
app.directive('focus', { /* ... */ }) // 太晚了,指令已经编译完了
// ✅ 正确:在 mount 之前注册
const app = createApp(App)
app.directive('focus', { /* ... */ }) // 先注册
app.directive('loading', { /* ... */ }) // 再注册
app.mount('#app') // 最后挂载
坑 4:指令里直接修改组件数据
<!-- ❌ 错误:在指令里直接改 prop,可能引发警告 -->
<script setup>
const vBad = {
mounted(el, binding, vnode) {
vnode.props.value = 'new value' // 不要这样!
}
}
</script>
<!-- ✅ 正确:通过 emits 或事件修改 -->
<script setup>
const vGood = {
mounted(el, binding, vnode) {
// 用 context.emit 通知组件
vnode.emit('update:value', 'new value')
}
}
</script>
坑 5:插件里忘了 return
// ❌ 错误:install 不是箭头函数的隐式 return
app.use({
install(app) {
app.directive('focus', { /* ... */ })
// 缺少 return,但如果你想返回 Promise 或值就有问题
}
})
性能小贴士:避免在指令里频繁操作 DOM
// ❌ 性能差:每次 updated 都操作 DOM
const vInefficient = {
updated(el, binding) {
el.innerHTML = '<span>' + binding.value + '</span>'
}
}
// ✅ 性能好:只更新变化的属性
const vEfficient = {
updated(el, binding) {
el.textContent = binding.value // 直接改文本,不重建 DOM
}
}
调试技巧:用 console.log 打印 binding
const vDebug = {
mounted(el, binding) {
console.log('=== 指令调试信息 ===')
console.log('el:', el)
console.log('binding.value:', binding.value)
console.log('binding.arg:', binding.arg)
console.log('binding.modifiers:', binding.modifiers)
console.log('binding.oldValue:', binding.oldValue)
console.log('========================')
}
}
提示:加一个 v-debug 指令专门用来调试,看看到底收到了什么值。
✏️ 练习题
练习 1(2 分钟):抄改聚焦指令
- 输入:把项目 1 的
v-focus指令改造成v-select,让它自动选中输入框里的文字 - 预期输出:输入框加载后,文字被蓝底白字选中
- 提示:用
el.select()而不是el.focus()
练习 2(2 分钟):加一个条件判断
- 输入:在项目 2 的
v-permission里加逻辑,只有role === 'admin'并且userState.isLocked === false时才显示 - 预期输出:加一个
isLocked: true到 userState,admin 也看不到按钮了 - 提示:在
mounted里加一个&&条件
练习 3(5 分钟):处理新的权限场景
- 输入:新增一个「财务」角色,能看到「财务报表」按钮,但普通 user 和 editor 看不到
- 预期输出:role='finance' 时显示按钮,其他角色隐藏
- 提示:参考练习 2,把判断逻辑改成检查数组包含
练习 4(8 分钟):两个指令串起来
- 输入:做一个
v-lazy-load指令,只有页面滚动到元素可见时才显示图片;再结合v-permission让它只对会员可见 - 预期输出:会员滚动到可视区才看到图片,非会员始终不显示
- 提示:用
IntersectionObserver做懒加载判断
练习 5(5 分钟):分析报错
- 输入:用户写了以下代码,点击按钮后报错
Cannot read property 'style' of undefined - 预期输出:找到原因并修复
- 提示:检查指令是否在正确的生命周期操作 DOM
<script setup>
const vBad = {
mounted(el, binding) {
el.style.background = binding.value
}
}
</script>
<template>
<div v-bad="'yellow'">内容</div>
</template>
作业:做一个「页面访问控制」插件
需求描述:
做一个插件,包含以下功能:
1. v-auth 指令:根据用户权限控制元素显示
2. v-role-badge 指令:显示用户角色标签
3. 一个全局方法 $checkPermission(需要的权限) 返回布尔值
功能点:
1. 权限配置可传入(支持数组形式的多个权限)
2. 隐藏元素时不是简单 display: none,而是有个「权限不足」占位符
3. 插件支持 debug 选项,开启后打印权限检查日志
加分项:
1. 加一个 v-loading 状态指令
2. 支持权限指令在元素上显示「权限不足」的灰色遮罩和图标
验收标准:
- 能跑起来
- 切换用户角色时,页面权限控制实时生效
- 控制台有清晰的调试日志
📚 总结 + 资源
本文学了 3 件事:
1. 自定义指令 = 你自己写的「如果...那么...」动作,用 app.directive() 或局部 const vXxx = {}
2. 指令生命周期 = created → beforeMount → mounted → updated → unmounted
3. 插件 = 把指令、组件、全局方法打包成一个工具箱,用 app.use() 一行激活
延伸学习资源:
| 资源 | 推荐理由 |
|---|---|
| Vue3 官方自定义指令文档 | 最权威,有完整 API 说明 |
| VueUse 源码 | 大量实战自定义指令,可以参考学习 |
| 《Vue.js 设计与实现》 | 从框架设计角度讲指令系统,理解更深入 |
互动钩子:
你在项目里自己写过哪些自定义指令?有没有什么「只有自己知道」的实用指令?评论区聊聊,老粉优先回复!
下章预告:
学会了自定义指令和插件,你肯定在想:「这些确实厉害,但有没有更优雅的方式复用逻辑?」——比如把逻辑抽出来但不用继承、不用 mixin?下一章我们来聊聊 mixin 与组合式函数的对比,看看 Vue3 里「复用代码」的正确姿势!

评论(0)