编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • Android提升进阶

  • iOS开发和进阶

  • Web开发和进阶

    • README
    • HTML工具手册

    • TypeScript入门

    • Vue高级进阶

      • 基础入门
      • 组件开发
      • 响应式系统
      • 路由管理
      • 状态管理
      • 组合式API
        • 6.1 组合式API概述
          • 6.1.1 为什么需要组合式API
          • 6.1.2 setup语法糖
          • 6.1.3 与选项式API对比
          • 6.1.4 综合案例与思考
        • 6.2 可组合函数Composables
          • 6.2.1 什么是Composables
          • 6.2.2 封装常用Composable
          • 6.2.3 Composable设计原则
          • 6.2.4 综合案例与思考
        • 6.3 依赖注入
          • 6.3.1 provide和inject
          • 6.3.2 类型安全的注入
          • 6.3.3 应用级provide
          • 6.3.4 综合案例与思考
        • 6.4 模板引用和组件实例
          • 6.4.1 ref获取DOM元素
          • 6.4.2 ref获取子组件实例
          • 6.4.3 defineExpose暴露方法
          • 6.4.4 综合案例与思考
        • 6.5 自定义指令
          • 6.5.1 指令钩子函数
          • 6.5.2 局部指令
          • 6.5.3 全局指令
          • 6.5.4 综合案例与思考
      • 工程化实战
  • Linux应用开发

  • Apps
  • Web开发和进阶
  • Vue高级进阶
杨充
2026-04-23
目录

组合式API

# 06.组合式API

# 目录介绍

  • 6.1 组合式API概述
    • 6.1.1 为什么需要组合式API
    • 6.1.2 setup语法糖
    • 6.1.3 与选项式API对比
    • 6.1.4 综合案例与思考
  • 6.2 可组合函数Composables
    • 6.2.1 什么是Composables
    • 6.2.2 封装常用Composable
    • 6.2.3 Composable设计原则
    • 6.2.4 综合案例与思考
  • 6.3 依赖注入
    • 6.3.1 provide和inject
    • 6.3.2 类型安全的注入
    • 6.3.3 应用级provide
    • 6.3.4 综合案例与思考
  • 6.4 模板引用和组件实例
    • 6.4.1 ref获取DOM元素
    • 6.4.2 ref获取子组件实例
    • 6.4.3 defineExpose暴露方法
    • 6.4.4 综合案例与思考
  • 6.5 自定义指令
    • 6.5.1 指令钩子函数
    • 6.5.2 局部指令
    • 6.5.3 全局指令
    • 6.5.4 综合案例与思考

# 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

# 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

# 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

思考题:

  1. 如果搜索和分页逻辑要在其他组件复用,应该怎么提取?
  2. Mixin 有"命名冲突"和"来源不清晰"的问题,Composables 如何解决?
  3. 选项式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
<!-- 在任意组件中使用 -->
<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

# 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

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

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

# 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
// 好的 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

# 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
<!-- 使用倒计时 -->
<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

思考题:

  1. Composables 和 React Hooks 有什么相似和不同之处?
  2. Composable 内部为什么可以使用 onMounted 等生命周期钩子?
  3. 如何测试一个 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
<!-- 任意后代组件(无论嵌套多深) -->
<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

# 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
<!-- 提供方 -->
<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
<!-- 注入方 -->
<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

# 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

# 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
<!-- 任意后代组件中使用 -->
<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

思考题:

  1. provide/inject 和 Pinia 都能实现跨组件数据共享,什么场景用哪个更合适?
  2. 为什么推荐使用 InjectionKey 而不是字符串作为 key?
  3. 如果 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

# 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

# 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

# 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
<!-- 父组件:表单 -->
<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

思考题:

  1. 为什么 <script setup> 默认不暴露内部属性?这体现了什么设计理念?
  2. 通过 ref 调用子组件方法 vs 通过 emit 事件通信,各适用于什么场景?
  3. 模板 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

# 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

# 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
<!-- 使用全局指令 -->
<template>
  <button v-permission="'write'">编辑</button>
  <button v-permission="'admin'">删除(无权限则隐藏)</button>
  <button v-debounce:500="handleSubmit">防抖提交</button>
</template>
1
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
<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

思考题:

  1. 自定义指令和 Composable 都能封装逻辑,什么场景用指令更合适?
  2. IntersectionObserver 在懒加载指令中起什么作用?它比 scroll 事件监听好在哪里?
  3. 自定义指令在 SSR(服务端渲染)中需要注意什么问题?
上次更新: 2026/06/10, 11:13:41
状态管理
工程化实战

← 状态管理 工程化实战→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式