Skip to content

Vue 3 Composition API

Vue 3 的核心 API,通过函数式的方式组织组件逻辑,解决 Options API 在复杂组件中逻辑分散的问题。Composition API 不是替代 Options API,而是提供更灵活的组织方式。


为什么要用 Composition API

Options API 的问题

js
// Options API —— 一个功能的逻辑分散在不同选项中
export default {
  // data 函数返回组件的响应式数据
  data() { 
    return { 
      count: 0,      // 计数器,功能 A 的状态
      timer: null     // 定时器引用,用于清理
    } 
  },        
  // computed 计算属性,基于响应式数据自动计算
  computed: { 
    double() { 
      return this.count * 2  // 依赖 count,当 count 变化时自动更新
    } 
  },    
  // methods 方法,包含组件的业务逻辑
  methods: { 
    increment() { 
      this.count++  // 修改响应式数据,触发视图更新
    } 
  },           
  // mounted 生命周期钩子,组件挂载后执行
  mounted() { 
    // 设置定时器,每秒调用 increment 方法
    this.timer = setInterval(this.increment, 1000) 
  },  
  // beforeUnmount 生命周期钩子,组件卸载前清理副作用
  beforeUnmount() { 
    clearInterval(this.timer)  // 清除定时器,避免内存泄漏
  },       
  // 功能 B 的逻辑也分散在各处...
}

Composition API 的优势

vue
<script setup lang="ts">
// 导入组合式函数,每个函数封装一个独立功能
import { useCounter } from './useCounter'  // 计数器逻辑
import { useTimer } from './useTimer'      // 定时器逻辑
import { useUser } from './useUser'        // 用户数据逻辑

// 功能 A 的逻辑集中在一起:计数器 + 定时器
const { count, doubled, increment } = useCounter()  // 解构获取计数器状态和方法
const { start, stop } = useTimer(increment, 1000)   // 创建定时器,每秒调用 increment

// 功能 B 的逻辑集中在一起:用户数据管理
const { user, fetchUser } = useUser()  // 获取用户状态和获取方法

// 生命周期钩子:组件挂载后启动定时器,卸载前停止
onMounted(() => start())      // 启动定时器
onUnmounted(() => stop())    // 停止定时器,避免内存泄漏
</script>

ref

大白话解释:ref 就像给数据"包一层盒子"。基本类型(数字、字符串、布尔值)不能直接被 Vue 追踪变化,所以需要用 ref 包起来。访问时用 .value 打开盒子,修改时也用 .value

为什么用 ref?

  • 基本类型需要包装:Vue 无法追踪 let count = 0 的变化,但可以追踪 ref(0) 的变化
  • 可以整体替换state.value = { name: '李四' } 可以替换整个对象
  • 模板中自动解包:在 <template> 中不需要写 .value

ref vs reactive 怎么选?

  • 推荐 ref:更灵活,可以替换整个值,TypeScript 类型推断更好
  • reactive 适合:表单对象等不需要整体替换的场景

创建基本类型的响应式数据,也支持对象类型。

ts
import { ref } from 'vue'  // 从 Vue 导入 ref 函数

// 基本类型:用 ref 包装原始值,使其成为响应式
const count = ref(0)          // 创建 ref 对象,初始值为 0
console.log(count.value)      // 通过 .value 访问内部值,输出 0
count.value++                 // 修改值必须通过 .value
console.log(count.value)      // 输出 1

// 对象类型(内部自动调用 reactive,深层属性也是响应式的)
const user = ref({ name: '张三', age: 25 })  // 包装对象
user.value.age++  // 需要 .value 访问内部对象,再直接修改属性

// 数组类型
const list = ref<string[]>([])  // 创建空数组 ref,指定元素类型为 string
list.value.push('item')         // 通过 .value 访问数组并添加元素

💡 在 <template> 中会自动解包,不需要 .value

vue
<template>
  <p>{{ count }}</p>  <!-- 不需要 count.value,自动解包 -->
</template>

注意:自动解包仅限顶层 ref。如果 ref 嵌套在对象或数组中,不会自动解包:

vue
<template>
  <!-- ✅ 顶层 ref 自动解包 -->
  <p>{{ count }}</p>
  
  <!-- ❌ 嵌套在对象中的 ref 不会自动解包 -->
  <p>{{ nested.count }}</p>  <!-- 显示 "[object Object]",不是数字 -->
  
  <!-- ✅ 需要手动访问 .value -->
  <p>{{ nested.count.value }}</p>
</template>

ref 的类型标注

ts
import { ref, type Ref } from 'vue'  // 导入 ref 和 Ref 类型

// 自动推导:TypeScript 根据初始值推断类型
const count = ref(0)  // 自动推导为 Ref<number>

// 显式标注:通过泛型参数明确指定类型
const name = ref<string>('张三')      // 显式标注为 Ref<string>
const list = ref<number[]>([])        // 显式标注为 Ref<number[]>

// 复杂类型:使用接口定义对象结构
interface User {
  id: number          // 用户 ID
  name: string        // 用户名
  email?: string      // 可选邮箱
}
const user = ref<User | null>(null)  // 初始值为 null,类型为 Ref<User | null>

// 函数类型:存储回调函数
const handler = ref<((e: Event) => void) | null>(null)  // 事件处理函数

ref 解包的细节

ts
const count = ref(0)                          // 创建基本类型 ref
const obj = ref({ nested: { count: ref(0) } }) // 创建包含 ref 的对象

// 在 reactive 中自动解包:ref 作为 reactive 属性时自动解包
const state = reactive({ count })  // state.count 是 number,不是 ref
state.count++                      // 不需要 .value,直接操作

// 在数组中不会自动解包:数组中的 ref 保持原样
const arr = ref([ref(1)])                  // 创建包含 ref 的数组
console.log(arr.value[0].value)           // 需要两层 .value:外层数组 + 内层 ref

// 在 template 中自动解包(仅限顶层 ref)
// 参考前面的注意事项

reactive

大白话解释:reactive 就像给对象"装监控"。对象的每个属性都会被 Vue 监控,任何变化都会被检测到,然后更新页面。

reactive 的限制:

  • 不能替换整个对象state = { count: 1 } 会丢失响应性,因为这是换了一个新对象
  • 不能解构const { count } = state 会丢失响应性,因为解构出来的是普通值

什么时候用 reactive?

  • 表单对象(不需要整体替换,只修改单个属性)
  • 复杂嵌套对象(深层属性也会被追踪)

创建对象类型的响应式数据,基于 Proxy 实现。

ts
import { reactive } from 'vue'

const state = reactive({
  count: 0,
  user: { name: '张三', age: 25 },
  list: [1, 2, 3],
  nested: { deep: { value: 'hello' } },
})

// 直接修改,不需要 .value
state.count++
state.user.name = '李四'
state.list.push(4)
state.nested.deep.value = 'world' // 深层也是响应式的

reactive 的限制

ts
// ❌ 不能替换整个对象:let 声明允许重新赋值,但会丢失响应性
let state = reactive({ count: 0 })  // 创建响应式对象
state = reactive({ count: 1 })      // 错误!重新赋值丢失原始引用,之前的绑定失效

// ✅ 用 Object.assign:修改属性而不替换对象
Object.assign(state, { count: 1 })  // 合并新属性到原对象

// ✅ 或用 ref:ref 可以整体替换 .value
const state = ref({ count: 0 })     // 用 ref 包装对象
state.value = { count: 1 }          // 正确!替换 .value 保持响应性

// ❌ 不能解构,会丢失响应性
const { count, name } = reactive({ count: 0, name: '张三' })
// count 和 name 是普通值,不是响应式的,修改它们不会触发更新

// ✅ 用 toRefs:将响应式对象的每个属性转换为 ref
import { toRefs } from 'vue'
const state = reactive({ count: 0, name: '张三' })  // 原始响应式对象
const { count, name } = toRefs(state)  // 解构出 ref,保持响应性
// count 和 name 是 Ref<number> 和 Ref<string>,需要 .value 访问

reactive 的类型标注

ts
// 定义状态接口,描述响应式对象的结构
interface State {
  count: number        // 计数器
  user: User | null    // 用户信息,可为空
  list: string[]       // 字符串列表
}

// 方式一:通过变量类型标注
const state: State = reactive({
  count: 0,           // 初始值 0
  user: null,         // 初始无用户
  list: [],           // 初始空数组
})

// 方式二:通过泛型参数标注(推荐)
const state = reactive<State>({
  count: 0,           // 初始值 0
  user: null,         // 初始无用户
  list: [],           // 初始空数组
})

computed

大白话解释:computed 就像"自动计算的公式"。你定义一个计算规则,Vue 会自动算出结果并缓存起来。只有当依赖的数据变化时才会重新计算,没变化就直接返回上次的结果,避免重复运算。

computed 的核心特性:

  • 有缓存:依赖不变就不重新计算,比 methods 性能好
  • 自动追踪依赖:用到了哪些响应式数据,Vue 自动记录
  • 只读 / 可读写:默认只读,也可以设置 setter 实现双向绑定

什么时候用 computed?

  • 从已有数据派生新数据(如全名 = 姓 + 名)
  • 格式化显示(如价格加 ¥ 符号)
  • 条件判断(如表单是否可提交)
  • 列表过滤与排序

计算属性,具有缓存特性,只有依赖变化时才重新计算。

ts
import { ref, computed } from 'vue'  // 导入 ref 和 computed

// 创建响应式数据
const firstName = ref('张')    // 姓
const lastName = ref('三')     // 名
const price = ref(100)         // 单价
const quantity = ref(3)        // 数量

// 只读 computed:依赖变化时自动重新计算,有缓存
const fullName = computed(() => firstName.value + lastName.value)  // 全名
const totalPrice = computed(() => price.value * quantity.value)    // 总价

// 带缓存的复杂计算:过滤和汇总
const items = ref([
  { name: '苹果', price: 5, count: 2, active: true },   // 激活的商品
  { name: '香蕉', price: 3, count: 5, active: false },  // 未激活的商品
])

// 过滤激活的商品
const activeItems = computed(() => items.value.filter((item) => item.active))
// 计算总金额:单价 × 数量 求和
const totalAmount = computed(() =>
  items.value.reduce((sum, item) => sum + item.price * item.count, 0)
)

// 可读写 computed:提供 getter 和 setter
const fullNameRW = computed({
  // getter:返回全名
  get: () => firstName.value + lastName.value,
  // setter:拆分字符串更新姓和名
  set: (val: string) => {
    firstName.value = val[0]          // 第一个字符作为姓
    lastName.value = val.slice(1)     // 剩余字符作为名
  },
})

fullNameRW.value = '李四' // 触发 setter,更新 firstName 和 lastName

computed vs methods

ts
// ✅ computed —— 有缓存,依赖不变时返回上次结果
const expensive = computed(() => {
  console.log('computed 执行了')  // 只在依赖变化时执行
  return bigList.value.filter(...).map(...).reduce(...)  // 复杂计算
})

// ❌ methods —— 每次调用都执行,无缓存
function expensive() {
  console.log('methods 执行了')  // 每次访问都执行
  return bigList.value.filter(...).map(...).reduce(...)  // 相同计算
}

computed 的常见用法

ts
// 派生状态:从原始数据推导出布尔值
const isLoggedIn = computed(() => !!token.value)  // token 存在即登录
const isAdmin = computed(() => roles.value.includes('admin'))  // 角色包含 admin

// 格式化显示:将数字格式化为带货币符号的字符串
const formattedPrice = computed(() => `¥${price.value.toFixed(2)}`)  // 保留两位小数

// 条件判断:多个条件同时满足才可提交
const canSubmit = computed(() => {
  return form.value.username && form.value.password && !loading.value  // 用户名、密码非空且未加载中
})

// 列表过滤与排序:根据关键词过滤,按分数降序排列
const filteredList = computed(() => {
  return list.value
    .filter((item) => item.name.includes(keyword.value))  // 名称包含关键词
    .sort((a, b) => b.score - a.score)                    // 分数高的排前面
})

watch / watchEffect

大白话解释:watchwatchEffect 都是"数据变化时执行某些操作"的工具,区别在于:

  • watch:你明确告诉它"帮我盯着某个数据",数据变了就执行回调(类似保安盯着特定的人)
  • watchEffect:你写一段代码,它自动发现里面用到了哪些数据,那些数据一变就重新执行(类似自动追踪器)

什么时候用 watch?

  • 需要对比新旧值(如搜索关键词变化时,记录旧值)
  • 需要精确控制监听哪个数据
  • 需要延迟执行(默认不立即执行)

什么时候用 watchEffect?

  • 副作用与多个响应式数据自动绑定(如同时依赖 keyword 和 page)
  • 不需要旧值,只关心最新状态
  • 需要立即执行一次

watch —— 明确指定侦听源

ts
import { ref, watch, reactive } from 'vue'  // 导入 ref、watch、reactive

// 创建响应式数据
const count = ref(0)                    // 计数器
const keyword = ref('')                 // 搜索关键词
const user = reactive({ name: '', age: 0 })  // 用户对象

// 侦听单个 ref:当 count 变化时触发回调
watch(count, (newVal, oldVal) => {
  console.log(`count: ${oldVal} → ${newVal}`)  // 输出新旧值
})

// 侦听多个源:任一源变化都会触发回调
watch([count, keyword], ([newCount, newKeyword], [oldCount, oldKeyword]) => {
  console.log(`count: ${oldCount} → ${newCount}`)        // 计数器变化
  console.log(`keyword: ${oldKeyword} → ${newKeyword}`)  // 关键词变化
})

// 侦听 reactive 对象的某个属性(用 getter 函数)
watch(
  () => user.name,  // 返回要侦听的属性
  (newName, oldName) => {
    console.log(`name: ${oldName} → ${newName}`)  // 名字变化
  }
)

// 侦听 reactive 对象(深度监听,任何嵌套属性变化都触发)
watch(
  user,  // 直接传入 reactive 对象
  (newUser) => {
    console.log('user 变化了', newUser)  // 对象变化
  },
  { deep: true }  // 启用深度监听
)

// 立即执行:组件挂载后立即执行一次回调
watch(
  keyword,
  (newVal) => {
    fetchResults(newVal)  // 立即搜索当前关键词
  },
  { immediate: true }  // 立即执行
)

watchEffect —— 自动追踪依赖

ts
import { ref, watchEffect } from 'vue'  // 导入 ref 和 watchEffect

// 创建响应式数据
const keyword = ref('')  // 搜索关键词
const page = ref(1)      // 当前页码

// 自动追踪内部用到的所有响应式数据
watchEffect(async () => {
  // keyword 或 page 变化时自动执行
  const res = await api.search(keyword.value, page.value)  // 发起搜索请求
  results.value = res.data  // 更新结果
})

watch vs watchEffect

特性watchwatchEffect
指定侦听源必须不需要,自动追踪
旧值可获取不可获取
立即执行需设置 immediate: true默认立即执行
适用场景需要对比新旧值副作用与依赖自动绑定

核心区别

  • watch:明确指定侦听源,可以获取新旧值,适合需要精确控制的场景
  • watchEffect:自动追踪依赖,立即执行,适合副作用与多个数据绑定的场景

选择建议

  • 需要对比新旧值 → 使用 watch
  • 依赖多个响应式数据,不需要旧值 → 使用 watchEffect
  • 需要立即执行一次 → 使用 watchEffectwatch + immediate: true

停止侦听器

ts
// 在 setup 中创建的侦听器,组件卸载时自动停止

// 手动停止:返回停止函数
const stop = watch(count, (newVal) => {
  if (newVal > 100) {
    stop() // 超过 100 后停止监听,避免不必要的更新
  }
})

// 在 onUnmounted 中手动清理(非 setup 顶层创建时)
const unwatch = watchEffect(() => { ... })  // 创建侦听器
onUnmounted(() => unwatch())  // 组件卸载时停止侦听

侦听器的 flush 选项

ts
// 默认:回调在 DOM 更新前执行(flush: 'pre')
watch(count, (newVal) => {
  // DOM 还没更新,适合准备性操作
})

// flush: 'post' —— DOM 更新后执行,适合操作 DOM
watch(
  count,
  (newVal) => {
    // DOM 已更新,可以安全操作 DOM 元素
  },
  { flush: 'post' }  // 在 DOM 更新后执行回调
)

// flush: 'sync' —— 同步执行(性能差,少用)
watch(count, (newVal) => { ... }, { flush: 'sync' })  // 同步执行,阻塞更新

💡 flush: 'post'flush: 'sync'watch/watchEffect 的内置选项,无需额外导入。


生命周期钩子

大白话解释: 生命周期钩子就像"人生的各个阶段"。组件从创建到销毁,会经历一系列阶段,你可以在每个阶段插入自己的代码:

  • onMounted:组件"出生"了,DOM 已经渲染完毕,可以操作 DOM、发起请求
  • onUpdated:组件"更新"了,数据变化导致页面重新渲染
  • onBeforeUnmount:组件"临终"前,清理定时器、事件监听等副作用
  • onUnmounted:组件"去世"了,已经从页面移除

什么时候用生命周期钩子?

  • onMounted:初始化第三方库(如 ECharts)、发起 API 请求、添加全局事件监听
  • onBeforeUnmount/onUnmounted:清除定时器、取消事件监听、断开 WebSocket 连接
  • onActivated/onDeactivated:KeepAlive 缓存组件的激活/休眠回调
ts
import {
  onBeforeMount,    // 挂载前
  onMounted,        // 挂载后
  onBeforeUpdate,   // 更新前
  onUpdated,        // 更新后
  onBeforeUnmount,  // 卸载前
  onUnmounted,      // 卸载后
  onErrorCaptured,  // 捕获错误
  onActivated,      // KeepAlive 激活
  onDeactivated,    // KeepAlive 停用
  onServerPrefetch, // SSR 预取
} from 'vue'  // 导入所有生命周期钩子

// 组件挂载后:DOM 已渲染,可以操作 DOM
onMounted(() => {
  console.log('组件已挂载,可以操作 DOM')
  // 常用:初始化第三方库、发起请求、添加事件监听
})

// 组件卸载前:清理副作用,避免内存泄漏
onBeforeUnmount(() => {
  console.log('即将卸载,清理副作用')
  // 常用:清除定时器、取消事件监听、断开连接
})

// 组件已卸载:从 DOM 移除
onUnmounted(() => {
  console.log('已卸载')
})

💡 <script setup> 中没有 beforeCreatecreated,因为 setup 本身就是在这两个钩子之间执行的。

Options API 与 Composition API 生命周期对照

Options API          Composition API
───────────────      ──────────────────
beforeCreate    →    setup()
created         →    setup()
beforeMount     →    onBeforeMount()
mounted         →    onMounted()
beforeUpdate    →    onBeforeUpdate()
updated         →    onUpdated()
beforeDestroy   →    onBeforeUnmount()
destroyed       →    onUnmounted()
activated       →    onActivated()
deactivated     →    onDeactivated()
errorCaptured   →    onErrorCaptured()

provide / inject

大白话解释:provide/inject 就像"家族传承"。祖先组件通过 provide 提供数据,后代组件(不管隔了多少层)通过 inject 直接获取,不需要一层一层传 props。

为什么用 provide/inject?

  • 避免 props 逐层传递:爷爷 → 爸爸 → 儿子,如果中间的爸爸不需要这个数据,传 props 就很浪费
  • 适合全局配置:主题色、语言、当前用户等全局信息

provide/inject vs Pinia 怎么选?

  • provide/inject:组件树内部的局部共享,如表单组件传递表单实例
  • Pinia:全局状态管理,需要 DevTools 追踪、持久化、跨页面共享

跨层级组件通信,类似 React 的 Context。

ts
// 祖先组件
import { provide, ref, readonly } from 'vue'  // 导入 provide、ref、readonly

// 创建响应式数据
const theme = ref('dark')  // 主题色
const toggleTheme = () => {
  theme.value = theme.value === 'dark' ? 'light' : 'dark'  // 切换主题
}

// 提供只读数据 + 修改方法
provide('theme', readonly(theme))      // 提供只读主题,防止后代直接修改
provide('toggleTheme', toggleTheme)    // 提供切换方法

// 提供 Symbol key 避免命名冲突
const ThemeKey = Symbol('theme')       // 创建唯一 Symbol
provide(ThemeKey, readonly(theme))     // 使用 Symbol 作为 key
ts
// 后代组件
import { inject } from 'vue'  // 导入 inject

// 注入数据,第二参数是默认值(祖先未提供时使用)
const theme = inject<string>('theme', 'light')  // 注入主题,默认 'light'
const toggleTheme = inject<() => void>('toggleTheme', () => {})  // 注入切换方法

// Symbol key(必须从共享文件导入,确保同一引用)
// import { ThemeKey } from '@/shared/keys'
const theme = inject(ThemeKey)  // 使用 Symbol 注入,避免命名冲突

组合式函数 (Composables)

大白话解释: Composables 就像"乐高积木"。把可复用的逻辑(如计数器、请求、事件监听)封装成一个个独立的函数,需要时直接"拼装"到组件里。每个 Composable 都是一块积木,可以自由组合。

为什么用 Composables?

  • 逻辑复用:多个组件用同一套逻辑,不用重复写
  • 关注点分离:把相关逻辑集中在一起,而不是分散在 data/methods/computed 里
  • 易于测试:纯函数,单独测试很方便

什么时候用 Composables?

  • 多个组件共享同一套逻辑(如表单验证、分页、搜索)
  • 封装副作用逻辑(如请求、事件监听、定时器)
  • 封装状态管理逻辑(如本地存储、主题切换)

将可复用的逻辑抽离为独立函数,是 Composition API 最强大的模式。

基本结构

ts
// composables/useCounter.ts
import { ref, computed } from 'vue'  // 导入 ref 和 computed

// 计数组合式函数:封装计数器逻辑
export function useCounter(initial = 0) {
  const count = ref(initial)  // 响应式计数器
  const doubled = computed(() => count.value * 2)  // 双倍值,自动缓存

  // 增加计数
  function increment() { count.value++ }
  // 减少计数
  function decrement() { count.value-- }
  // 重置为初始值
  function reset() { count.value = initial }

  // 返回状态和方法
  return {
    count,      // 计数器
    doubled,    // 双倍值
    increment,  // 增加方法
    decrement,  // 减少方法
    reset,      // 重置方法
  }
}
vue
<script setup lang="ts">
// 导入组合式函数
import { useCounter } from '@/composables/useCounter'

// 使用组合式函数,解构获取状态和方法
const { count, doubled, increment } = useCounter(10)  // 初始值 10
</script>

<template>
  <!-- 显示计数器和双倍值 -->
  <p>count: {{ count }}, doubled: {{ doubled }}</p>
  <!-- 点击按钮增加计数 -->
  <button @click="increment">+1</button>
</template>

带副作用的 Composable

更多 Composable 示例(useFetch、useEventListener、useLocalStorage 等)详见 自定义指令 & Composables

ts
// composables/useEventListener.ts
import { onMounted, onUnmounted, type Ref } from 'vue'  // 导入生命周期钩子和 Ref 类型

// 事件监听组合式函数:自动管理事件监听器的添加和移除
export function useEventListener(
  target: Ref<EventTarget | null> | EventTarget,  // 目标元素(ref 或直接元素)
  event: string,                                   // 事件名
  handler: (e: Event) => void                      // 事件处理函数
) {
  // 组件挂载后添加事件监听
  onMounted(() => {
    const el = target instanceof EventTarget ? target : target.value  // 获取元素
    el?.addEventListener(event, handler)  // 添加事件监听
  })

  // 组件卸载前移除事件监听,避免内存泄漏
  onUnmounted(() => {
    const el = target instanceof EventTarget ? target : target.value  // 获取元素
    el?.removeEventListener(event, handler)  // 移除事件监听
  })
}
ts
// composables/useFetch.ts
import { ref, watchEffect, type Ref } from 'vue'  // 导入 ref、watchEffect、Ref 类型

// 数据请求组合式函数:封装 fetch 请求逻辑
export function useFetch<T>(url: Ref<string> | string) {
  const data = ref<T | null>(null)      // 响应数据
  const error = ref<Error | null>(null) // 错误信息
  const loading = ref(false)            // 加载状态

  // 执行请求
  async function execute() {
    loading.value = true   // 开始加载
    error.value = null     // 清空错误
    try {
      const urlValue = typeof url === 'string' ? url : url.value  // 获取 URL
      const res = await fetch(urlValue)  // 发起请求
      if (!res.ok) throw new Error(`HTTP ${res.status}`)  // 检查响应状态
      data.value = await res.json()  // 解析 JSON
    } catch (e) {
      error.value = e as Error  // 捕获错误
    } finally {
      loading.value = false  // 结束加载
    }
  }

  // 自动追踪 url 变化,url 变化时自动重新请求
  watchEffect(() => { execute() })

  return { data, error, loading, execute }  // 返回状态和方法
}

Vue 3.3+ 新增 API

toValue()

将 ref、getter 或普通值统一解包为原始值,比 unref() 更强大。

ts
import { toValue, ref } from 'vue'  // 导入 toValue 和 ref

// toValue 统一处理 ref / getter / 普通值
const count = ref(5)                // 创建 ref
toValue(count)                     // 5,解包 ref
toValue(() => count.value)         // 5,调用 getter
toValue(10)                        // 10,直接返回普通值

// 在 composable 中使用:统一处理不同类型的参数
export function useFetch(url: Ref<string> | string | (() => string)) {
  // toValue 统一处理 ref / getter / 普通值
  const urlValue = toValue(url)  // 获取实际 URL 字符串
}

useTemplateRef()(Vue 3.5+)

获取模板 ref 的新推荐方式,替代 ref() + 同名变量。

vue
<script setup lang="ts">
import { useTemplateRef } from 'vue'  // 导入 useTemplateRef

// 参数名必须与 template 中的 ref="xxx" 一致
const inputRef = useTemplateRef<HTMLInputElement>('inputRef')  // 获取模板 ref

// 组件挂载后自动聚焦输入框
onMounted(() => {
  inputRef.value?.focus()  // 调用 focus 方法
})
</script>

<template>
  <!-- 模板 ref,与 useTemplateRef 参数对应 -->
  <input ref="inputRef" />
</template>

useId()(Vue 3.5+)

生成唯一的 ID,SSR 安全,适合无障碍属性。

vue
<script setup lang="ts">
import { useId } from 'vue'  // 导入 useId

const id = useId() // 生成如 ":rp:" 的唯一 ID,SSR 安全
</script>

<template>
  <!-- 使用唯一 ID 关联 label 和 input -->
  <label :for="id">用户名</label>
  <input :id="id" />
</template>

Composables 命名规范

ts
// ✅ 以 use 开头:命名规范,便于识别和搜索
useCounter()        // 计数器
useFetch()          // 数据请求
useLocalStorage()   // 本地存储
useEventListener()  // 事件监听

// ✅ 返回值用对象形式(解构时可重命名)
return { count, doubled, increment }  // 返回状态和方法

// ✅ 参数用 ref 或 getter(保持响应性)
export function useTitle(newTitle: Ref<string> | (() => string)) { ... }  // 接受 ref 或 getter

顶层 await

<script setup> 中可以直接使用顶层 await:

vue
<script setup lang="ts">
// 顶层 await:直接使用 await,无需 async 函数包裹
const res = await fetch('/api/user')  // 发起请求
const user = await res.json()         // 解析 JSON
</script>

⚠️ 顶层 await 会将组件变为异步组件,父组件需要 <Suspense> 包裹。


内置组件

Teleport(传送门)

大白话解释: Teleport 就像"传送门"。组件的逻辑还在原来的位置,但 DOM 元素可以"传送"到页面的其他位置。最典型的场景是弹窗:弹窗的逻辑写在组件里,但 DOM 渲染到 <body> 下,避免被父组件的 overflow: hiddenz-index 影响。

什么时候用 Teleport?

  • 弹窗 / 对话框(Modal)
  • 抽屉(Drawer)
  • 消息提示(Toast / Notification)
  • 任何需要脱离父组件 DOM 层级的场景

将组件的 DOM 渲染到指定位置,常用于弹窗、抽屉等需要脱离父组件层级的场景。

vue
<script setup lang="ts">
import { ref } from 'vue'  // 导入 ref

const showModal = ref(false)  // 控制弹窗显示
</script>

<template>
  <!-- 打开弹窗按钮 -->
  <button @click="showModal = true">打开弹窗</button>

  <!-- 渲染到 body 下,不受父组件样式影响 -->
  <Teleport to="body">
    <div v-if="showModal" class="modal-overlay">
      <div class="modal-content">
        <p>弹窗内容</p>
        <!-- 关闭弹窗按钮 -->
        <button @click="showModal = false">关闭</button>
      </div>
    </div>
  </Teleport>
</template>

💡 Teleport 只改变 DOM 位置,不影响组件逻辑和数据流。<Teleport to="body"> 是最常用的目标。

Suspense(异步组件容器)

大白话解释: Suspense 就像"加载等待区"。当组件内部有异步操作(如 await fetch)时,Suspense 会在数据加载完成前显示一个"加载中"的占位内容,加载完毕后再渲染真正的组件。

什么时候用 Suspense?

  • 组件内部使用了顶层 await(如 <script setup> 中直接 await 请求)
  • 需要统一处理多个异步组件的加载状态

💡 注意:Suspense 在 Vue 3.5 中已稳定,可放心使用。

配合异步组件和顶层 await 使用,在加载完成前显示 fallback 内容。

vue
<!-- AsyncComponent.vue -->
<script setup lang="ts">
// 异步组件:使用顶层 await
const res = await fetch('/api/user')  // 发起请求
const user = await res.json()         // 解析 JSON
</script>

<template>
  <!-- 显示用户名 -->
  <p>{{ user.name }}</p>
</template>
vue
<!-- App.vue -->
<template>
  <!-- Suspense 包裹异步组件 -->
  <Suspense>
    <!-- 默认插槽:异步组件 -->
    <template #default>
      <AsyncComponent />  <!-- 异步组件 -->
    </template>

    <!-- fallback 插槽:加载中显示 -->
    <template #fallback>
      <div>加载中...</div>  <!-- 加载占位内容 -->
    </template>
  </Suspense>
</template>

💡 Suspense 在 Vue 3.5 中已稳定,可放心使用。


常见坑点

1. ref 与 reactive 混用

ts
// ❌ reactive 中的 ref 不需要 .value
const count = ref(0)                    // 创建 ref
const state = reactive({ count })       // ref 作为 reactive 属性时自动解包
// state.count 是 number,不是 ref

// ✅ 但直接访问 count 仍需要 .value
count.value++  // 正确,直接访问 ref
state.count++  // 正确,访问 reactive 属性

2. 解构 reactive 丢失响应性

ts
const state = reactive({ count: 0, name: '张三' })  // 创建响应式对象

// ❌ 解构后丢失响应性
const { count, name } = state  // count 和 name 是普通值

// ✅ 用 toRefs:将每个属性转换为 ref
const { count, name } = toRefs(state)  // count 和 name 是 Ref 类型

3. watch 的第一个参数

ts
const state = reactive({ count: 0 })  // 创建响应式对象

// ❌ 不能直接传 reactive 对象的属性
watch(state.count, (newVal) => { ... }) // state.count 是 number,不是响应式引用

// ✅ 用 getter 函数:返回要侦听的属性
watch(() => state.count, (newVal) => { ... })  // getter 函数返回响应式属性

4. 组件销毁时清理副作用

ts
// ✅ 在 composable 中自动清理副作用
export function useTimer(callback: () => void, delay: number) {
  let timer: number  // 定时器引用

  // 组件挂载后启动定时器
  onMounted(() => {
    timer = setInterval(callback, delay)  // 设置定时器
  })

  // 组件卸载前清除定时器,避免内存泄漏
  onUnmounted(() => {
    clearInterval(timer)  // 清除定时器
  })
}

参考

个人学习笔记,部分内容借助 AI 辅助整理,仅供查阅参考,请以官方文档为准