组合式API
# 06.组合式API
# 目录介绍
# 6.1 组合式API概述
# 6.1.1 为什么需要组合式API
选项式API(Options API)按选项类型组织代码,当组件逻辑复杂时,同一功能的代码被分散在data、methods、computed、watch等不同位置:
选项式API:代码按类型分散
┌─────────────────────┐
│ data() { │ ← 搜索的状态
│ searchText │
│ results │
│ loading │ ← 分页的状态
│ page │
│ pageSize │
│ } │
├─────────────────────┤
│ computed: { │ ← 搜索的计算属性
│ filteredResults │
│ totalPages │ ← 分页的计算属性
│ } │
├─────────────────────┤
│ methods: { │ ← 搜索的方法
│ search() │
│ changePage() │ ← 分页的方法
│ } │
└─────────────────────┘
组合式API:代码按功能聚合
┌─────────────────────┐
│ // 搜索功能 │
│ const searchText │
│ const results │
│ const search() │
│ const filtered │
├─────────────────────┤
│ // 分页功能 │
│ const page │
│ const pageSize │
│ const changePage() │
│ const totalPages │
└─────────────────────┘
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
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
# 6.1.2 setup语法糖
<script setup> 是组合式API的编译时语法糖:
<!-- ❌ 不使用 setup 语法糖 -->
<script lang="ts">
import { ref, computed, defineComponent } from 'vue'
export default defineComponent({
setup() {
const count = ref(0)
const double = computed(() => count.value * 2)
const increment = () => count.value++
// 必须手动 return
return { count, double, increment }
}
})
</script>
<!-- ✅ 使用 setup 语法糖(推荐) -->
<script setup lang="ts">
import { ref, computed } from 'vue'
const count = ref(0)
const double = computed(() => count.value * 2)
const increment = () => count.value++
// 自动暴露到模板,无需 return
</script>
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 6.1.3 与选项式API对比
| 对比项 | 选项式API | 组合式API |
|---|---|---|
| 代码组织 | 按选项类型(data/methods) | 按逻辑功能 |
| 逻辑复用 | Mixin(有命名冲突风险) | Composables(函数组合) |
| TypeScript | 支持较弱 | 完善支持 |
| 学习曲线 | 低(结构固定) | 中(需理解响应式API) |
| 代码量 | 多(模板代码) | 少(无需return等) |
| 适合场景 | 简单组件 | 复杂组件、逻辑复用 |
# 6.1.4 综合案例与思考
综合案例:用组合式API重构复杂组件
<template>
<div>
<input v-model="searchText" placeholder="搜索..." />
<ul>
<li v-for="item in paginatedResults" :key="item.id">{{ item.name }}</li>
</ul>
<div class="pagination">
<button :disabled="page <= 1" @click="page--">上一页</button>
<span>{{ page }} / {{ totalPages }}</span>
<button :disabled="page >= totalPages" @click="page++">下一页</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// ========== 搜索功能 ==========
const searchText = ref('')
const allItems = ref([
{ id: 1, name: 'Vue.js' }, { id: 2, name: 'React' },
{ id: 3, name: 'Angular' }, { id: 4, name: 'Svelte' },
{ id: 5, name: 'TypeScript' }, { id: 6, name: 'JavaScript' },
{ id: 7, name: 'Node.js' }, { id: 8, name: 'Deno' }
])
const filteredItems = computed(() =>
allItems.value.filter(item =>
item.name.toLowerCase().includes(searchText.value.toLowerCase())
)
)
// ========== 分页功能 ==========
const page = ref(1)
const pageSize = ref(3)
const totalPages = computed(() =>
Math.ceil(filteredItems.value.length / pageSize.value)
)
const paginatedResults = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredItems.value.slice(start, start + pageSize.value)
})
</script>
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
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
思考题:
- 如果搜索和分页逻辑要在其他组件复用,应该怎么提取?
- Mixin 有"命名冲突"和"来源不清晰"的问题,Composables 如何解决?
- 选项式API和组合式API能在同一个组件中混用吗?
# 6.2 可组合函数Composables
# 6.2.1 什么是Composables
Composable 是利用组合式API封装的可复用逻辑函数,命名约定以 use 开头:
// composables/useMouse.ts
import { ref, onMounted, onBeforeUnmount } from 'vue'
// 封装鼠标位置追踪逻辑
export function useMouse() {
const x = ref(0)
const y = ref(0)
const update = (e: MouseEvent) => {
x.value = e.clientX
y.value = e.clientY
}
onMounted(() => window.addEventListener('mousemove', update))
onBeforeUnmount(() => window.removeEventListener('mousemove', update))
return { x, y }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- 在任意组件中使用 -->
<template>
<p>鼠标位置: ({{ x }}, {{ y }})</p>
</template>
<script setup lang="ts">
import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()
</script>
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
# 6.2.2 封装常用Composable
防抖Ref:
// composables/useDebouncedRef.ts
import { ref, watch, type Ref } from 'vue'
export function useDebouncedRef<T>(initialValue: T, delay = 300): Ref<T> {
const value = ref(initialValue) as Ref<T>
const debouncedValue = ref(initialValue) as Ref<T>
let timer: number
watch(value, (newVal) => {
clearTimeout(timer)
timer = setTimeout(() => {
debouncedValue.value = newVal
}, delay)
})
return debouncedValue
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Fetch请求封装:
// composables/useFetch.ts
import { ref, watchEffect, type Ref } from 'vue'
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<string>
loading: Ref<boolean>
refresh: () => Promise<void>
}
export function useFetch<T = any>(url: Ref<string> | string): UseFetchReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref('')
const loading = ref(false)
const fetchData = async () => {
loading.value = true
error.value = ''
try {
const urlValue = typeof url === 'string' ? url : url.value
const response = await fetch(urlValue)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
// 如果 url 是响应式的,自动在 url 变化时重新请求
if (typeof url !== 'string') {
watchEffect(fetchData)
} else {
fetchData()
}
return { data, error, loading, refresh: fetchData }
}
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
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
Local Storage响应式:
// composables/useStorage.ts
import { ref, watch, type Ref } from 'vue'
export function useStorage<T>(key: string, defaultValue: T): Ref<T> {
const stored = localStorage.getItem(key)
const data = ref<T>(stored ? JSON.parse(stored) : defaultValue) as Ref<T>
watch(data, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
}, { deep: true })
return data
}
1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
# 6.2.3 Composable设计原则
Composable 设计原则:
1. 命名以 use 开头 → useMouse, useFetch, useAuth
2. 接收 ref 或响应式参数 → 支持响应式输入
3. 返回 ref 对象 → 调用者可以解构
4. 内部管理生命周期 → 自动清理副作用
5. 无副作用或可控副作用 → 调用者决定何时执行
6. 类型安全 → 完善的 TypeScript 类型
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
// 好的 Composable 示例
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = (step = 1) => { count.value += step }
const decrement = (step = 1) => { count.value -= step }
const reset = () => { count.value = initialValue }
return { count, increment, decrement, reset }
}
1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
# 6.2.4 综合案例与思考
综合案例:倒计时Composable
// composables/useCountdown.ts
import { ref, computed, onBeforeUnmount } from 'vue'
export function useCountdown(seconds: number) {
const remaining = ref(seconds)
const isRunning = ref(false)
let timer: number | null = null
const formatted = computed(() => {
const mins = Math.floor(remaining.value / 60)
const secs = remaining.value % 60
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
})
const isFinished = computed(() => remaining.value <= 0)
const progress = computed(() => ((seconds - remaining.value) / seconds) * 100)
function start() {
if (isRunning.value) return
isRunning.value = true
timer = setInterval(() => {
remaining.value--
if (remaining.value <= 0) {
stop()
}
}, 1000)
}
function stop() {
isRunning.value = false
if (timer) { clearInterval(timer); timer = null }
}
function reset(newSeconds?: number) {
stop()
remaining.value = newSeconds ?? seconds
}
onBeforeUnmount(stop)
return { remaining, formatted, isRunning, isFinished, progress, start, stop, reset }
}
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
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
<!-- 使用倒计时 -->
<template>
<div>
<h3>{{ countdown.formatted }}</h3>
<div class="progress-bar">
<div :style="{ width: countdown.progress + '%' }"></div>
</div>
<button v-if="!countdown.isRunning" @click="countdown.start()">开始</button>
<button v-else @click="countdown.stop()">暂停</button>
<button @click="countdown.reset()">重置</button>
<p v-if="countdown.isFinished">时间到!</p>
</div>
</template>
<script setup lang="ts">
import { useCountdown } from '@/composables/useCountdown'
const countdown = useCountdown(60)
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
思考题:
- Composables 和 React Hooks 有什么相似和不同之处?
- Composable 内部为什么可以使用
onMounted等生命周期钩子? - 如何测试一个 Composable 函数?需要什么测试环境?
# 6.3 依赖注入
# 6.3.1 provide和inject
跨层级传递数据,无需通过Props逐层传递:
<!-- 祖先组件 -->
<script setup lang="ts">
import { provide, ref } from 'vue'
const theme = ref('light')
const fontSize = ref(14)
provide('theme', theme)
provide('fontSize', fontSize)
provide('changeTheme', (newTheme: string) => {
theme.value = newTheme
})
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 任意后代组件(无论嵌套多深) -->
<script setup lang="ts">
import { inject, type Ref } from 'vue'
const theme = inject<Ref<string>>('theme')
const fontSize = inject<Ref<number>>('fontSize')
const changeTheme = inject<(theme: string) => void>('changeTheme')
// 提供默认值
const color = inject('color', 'blue') // 如果没有provide,使用默认值
</script>
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
# 6.3.2 类型安全的注入
使用 InjectionKey 确保类型安全:
// types/injection-keys.ts
import type { InjectionKey, Ref } from 'vue'
export interface ThemeConfig {
theme: Ref<string>
fontSize: Ref<number>
changeTheme: (theme: string) => void
}
export const themeKey: InjectionKey<ThemeConfig> = Symbol('theme')
1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
<!-- 提供方 -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import { themeKey } from '@/types/injection-keys'
const theme = ref('light')
const fontSize = ref(14)
provide(themeKey, {
theme,
fontSize,
changeTheme: (t: string) => { theme.value = t }
})
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 注入方 -->
<script setup lang="ts">
import { inject } from 'vue'
import { themeKey } from '@/types/injection-keys'
const themeConfig = inject(themeKey)
// themeConfig 的类型被正确推断为 ThemeConfig | undefined
themeConfig?.changeTheme('dark')
</script>
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
# 6.3.3 应用级provide
在应用入口提供全局数据:
// main.ts
import { createApp, ref } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 应用级 provide,所有组件都可以 inject
app.provide('appVersion', '1.0.0')
app.provide('apiBaseUrl', 'https://api.example.com')
app.mount('#app')
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
# 6.3.4 综合案例与思考
综合案例:主题系统
// composables/useTheme.ts
import { ref, provide, inject, type Ref, type InjectionKey } from 'vue'
interface ThemeContext {
theme: Ref<'light' | 'dark'>
primaryColor: Ref<string>
toggleTheme: () => void
setPrimaryColor: (color: string) => void
}
const themeKey: InjectionKey<ThemeContext> = Symbol('theme')
// 在根组件调用
export function provideTheme() {
const theme = ref<'light' | 'dark'>('light')
const primaryColor = ref('#42b883')
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
const setPrimaryColor = (color: string) => {
primaryColor.value = color
}
const context: ThemeContext = { theme, primaryColor, toggleTheme, setPrimaryColor }
provide(themeKey, context)
return context
}
// 在任意后代组件调用
export function useTheme() {
const context = inject(themeKey)
if (!context) throw new Error('useTheme must be used within provideTheme')
return context
}
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
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
<!-- 任意后代组件中使用 -->
<template>
<div :class="theme">
<button @click="toggleTheme">切换主题: {{ theme }}</button>
<div :style="{ color: primaryColor }">主题色文字</div>
</div>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { theme, primaryColor, toggleTheme } = useTheme()
</script>
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
思考题:
- provide/inject 和 Pinia 都能实现跨组件数据共享,什么场景用哪个更合适?
- 为什么推荐使用
InjectionKey而不是字符串作为 key? - 如果
inject没有找到对应的provide,会发生什么?如何处理这种情况?
# 6.4 模板引用和组件实例
# 6.4.1 ref获取DOM元素
<template>
<input ref="inputRef" placeholder="自动聚焦" />
<canvas ref="canvasRef" width="300" height="200"></canvas>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
// 模板中 ref="inputRef" 对应此变量
const inputRef = ref<HTMLInputElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
onMounted(() => {
// DOM 已挂载,可以操作
inputRef.value?.focus()
const ctx = canvasRef.value?.getContext('2d')
if (ctx) {
ctx.fillStyle = '#42b883'
ctx.fillRect(10, 10, 100, 80)
}
})
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 6.4.2 ref获取子组件实例
<!-- 父组件 -->
<template>
<ChildComponent ref="childRef" />
<button @click="callChildMethod">调用子组件方法</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null)
const callChildMethod = () => {
childRef.value?.doSomething()
console.log(childRef.value?.publicData)
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 6.4.3 defineExpose暴露方法
<script setup> 中组件默认是封闭的,需要用 defineExpose 显式暴露:
<!-- ChildComponent.vue -->
<template>
<div>{{ count }}</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const internalData = ref('私有数据')
function doSomething() {
console.log('子组件方法被调用')
count.value++
}
function reset() {
count.value = 0
}
// 只暴露指定的属性和方法给父组件
defineExpose({
count, // 暴露
doSomething, // 暴露
reset // 暴露
// internalData 不暴露
})
</script>
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
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
# 6.4.4 综合案例与思考
综合案例:表单验证组件
<!-- FormField.vue -->
<template>
<div class="form-field" :class="{ error: errorMsg }">
<label>{{ label }}</label>
<input v-model="value" @blur="validate" :placeholder="placeholder" />
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
label: string
placeholder?: string
rules?: Array<(val: string) => string | true>
}>()
const value = ref('')
const errorMsg = ref('')
function validate(): boolean {
if (!props.rules) return true
for (const rule of props.rules) {
const result = rule(value.value)
if (result !== true) {
errorMsg.value = result
return false
}
}
errorMsg.value = ''
return true
}
function getValue() {
return value.value
}
function reset() {
value.value = ''
errorMsg.value = ''
}
defineExpose({ validate, getValue, reset })
</script>
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
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
<!-- 父组件:表单 -->
<template>
<form @submit.prevent="handleSubmit">
<FormField ref="nameRef" label="姓名" :rules="[required]" />
<FormField ref="emailRef" label="邮箱" :rules="[required, isEmail]" />
<button type="submit">提交</button>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import FormField from './FormField.vue'
const nameRef = ref<InstanceType<typeof FormField> | null>(null)
const emailRef = ref<InstanceType<typeof FormField> | null>(null)
const required = (val: string) => val.trim() ? true : '此项必填'
const isEmail = (val: string) => /\S+@\S+\.\S+/.test(val) ? true : '邮箱格式不正确'
const handleSubmit = () => {
const nameValid = nameRef.value?.validate()
const emailValid = emailRef.value?.validate()
if (nameValid && emailValid) {
console.log('提交:', nameRef.value?.getValue(), emailRef.value?.getValue())
}
}
</script>
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
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
思考题:
- 为什么
<script setup>默认不暴露内部属性?这体现了什么设计理念? - 通过 ref 调用子组件方法 vs 通过 emit 事件通信,各适用于什么场景?
- 模板 ref 在
v-for中使用时,ref会得到什么?
# 6.5 自定义指令
# 6.5.1 指令钩子函数
自定义指令的生命周期钩子:
const myDirective = {
// 元素挂载前
beforeMount(el: HTMLElement, binding: any) {},
// 元素挂载后
mounted(el: HTMLElement, binding: any) {},
// 更新前
beforeUpdate(el: HTMLElement, binding: any) {},
// 更新后
updated(el: HTMLElement, binding: any) {},
// 卸载前
beforeUnmount(el: HTMLElement, binding: any) {},
// 卸载后
unmounted(el: HTMLElement, binding: any) {}
}
// binding 对象包含:
// binding.value → 指令的值 v-my="value"
// binding.oldValue → 之前的值
// binding.arg → 指令参数 v-my:arg
// binding.modifiers → 修饰符 v-my.mod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 6.5.2 局部指令
在 <script setup> 中以 v 开头命名的变量自动注册为指令:
<template>
<!-- 自动聚焦 -->
<input v-focus />
<!-- 点击外部关闭 -->
<div v-click-outside="closeMenu">
<ul v-show="showMenu">菜单内容</ul>
</div>
<!-- 长按事件 -->
<button v-longpress="onLongPress">长按我</button>
</template>
<script setup lang="ts">
import { ref, type Directive } from 'vue'
// 自动聚焦指令
const vFocus: Directive = {
mounted(el: HTMLElement) {
el.focus()
}
}
// 点击外部指令
const vClickOutside: Directive = {
mounted(el: any, binding) {
el._clickOutside = (e: MouseEvent) => {
if (!el.contains(e.target)) {
binding.value()
}
}
document.addEventListener('click', el._clickOutside)
},
unmounted(el: any) {
document.removeEventListener('click', el._clickOutside)
}
}
// 长按指令
const vLongpress: Directive = {
mounted(el: any, binding) {
let timer: number
el.addEventListener('mousedown', () => {
timer = setTimeout(() => binding.value(), 800)
})
el.addEventListener('mouseup', () => clearTimeout(timer))
el.addEventListener('mouseleave', () => clearTimeout(timer))
}
}
const showMenu = ref(true)
const closeMenu = () => { showMenu.value = false }
const onLongPress = () => { alert('长按触发!') }
</script>
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
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
# 6.5.3 全局指令
在应用入口全局注册:
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 全局权限指令
app.directive('permission', {
mounted(el: HTMLElement, binding) {
const userPermissions = ['read', 'write'] // 从 store 获取
const required = binding.value
if (!userPermissions.includes(required)) {
el.style.display = 'none'
// 或者 el.parentNode?.removeChild(el)
}
}
})
// 全局防抖指令
app.directive('debounce', {
mounted(el: HTMLElement, binding) {
let timer: number
const delay = binding.arg ? parseInt(binding.arg) : 300
el.addEventListener('click', () => {
clearTimeout(timer)
timer = setTimeout(() => binding.value(), delay)
})
}
})
app.mount('#app')
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
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>
<button v-permission="'write'">编辑</button>
<button v-permission="'admin'">删除(无权限则隐藏)</button>
<button v-debounce:500="handleSubmit">防抖提交</button>
</template>
1
2
3
4
5
6
2
3
4
5
6
# 6.5.4 综合案例与思考
综合案例:图片懒加载指令
// directives/lazyLoad.ts
import type { Directive } from 'vue'
const vLazy: Directive<HTMLImageElement, string> = {
mounted(el, binding) {
// 设置占位图
el.src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect fill="%23f0f0f0" width="100" height="100"/></svg>'
el.dataset.src = binding.value
// 使用 IntersectionObserver 监听元素是否进入视口
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement
img.src = img.dataset.src || ''
img.removeAttribute('data-src')
observer.unobserve(img)
}
})
},
{ rootMargin: '100px' } // 提前100px开始加载
)
observer.observe(el)
// 保存 observer 引用,用于清理
;(el as any)._observer = observer
},
unmounted(el) {
;(el as any)._observer?.disconnect()
},
updated(el, binding) {
if (binding.value !== binding.oldValue) {
el.dataset.src = binding.value
}
}
}
export default vLazy
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
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
<template>
<div class="gallery">
<img
v-for="img in images"
:key="img.id"
v-lazy="img.url"
:alt="img.title"
class="lazy-img"
/>
</div>
</template>
<script setup lang="ts">
import vLazy from '@/directives/lazyLoad'
const images = Array.from({ length: 20 }, (_, i) => ({
id: i,
url: `https://picsum.photos/400/300?random=${i}`,
title: `图片 ${i + 1}`
}))
</script>
<style scoped>
.gallery { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.lazy-img { width: 100%; height: 200px; object-fit: cover; border-radius: 8px; transition: opacity 0.3s; }
</style>
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
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
思考题:
- 自定义指令和 Composable 都能封装逻辑,什么场景用指令更合适?
IntersectionObserver在懒加载指令中起什么作用?它比 scroll 事件监听好在哪里?- 自定义指令在 SSR(服务端渲染)中需要注意什么问题?
上次更新: 2026/06/10, 11:13:41