第20章 自定义指令
第二十章 自定义指令
Vue内置了很多有用的指令(v-if、v-for、v-model…),但总有一些场景需要更直接地操作DOM。自定义指令就是Vue给你的"特权"——让你能够直接操控DOM元素,实现那些内置指令做不到的事情。本章我们来学习如何编写自定义指令,让你的Vue应用拥有"超能力"。
20.1 内置指令回顾
在深入自定义指令之前,先回顾一下Vue的内置指令:
graph LR
A[内置指令] --> B[v-bind]
A --> C[v-model]
A --> D[v-if/v-show]
A --> D2[v-for]
A --> E[v-on]
A --> F[v-slot]
A --> G[v-ref]
A --> H[v-cloak]
I[自定义需求] --> J{是否涉及DOM操作?}
J -->|是| K[自定义指令 ✓]
J -->|否| L[Composable (逻辑复用)]什么时候用自定义指令:
- 直接操作DOM元素
- 需要在元素挂载/更新/卸载时执行逻辑
- 需要添加自定义事件监听
- 需要集成第三方DOM库
20.2 自定义指令基础
20.2.1 指令生命周期钩子
graph TD
A[元素挂载到DOM] --> B[beforeMount]
B --> C[mounted]
C --> D{组件更新?}
D -->|是| E[beforeUpdate]
E --> F[updated]
F --> D
D -->|否| G{元素从DOM移除?}
G -->|是| H[beforeUnmount]
H --> I[unmounted]
style B fill:#FFE4B5
style C fill:#90EE90
style E fill:#FFE4B5
style F fill:#90EE90
style H fill:#FFE4B5
style I fill:#FF6B6B钩子参数:
el:指令绑定的DOM元素binding:包含指令所有信息的对象vnode:虚拟节点prevVnode:上一个虚拟节点(仅在updated钩子中可用)
1
2
3
4
5
6
7
8
9
| // binding 对象包含:
interface DirectiveBinding {
instance: any // 组件实例
value: any // 指令绑定的值
oldValue: any // 上一个值(updated和componentUpdated可用)
arg: string // 指令参数(v-xxx:arg 中的 arg)
modifiers: object // 修饰符对象
dir: Directive<any> // 指令定义对象
}
|
20.2.2 全局注册指令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| // main.ts
import { createApp } from 'vue'
// 方式1:直接注册
const app = createApp(App)
// 注册一个全局指令
app.directive('focus', {
mounted(el, binding, vnode) {
el.focus()
}
})
// 方式2:创建后统一注册
const myDirectives = {
// 指令名称
focus: {
mounted(el, binding, vnode) {
el.focus()
}
},
'permission': {
mounted(el, binding) {
// binding.value 是权限标识
const hasPermission = checkPermission(binding.value)
if (!hasPermission) {
el.remove() // 或者 el.style.display = 'none'
}
}
}
}
// 批量注册
for (const [name, directive] of Object.entries(myDirectives)) {
app.directive(name, directive)
}
app.mount('#app')
|
20.2.3 局部注册指令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| <script setup lang="ts">
// 只有当前组件能使用的指令
const vFocus = {
mounted(el: HTMLElement) {
el.focus()
}
}
const vColor = {
mounted(el: HTMLElement, binding: any) {
el.style.color = binding.value
}
}
// 带参数的指令
const vBgColor = {
mounted(el: HTMLElement, binding: any) {
el.style.backgroundColor = binding.value
}
}
// 带修饰符的指令
const vHighlight = {
mounted(el: HTMLElement, binding: any) {
if (binding.modifiers.bold) {
el.style.fontWeight = 'bold'
}
if (binding.modifiers.italic) {
el.style.fontStyle = 'italic'
}
el.style.color = binding.value || 'yellow'
}
}
</script>
<template>
<!-- 无参数 -->
<input v-focus placeholder="自动聚焦" />
<!-- 带值 -->
<p v-color="'red'">红色文字</p>
<!-- 带参数 -->
<div v-bg-color:lightblue>浅蓝色背景</div>
<!-- 带修饰符 -->
<p v-highlight.bold.italic="'orange'">粗体斜体橙色高亮</p>
</template>
|
20.3 实战自定义指令
20.3.1 v-focus - 自动聚焦
最常用的指令:输入框自动聚焦
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // directives/v-focus.ts
import type { Directive, DirectiveBinding } from 'vue'
/**
* 自动聚焦指令
* 用法:v-focus
* v-focus.lazy(延迟聚焦)
*/
export const vFocus: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
// 支持 v-focus.lazy 延迟聚焦
const delay = binding.modifiers.lazy ? 100 : 0
const focus = () => {
// 聚焦到第一个可输入元素
const input = el.querySelector<HTMLInputElement>(
'input:not([disabled]), textarea:not([disabled])'
)
if (input) {
input.focus()
} else {
el.focus()
}
}
setTimeout(focus, delay)
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| <!-- 使用示例 -->
<template>
<div>
<input v-focus placeholder="页面加载后自动聚焦" />
<button @click="showInput = true">显示输入框</button>
<!-- 动态出现时自动聚焦 -->
<input v-if="showInput" v-focus.lazy placeholder="出现后自动聚焦" />
</div>
</template>
<script setup lang="ts">
import { vFocus } from '@/directives/v-focus'
const showInput = ref(false)
</script>
|
20.3.2 v-debounce - 防抖指令
输入框防抖的便捷封装:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
| // directives/v-debounce.ts
import type { Directive, DirectiveBinding } from 'vue'
/**
* 防抖指令
* 用法:v-debounce:click="handler" 1000
* v-debounce:input="onInput" 300
*/
interface DebounceBinding extends DirectiveBinding {
value: Function
arg?: string
modifiers?: { immediate?: boolean }
}
export const vDebounce: Directive = {
mounted(el: HTMLElement, binding: DebounceBinding) {
const eventName = binding.arg || 'click'
const delay = (binding.arg && !isNaN(Number(binding.arg)))
? Number(binding.arg)
: 500
const isImmediate = binding.modifiers?.immediate || false
let timer: ReturnType<typeof setTimeout> | null = null
const handler = (e: Event) => {
if (timer) {
clearTimeout(timer)
}
if (isImmediate) {
// 立即执行,之后防抖
if (!timer) {
binding.value(e)
}
timer = setTimeout(() => {
timer = null
}, delay)
} else {
// 延迟执行
timer = setTimeout(() => {
binding.value(e)
timer = null
}, delay)
}
}
// 存储handler以便卸载时移除
;(el as any)._debounceHandler = handler
el.addEventListener(eventName, handler)
},
unmounted(el: HTMLElement, binding: DebounceBinding) {
const eventName = binding.arg || 'click'
const handler = (el as any)._debounceHandler
if (handler) {
el.removeEventListener(eventName, handler)
delete (el as any)._debounceHandler
}
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| <!-- 使用示例 -->
<template>
<div>
<!-- 点击防抖,延迟500ms -->
<button v-debounce:click="handleSave">保存(防抖)</button>
<!-- 输入防抖,延迟300ms -->
<input
v-debounce:input="handleSearch"
placeholder="搜索(防抖300ms)"
/>
<!-- 立即执行,之后防抖500ms -->
<button v-debounce:click.immediate="handleSubmit">立即提交</button>
</div>
</template>
<script setup lang="ts">
import { vDebounce } from '@/directives/v-debounce'
function handleSave() {
console.log('保存')
}
function handleSearch(e: Event) {
const value = (e.target as HTMLInputElement).value
console.log('搜索:', value)
}
function handleSubmit() {
console.log('提交')
}
</script>
|
20.3.3 v-permission - 权限指令
基于用户权限控制元素显示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
| // directives/v-permission.ts
import type { Directive, DirectiveBinding } from 'vue'
import { useUserStore } from '@/stores/user'
/**
* 权限指令
* 用法:v-permission="'user:create'"
* v-permission="['user:create', 'user:update']"
* v-permission:or="['user:create', 'user:update']"
*/
export const vPermission: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const userStore = useUserStore()
const value = binding.value
if (!value) {
// 没有传值,不做限制
return
}
// 权限列表
const permissions = Array.isArray(value) ? value : [value]
// 是否是OR模式(满足任一权限即可)
const isOrMode = binding.modifiers?.or
// 检查权限
const hasPermission = isOrMode
? permissions.some(p => userStore.hasPermission(p))
: permissions.every(p => userStore.hasPermission(p))
if (!hasPermission) {
// 移除元素或隐藏
if (binding.modifiers?.remove) {
el.parentNode?.removeChild(el)
} else {
el.style.display = 'none'
}
}
},
updated(el: HTMLElement, binding: DirectiveBinding) {
// 权限可能动态变化,需要重新检查
if (binding.oldValue !== binding.value) {
// 重新触发mounted逻辑
const userStore = useUserStore()
const value = binding.value
if (!value) return
const permissions = Array.isArray(value) ? value : [value]
const isOrMode = binding.modifiers?.or
const hasPermission = isOrMode
? permissions.some(p => userStore.hasPermission(p))
: permissions.every(p => userStore.hasPermission(p))
if (hasPermission) {
el.style.display = ''
} else {
el.style.display = 'none'
}
}
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| <!-- 使用示例 -->
<template>
<div class="toolbar">
<!-- 单个权限 -->
<button v-permission="'user:create'">创建用户</button>
<button v-permission="'user:update'">编辑用户</button>
<button v-permission="'user:delete'">删除用户</button>
<!-- 多个权限(AND模式:需要同时满足) -->
<button v-permission="['system:config:read', 'system:config:write']">
系统配置
</button>
<!-- 多个权限(OR模式:满足任一即可) -->
<button v-permission:or="['admin', 'super-admin']">
管理员按钮
</button>
<!-- 直接移除DOM(而非隐藏) -->
<div v-permission.remove="'guest'">
仅管理员可见
</div>
</div>
</template>
|
20.3.4 v-draggable - 拖拽指令
实现元素的拖拽功能:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
| // directives/v-draggable.ts
import type { Directive, DirectiveBinding } from 'vue'
interface DraggableBinding extends DirectiveBinding {
value?: {
handle?: string // 拖拽手柄选择器
bounding?: HTMLElement // 限制在哪个元素内
onStart?: (pos: { x: number; y: number }) => void
onMove?: (pos: { x: number; y: number }) => void
onEnd?: (pos: { x: number; y: number }) => void
}
}
interface Position {
x: number
y: number
}
export const vDraggable: Directive = {
mounted(el: HTMLElement, binding: DraggableBinding) {
const options = binding.value || {}
// 获取拖拽手柄
const handle = options.handle
? el.querySelector<HTMLElement>(options.handle)
: el
if (!handle) {
console.warn('v-draggable: 未找到拖拽手柄')
return
}
let startPos: Position = { x: 0, y: 0 }
let isDragging = false
const onMouseDown = (e: MouseEvent) => {
// 只响应左键
if (e.button !== 0) return
// 如果有手柄,只在手柄上响应
if (options.handle && !(e.target as HTMLElement).matches(options.handle)) {
return
}
isDragging = true
startPos = { x: e.clientX, y: e.clientY }
// 触发开始回调
options.onStart?.({ x: e.clientX, y: e.clientY })
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
// 阻止默认行为和文本选择
e.preventDefault()
el.style.cursor = 'grabbing'
}
const onMouseMove = (e: MouseEvent) => {
if (!isDragging) return
const deltaX = e.clientX - startPos.x
const deltaY = e.clientY - startPos.y
// 获取当前transform
const transform = getComputedStyle(el).transform
let currentX = 0
let currentY = 0
if (transform && transform !== 'none') {
const matrix = new DOMMatrixReadOnly(transform)
currentX = matrix.m41
currentY = matrix.m42
}
// 计算新位置
let newX = currentX + deltaX
let newY = currentY + deltaY
// 边界限制
if (options.bounding) {
const rect = el.getBoundingClientRect()
const boundRect = options.bounding.getBoundingClientRect()
newX = Math.max(boundRect.left, Math.min(newX, boundRect.right - rect.width))
newY = Math.max(boundRect.top, Math.min(newY, boundRect.bottom - rect.height))
}
el.style.transform = `translate(${newX}px, ${newY}px)`
startPos = { x: e.clientX, y: e.clientY }
// 触发移动回调
options.onMove?.({ x: newX, y: newY })
}
const onMouseUp = (e: MouseEvent) => {
isDragging = false
el.style.cursor = ''
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
// 触发结束回调
options.onEnd?.({ x: e.clientX, y: e.clientY })
}
handle.addEventListener('mousedown', onMouseDown)
// 清理
;(el as any)._draggableCleanup = () => {
handle.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
},
unmounted(el: HTMLElement) {
const cleanup = (el as any)._draggableCleanup
if (cleanup) {
cleanup()
delete (el as any)._draggableCleanup
}
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
| <!-- 使用示例 -->
<template>
<div class="container">
<!-- 基础拖拽 -->
<div v-draggable class="draggable-box">
拖拽我!
</div>
<!-- 带手柄的拖拽 -->
<div v-draggable="{ handle: '.drag-header' }" class="modal">
<div class="drag-header">拖拽头部移动</div>
<div class="modal-body">模态框内容</div>
</div>
<!-- 限制在容器内 -->
<div ref="containerRef" class="bounding-container">
<div v-draggable="{ bounding: containerRef }" class="draggable-item">
只能在容器内移动
</div>
</div>
<!-- 带回调 -->
<div
v-draggable="{
onStart: handleStart,
onMove: handleMove,
onEnd: handleEnd
}"
class="draggable-box"
>
带回调的拖拽
</div>
</div>
</template>
<script setup lang="ts">
import { vDraggable } from '@/directives/v-draggable'
import { ref } from 'vue'
const containerRef = ref<HTMLElement>()
function handleStart(pos: { x: number; y: number }) {
console.log('开始拖拽:', pos)
}
function handleMove(pos: { x: number; y: number }) {
console.log('移动中:', pos)
}
function handleEnd(pos: { x: number; y: number }) {
console.log('拖拽结束:', pos)
}
</script>
|
20.3.5 v-copy - 复制指令
一键复制文本到剪贴板:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
| // directives/v-copy.ts
import type { Directive, DirectiveBinding } from 'vue'
import { ElMessage } from 'element-plus'
/**
* 复制指令
* 用法:v-copy="text"
* v-copy="'Hello World'"
*/
export const vCopy: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const text = typeof binding.value === 'function'
? binding.value()
: binding.value
if (!text) {
console.warn('v-copy: 未提供要复制的文本')
return
}
const copy = async () => {
try {
// 现代浏览器API
await navigator.clipboard.writeText(text)
ElMessage.success('复制成功!')
} catch (err) {
// 降级方案
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
ElMessage.success('复制成功!')
} catch (e) {
ElMessage.error('复制失败,请手动复制')
}
}
}
// 点击即复制
el.addEventListener('click', copy)
// 存储清理函数
;(el as any)._copyCleanup = () => {
el.removeEventListener('click', copy)
}
// 添加复制提示样式
el.style.cursor = 'pointer'
},
updated(el: HTMLElement, binding: DirectiveBinding) {
// 如果值变了,更新清理函数
if (binding.oldValue !== binding.value) {
;(el as any)._copyCleanup?.()
const text = typeof binding.value === 'function'
? binding.value()
: binding.value
if (text) {
const copy = async () => {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('复制成功!')
} catch (err) {
ElMessage.error('复制失败')
}
}
el.addEventListener('click', copy)
;(el as any)._copyCleanup = () => {
el.removeEventListener('click', copy)
}
}
}
},
unmounted(el: HTMLElement) {
;(el as any)._copyCleanup?.()
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| <!-- 使用示例 -->
<template>
<div>
<!-- 复制静态字符串 -->
<p v-copy="'Hello Vue 3!'">点击复制: Hello Vue 3!</p>
<!-- 复制变量 -->
<p v-copy="inviteCode">邀请码: {{ inviteCode }}</p>
<!-- 复制函数返回值 -->
<p v-copy="getShareText">点击复制分享文案</p>
<!-- 按钮样式 -->
<button v-copy="inviteCode" class="copy-btn">
复制邀请链接
</button>
</div>
</template>
<script setup lang="ts">
import { vCopy } from '@/directives/v-copy'
const inviteCode = ref('ABC123456')
function getShareText() {
return `邀请码:${inviteCode.value},快来加入我们!`
}
</script>
|
20.4 高级用法
20.4.1 带配置项的指令
通过修饰符传递配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
| // directives/v-loading.ts
import type { Directive, DirectiveBinding } from 'vue'
interface LoadingBinding extends DirectiveBinding {
modifiers: {
absolute?: boolean // 绝对定位
overlay?: boolean // 覆盖层模式
}
value: boolean
}
export const vLoading: Directive = {
mounted(el: HTMLElement, binding: LoadingBinding) {
if (!binding.value) return
// 创建loading容器
const spinner = document.createElement('div')
spinner.className = 'loading-spinner'
spinner.innerHTML = `
<div class="spinner">
<div class="circle"></div>
</div>
`
// 添加样式
const style = document.createElement('style')
style.textContent = `
.loading-spinner {
position: ${binding.modifiers.absolute ? 'absolute' : 'absolute'};
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background: rgba(255, 255, 255, 0.8);
z-index: 9999;
}
.loading-spinner.overlay {
background: rgba(0, 0, 0, 0.5);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #f3f3f3;
border-top: 3px solid #42b883;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`
if (binding.modifiers.overlay) {
spinner.classList.add('overlay')
}
spinner.appendChild(style)
el.style.position = 'relative'
el.appendChild(spinner)
// 存储引用
;(el as any)._loadingElement = spinner
},
updated(el: HTMLElement, binding: LoadingBinding) {
const spinner = (el as any)._loadingElement
if (binding.value && !spinner) {
// 重新mounted
const newBinding = { ...binding } as LoadingBinding
this.mounted?.(el, newBinding)
} else if (!binding.value && spinner) {
// 移除loading
spinner.remove()
;(el as any)._loadingElement = null
}
},
unmounted(el: HTMLElement) {
const spinner = (el as any)._loadingElement
if (spinner) {
spinner.remove()
}
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
| <template>
<button :disabled="loading" v-copy="text">
{{ loading ? '加载中...' : '复制' }}
</button>
<div v-loading="isLoading" class="content">
大量内容...
</div>
<div v-loading.overlay="isLoading" class="modal">
模态框内容
</div>
</template>
|
20.4.2 指令与 Composables 结合
自定义指令擅长"直接操作 DOM",Composable 擅长"逻辑复用"。把两者结合,就能让指令拥有 Composable 的所有逻辑能力,同时保持 DOM 操作的便捷性。
使用场景:比如懒加载指令(图片进入视口才加载),这个功能既需要 IntersectionObserver 的 DOM 检测逻辑(适合抽成 Composable),又需要操作 DOM(适合用指令)。把 useIntersectionObserver 逻辑注入指令,就能写出既简洁又功能强大的懒加载指令:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
```typescript
// composables/useIntersectionObserver.ts
import { ref, onMounted, onUnmounted, Ref } from 'vue'
export function useLazyLoad(
elementRef: Ref<HTMLElement | undefined>,
callback: (isVisible: boolean) => void,
options?: IntersectionObserverInit
) {
let observer: IntersectionObserver | null = null
onMounted(() => {
if (!elementRef.value) return
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
callback(entry.isIntersecting)
})
}, options)
observer.observe(elementRef.value)
})
onUnmounted(() => {
observer?.disconnect()
})
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // directives/v-lazy.ts
import type { Directive } from 'vue'
import { useLazyLoad } from '@/composables/useLazyLoad'
export const vLazy: Directive = {
mounted(el: HTMLElement, binding) {
const src = binding.value
if (!src) return
// 先用一个占位图
el.src = '/placeholder.png'
// 使用composable
useLazyLoad(
{ value: el } as any,
(isVisible) => {
if (isVisible) {
el.src = src
}
},
{ threshold: 0.1 }
)
}
}
|
20.4.3 第三方DOM库集成
以整合Sortable.js为例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| // directives/v-sortable.ts
import type { Directive, DirectiveBinding } from 'vue'
import Sortable from 'sortablejs'
interface SortableBinding extends DirectiveBinding {
value?: {
group?: string | { name: string; pull?: boolean; put?: boolean }
animation?: number
onEnd?: (evt: any) => void
onAdd?: (evt: any) => void
onUpdate?: (evt: any) => void
onRemove?: (evt: any) => void
}
}
export const vSortable: Directive = {
mounted(el: HTMLElement, binding: SortableBinding) {
const options = binding.value || {}
const sortable = Sortable.create(el, {
group: options.group,
animation: options.animation || 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
dragClass: 'sortable-drag',
onEnd: (evt) => {
options.onEnd?.(evt)
},
onAdd: (evt) => {
options.onAdd?.(evt)
},
onUpdate: (evt) => {
options.onUpdate?.(evt)
},
onRemove: (evt) => {
options.onRemove?.(evt)
}
})
// 存储实例
;(el as any)._sortableInstance = sortable
},
unmounted(el: HTMLElement) {
const instance = (el as any)._sortableInstance
if (instance) {
instance.destroy()
delete (el as any)._sortableInstance
}
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
| <!-- 使用示例 -->
<template>
<div class="kanban-board">
<div
v-sortable="{
group: 'kanban',
animation: 200,
onEnd: handleDragEnd
}"
class="column"
>
<div class="card" v-for="task in todoTasks" :key="task.id">
{{ task.title }}
</div>
</div>
<div
v-sortable="{
group: 'kanban',
onAdd: handleTaskMoved
}"
class="column"
>
<div class="card" v-for="task in doneTasks" :key="task.id">
{{ task.title }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { vSortable } from '@/directives/v-sortable'
const todoTasks = ref([{ id: 1, title: '任务1' }])
const doneTasks = ref([{ id: 2, title: '任务2' }])
function handleDragEnd(evt: any) {
console.log('拖拽结束:', evt)
// 更新数据
}
function handleTaskMoved(evt: any) {
console.log('任务移动:', evt)
}
</script>
<style scoped>
.kanban-board {
display: flex;
gap: 20px;
}
.column {
flex: 1;
min-height: 200px;
background: #f5f5f5;
border-radius: 8px;
padding: 12px;
}
.card {
background: white;
padding: 12px;
margin-bottom: 8px;
border-radius: 4px;
cursor: move;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-chosen {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</style>
|
20.5 指令注册管理
20.5.1 集中管理指令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // directives/index.ts
import type { App } from 'vue'
// 导入所有指令
import { vFocus } from './v-focus'
import { vDebounce } from './v-debounce'
import { vPermission } from './v-permission'
import { vDraggable } from './v-draggable'
import { vCopy } from './v-copy'
import { vLoading } from './v-loading'
import { vSortable } from './v-sortable'
export const directives = {
vFocus,
vDebounce,
vPermission,
vDraggable,
vCopy,
vLoading,
vSortable
}
// 批量注册
export function registerDirectives(app: App) {
for (const [name, directive] of Object.entries(directives)) {
app.directive(name, directive)
}
}
|
1
2
3
4
5
6
7
8
9
10
11
| // main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { registerDirectives } from './directives'
const app = createApp(App)
// 一行代码注册所有指令
registerDirectives(app)
app.mount('#app')
|
20.5.2 条件注册指令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // 仅为特定环境注册某些指令
export function registerDirectives(app: App) {
// 开发环境注册调试指令
if (import.meta.env.DEV) {
app.directive('debug', vDebug)
}
// 生产环境注册性能监控指令
if (import.meta.env.PROD) {
app.directive('performance', vPerformance)
}
// 注册所有通用指令
app.directive('focus', vFocus)
app.directive('copy', vCopy)
app.directive('loading', vLoading)
}
|
20.6 本章小结
本章我们学习了Vue自定义指令:
| 指令 | 用途 | 关键点 |
|---|
| v-focus | 自动聚焦 | 支持lazy修饰符 |
| v-debounce | 防抖 | 支持事件名、延迟、immediate |
| v-permission | 权限控制 | 支持单个/多个、AND/OR模式 |
| v-draggable | 拖拽 | 支持手柄、边界限制、回调 |
| v-copy | 复制 | 自动降级处理 |
| v-loading | 加载状态 | 支持overlay模式 |
| v-sortable | 列表排序 | 集成Sortable.js |
指令vsComposable的选择:
- 需要操作DOM → 指令
- 只是逻辑复用 → Composables
- 需要在生命周期钩子中操作DOM → 指令
自定义指令是Vue给开发者的"瑞士军刀"——你可以用它来实现任何DOM操作。但正如瑞士军刀不是每个场景的最佳工具一样,自定义指令也不是万能的。想清楚了再用,别把指令当成万能解决方案。