Vue 3组合式API实战

张玥 2026年9月19日 阅读时间 55分钟
Vue 3 Composition API 组合式函数 TypeScript 性能优化
前端开发技巧之Vue 3组合式API实战

2020 年 9 月,Vue 3 正式发布,Composition API 随之登场。六年过去,它早已不是“新特性”,而是 Vue 生态的默认写法——<script setup> 成为脚手架模板的标配,Pinia、VueUse、Vue Router 全部围绕它重构,社区里几乎找不到还在用 mixins 的新项目。但“会用”和“用对”之间,隔着一整个工程实践的距离。很多人把 Options API 的 data / methods 原封不动搬进 setup,只是换了个语法外壳;也有人被 ref 和 reactive 的取舍、watch 的触发时机、computed 的缓存失效搞到头大。本篇 55 分钟长文,从响应式基石讲到组合式函数封装,从生命周期时序讲到 TypeScript 类型推导,用十几个可直接落地的实战案例,把组合式 API 真正变成你手里的工程武器。

一、为什么需要组合式 API:从 Options 的痛点说起

Options API 的设计哲学是“按选项类型组织代码”:所有状态放 data,所有计算放 computed,所有方法放 methods,所有副作用放 watch。这在小组件里非常清晰,但当组件承载了多个互不相关的功能时,问题就暴露了。

一个真实的“搜索 + 分页 + 收藏”组件

假设有一个商品列表页,同时包含三个功能:关键词搜索、分页加载、收藏切换。用 Options API 写出来,代码会像这样分布:

data keyword、list、page、pageSize、total、favorites、loading —— 三组状态混在一起
computed filteredList、totalPages、isAllFavorite —— 三个功能的派生状态互相交错
methods handleSearch、handlePageChange、toggleFavorite、fetchList —— 散落在同一层级
watch keyword 防抖搜索、page 重新请求、favorites 持久化 —— 三段无关的侦听器排在一起

结果是:改一个功能,要在文件里上下横跳五次。当你想要把“收藏”逻辑复用到另一个页面时,会发现它和搜索、分页的代码缠在一起,根本拆不出来。这就是 Options API 最核心的痛点——代码按“选项类型”切分,而不是按“业务逻辑”切分。

Composition API 的答案:按逻辑组织

组合式 API 的核心主张只有一句话:把同一个功能的状态、计算、方法、副作用写在一起。同一个商品列表组件,用组合式 API 可以重写成三块:

import { useSearch } from './composables/useSearch'
import { usePagination } from './composables/usePagination'
import { useFavorites } from './composables/useFavorites'

// 三个功能各自独立,互不干扰
const { keyword, debouncedKeyword } = useSearch()
const { page, pageSize, total, totalPages } = usePagination()
const { favorites, toggle, isFavorite } = useFavorites()

// 组装:把三者串起来
watch([debouncedKeyword, page], fetchList, { immediate: true })

每个功能的代码都在自己的文件里自洽,需要复用就 import,不需要就删掉。这带来的不只是可读性,更是可测试性——组合式函数是纯 JavaScript 函数,可以脱离组件单独跑单元测试。

Options API 与 Composition API 对照

点击下面的按钮,对比同一段逻辑在两种范式下的写法差异。

<script>
export default {
  data() {
    return {
      count: 0,
      step: 1
    }
  },
  computed: {
    doubled() {
      return this.count * 2
    }
  },
  methods: {
    increment() {
      this.count += this.step
    }
  },
  watch: {
    count(val, old) {
      console.log('count 从', old, '变成', val)
    }
  },
  mounted() {
    console.log('组件已挂载')
  }
}
</script>

什么时候 Options API 依然是好选择

不必把它当成宗教战争。以下场景,Options API 依然称手:

  • 极简组件:只有两三个状态、一两个方法,用 setup 反而更啰嗦。
  • 团队尚未熟悉:强行推广组合式 API,可能带来更大的沟通成本。
  • 老项目维护:Vue 3 完整支持 Options API,没有迁移压力。

但只要你开始写中等以上复杂度的组件,或者有逻辑复用的需求,组合式 API 的优势就会迅速压倒一切。本篇文章后续所有内容,均以 <script setup> 为前提展开。

二、setup 语法糖与响应式三大基石

<script setup> 是组合式 API 的编译时语法糖。它做了三件事:把顶层变量自动暴露给模板、把顶层 await 变成异步组件、把 defineProps 等宏提升到编译期处理。理解它的编译结果,能避开很多“为什么这里拿不到值”的困惑。

ref 与 reactive:到底该用哪个

这是新手最常纠结的问题。先说结论:在 <script setup> 中,默认用 ref,只有在需要整体替换语义明确的“对象状态”时才用 reactive。原因有三:

// ✅ 推荐:ref 可以整体替换,也可以传参
const user = ref({ name: '', age: 0 })
user.value = { name: '张玥', age: 30 }

// ❌ 陷阱:reactive 解构后失去响应性
const state = reactive({ count: 0 })
let { count } = state  // count 是一个普通数字
count++  // 视图不会更新

// ✅ 用 toRefs 解构,保持响应性
const { count: c } = toRefs(state)
c.value++  // 视图正常更新
ref 任意类型,可整体替换,跨函数传递无歧义
reactive 仅对象/数组,不能整体替换,解构即失效
toRefs 把 reactive 拆成一组 ref,安全解构
切记 reactive 重新赋值会断开响应式连接

另一个容易被忽略的细节是:模板中访问 ref 会自动解包,所以写 {{ count }} 而不是 {{ count.value }}。但在 <script> 里必须写 .value。这个差异是新手最常见的心智负担,习惯之后反而会觉得清晰——因为 .value 的存在明确提醒你“这是一个响应式引用”。

computed:缓存才是它的灵魂

很多人把 computed 当成“写在模板里的函数”的替代品,这是误解。它最大的价值是基于依赖的缓存:只要依赖没变,重复读取不会重新执行。

const list = ref([...])
const keyword = ref('')

// ✅ computed:只在 list 或 keyword 变化时重新计算
const filtered = computed(() => {
  console.log('执行了过滤')  // 只在依赖变化时打印
  return list.value.filter(i => i.name.includes(keyword.value))
})

// ❌ 方法:每次重新渲染都会完整执行一遍
function getFiltered() {
  console.log('执行了过滤')  // 每次渲染都打印
  return list.value.filter(i => i.name.includes(keyword.value))
}

当列表有几千条数据,且组件因为其他状态频繁重渲染时,这个差异会直接反映在帧率上。凡是“由已有状态推导出来的值”,一律用 computed。

可写 computed:一个被低估的能力

computed 默认只读,但传入带 get / set 的对象后,它就变成了一个可以双向绑定的“计算属性”:

const firstName = ref('玥')
const lastName = ref('张')

const fullName = computed({
  get: () => `${lastName.value}${firstName.value}`,
  set: (val) => {
    [lastName.value, firstName.value] = val.split('')
  }
})

fullName.value = '李雷'
// lastName → '李',firstName → '雷'

这个模式在“派生 + 回写”的场景非常好用,比如把日期对象和字符串互转、把百分比和进度条双向绑定。

动手实验:亲眼看看响应式与非响应式的差别

下面的实验台模拟了 Vue 的渲染机制。点击“ref 计数”会触发一次“重新渲染”,两个数字都会刷新;而单独点击“普通变量”时,它悄悄变了,但界面纹丝不动——直到下一次重新渲染,你才会看到它“跳变”成新值。这正是 ref 存在的意义。

ref(0) —— 响应式
0
let n = 0 —— 非响应式
0
[初始化] 组件首次渲染完成
观察要点:普通变量在点击后内部值已经改变,但界面显示的是上一次渲染时的快照;只有触发一次真正的重新渲染(点击 ref 计数),它的新值才会被“顺带”画到屏幕上。

三、生命周期钩子:组合式 API 的时序地图

组合式 API 保留了完整的生命周期能力,但调用方式从“选项”变成了“注册函数”。这些函数只能在 setup 的同步执行阶段调用——因为它们依赖当前正在初始化的组件实例。点击下面每个阶段,看看它对应什么时机、适合做什么。

1
setup
创建阶段
props 已解析、组件实例已创建,但 DOM 尚未生成。在这里初始化响应式状态、注册侦听器、调用组合式函数。注意:不要在这里访问模板 ref,此时它还是 null。
2
onMounted
挂载完成
DOM 已经渲染并插入文档,可以安全访问模板 ref、测量元素尺寸、初始化第三方库(图表、地图、编辑器)、发起首屏数据请求。SSR 环境下不会执行。
3
onUpdated
更新完成
响应式数据变化引发 DOM 更新之后触发。切勿在此修改状态,极易造成无限循环。适合做与 DOM 同步的副作用,例如重新计算滚动位置、同步 canvas 绘制。
4
onUnmounted
卸载清理
组件实例被销毁后触发。清理定时器、移除全局事件监听、断开 WebSocket、abort 未完成的请求、销毁第三方实例。这是最容易被忽略、也最容易造成内存泄漏的一环。

完整的生命周期对照表

Options API Composition API 执行时机
beforeCreate 无需使用 逻辑直接写在 setup 顶部
created 无需使用 逻辑直接写在 setup 顶部
beforeMount onBeforeMount 挂载开始前,DOM 尚未生成
mounted onMounted DOM 挂载完成
beforeUpdate onBeforeUpdate 数据变化后、DOM 更新前
updated onUpdated DOM 更新完成
beforeUnmount onBeforeUnmount 卸载前,实例仍可用
unmounted onUnmounted 卸载完成,实例已销毁
activated onActivated 被 KeepAlive 缓存的组件激活时
deactivated onDeactivated 被 KeepAlive 缓存的组件停用时
errorCaptured onErrorCaptured 捕获后代组件抛出的错误

实战:一个规范的“第三方编辑器”组件

下面这个例子展示了生命周期钩子的正确使用姿势——挂载时创建实例,卸载时销毁,中间用 watch 同步数据:

<script setup>
import { ref, watch, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
import Editor from 'some-editor-lib'

const container = ref(null)
const model = defineModel({ type: String, default: '' })

let editor = null
let resizeObserver = null

onMounted(() => {
  // DOM 已就绪,可以安全初始化
  editor = new Editor(container.value, {
    value: model.value,
    onChange: (val) => { model.value = val }
  })

  // 监听容器尺寸变化,让编辑器自适应
  resizeObserver = new ResizeObserver(() => editor?.resize())
  resizeObserver.observe(container.value)
})

// 外部数据变化时同步到编辑器
watch(model, (val) => {
  if (editor && editor.getValue() !== val) editor.setValue(val)
})

// KeepAlive 缓存时暂停轮询,激活时恢复
onDeactivated(() => editor?.pause())
onActivated(() => editor?.resume())

// 卸载时彻底清理,避免内存泄漏
onBeforeUnmount(() => {
  resizeObserver?.disconnect()
  editor?.destroy()
  editor = null
})
</script>

一个常见误区:在 setup 里“同步等待”

下面这段代码看起来没问题,实际上是错的:

错误写法
await fetchData() 写在 setup 顶层,后面再调用 onMounted()。
此时组件实例的初始化已经结束,onMounted 不会报错,但回调永远不会执行。
正确写法
要么把 onMounted 移到 await 之前,要么使用 <Suspense> 包裹组件。所有生命周期注册都必须在 await 之前完成。

四、侦听器:watch、watchEffect 与它们的边界

侦听器是组合式 API 里最灵活、也最容易被滥用的部分。核心原则是:能不用 watch 就不用 watch。绝大多数“数据变了要重新算”的需求,应该由 computed 承担;只有真正需要执行副作用(请求、DOM 操作、日志、存储)时,才轮到 watch 出场。

watch 与 watchEffect 的分工

// watch:明确指定依赖,可拿到新旧值
watch(keyword, (newVal, oldVal) => {
  console.log('搜索词:', oldVal, '→', newVal)
}, { immediate: true })

// 同时监听多个源
watch([keyword, page], ([kw, p]) => {
  fetchList({ kw, p })
})

// 监听 reactive 对象的某个字段(必须用函数)
watch(() => state.count, (val) => { ... })

// 深度监听
watch(() => state.user, (val) => { ... }, { deep: true })
// watchEffect:自动收集依赖,立即执行
watchEffect(() => {
  // 用到谁就自动监听谁
  localStorage.setItem('kw', keyword.value)
  localStorage.setItem('page', page.value)
})

// 拿到停止函数,手动终止侦听
const stop = watchEffect(() => { ... })
stop()  // 停止监听

// 清理副作用:上一次执行的回调先跑
watchEffect((onCleanup) => {
  const timer = setTimeout(() => { ... }, 300)
  onCleanup(() => clearTimeout(timer))
})

选择建议

用 computed 由状态推导出新状态,且需要缓存 —— 这是最优先的选择
用 watch 需要旧值、需要精确控制依赖、需要 immediate 或 deep 选项
用 watchEffect 依赖关系复杂且会动态变化,只关心“执行副作用”不关心新旧值
避免 在 watch 里修改被监听的状态,造成循环;用 watch 做纯粹的数据转换

flush 选项:控制执行时机

这是 watch 里最容易被忽略、却对性能影响最大的参数:

  • flush: 'pre'(默认):组件更新前执行。此时 DOM 还是旧的,但可以访问到最新的状态。
  • flush: 'post':组件更新后执行。适合需要读取更新后 DOM 的场景,等同于把逻辑放进 onUpdated 但更精确。
  • flush: 'sync':同步执行。数据一变立刻触发,性能开销大,除非万不得已不要用。
// 需求:列表更新后自动滚动到底部
watch(messages, () => {
  listEl.value.scrollTop = listEl.value.scrollHeight
}, { flush: 'post' })  // 必须等 DOM 更新完才能拿到正确的 scrollHeight

实战:带清理的防抖搜索

把前面的知识组合起来,写一个健壮的搜索侦听器:

<script setup>
import { ref, watch } from 'vue'

const keyword = ref('')
const results = ref([])
const loading = ref(false)

watch(keyword, (kw, oldKw, onCleanup) => {
  // 空关键词直接清空,不浪费请求
  if (!kw.trim()) { results.value = []; return }

  const controller = new AbortController()
  loading.value = true

  const timer = setTimeout(async () => {
    try {
      const res = await fetch(`/api/search?q=${encodeURIComponent(kw)}`, {
        signal: controller.signal
      })
      results.value = await res.json()
    } catch (e) {
      if (e.name !== 'AbortError') console.error(e)
    } finally {
      loading.value = false
    }
  }, 300)

  // 关键:下一次触发前,取消上一次的定时器与请求
  onCleanup(() => {
    clearTimeout(timer)
    controller.abort()
  })
})
</script>

onCleanup 是 watch 回调的第三个参数,它在下一次回调执行之前以及侦听器被停止时触发。用好它,能避免绝大多数竞态条件和内存泄漏。

五、组合式函数:逻辑复用的正确姿势

组合式函数(Composable)是组合式 API 真正的杀手锏。它本质上就是一个以 use 开头、内部使用了响应式 API 的普通 JavaScript 函数。它替代了 mixins,但比 mixins 好得多——没有隐式的属性合并、没有命名冲突、来源清晰可追溯。

约定:组合式函数的三条规则

命名 一律以 use 开头,如 useMouse、useRequest、useLocalStorage
返回 返回一个普通对象,且尽量使用 ref 而非 reactive,方便调用方解构
同步 必须在 setup 同步阶段调用,因为内部可能需要注册生命周期钩子

实战一:useMouse —— 最简单的组合式函数

// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(e) {
    x.value = e.pageX
    y.value = e.pageY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

注意这里的关键点:事件监听的注册与清理被封装在函数内部。调用方只需要 const { x, y } = useMouse(),完全不用操心卸载时要不要解绑——这是组合式函数最迷人的地方,它把“配对的资源管理”变成了一个不可分割的整体。

实战二:useLocalStorage —— 带持久化的响应式状态

// composables/useLocalStorage.js
import { ref, watch } from 'vue'

export function useLocalStorage(key, defaultValue) {
  let initial = defaultValue
  try {
    const raw = localStorage.getItem(key)
    if (raw !== null) initial = JSON.parse(raw)
  } catch (e) {
    console.warn(`[useLocalStorage] 解析 ${key} 失败`, e)
  }

  const data = ref(initial)

  watch(data, (val) => {
    try {
      if (val === null || val === undefined) {
        localStorage.removeItem(key)
      } else {
        localStorage.setItem(key, JSON.stringify(val))
      }
    } catch (e) {
      console.warn(`[useLocalStorage] 写入 ${key} 失败`, e)
    }
  }, { deep: true })

  return data
}

调用方可以直接把它当成普通 ref 用,数据会自动同步到 localStorage:

<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage'

// 主题偏好:刷新页面后依然保留
const theme = useLocalStorage('theme', 'light')
const sidebarCollapsed = useLocalStorage('sidebar', false)
</script>

<template>
  <button @click="theme = theme === 'light' ? 'dark' : 'light'">
    当前主题:{{ theme }}
  </button>
</template>

实战三:组合式函数之间的组合

真正的威力在于,组合式函数可以调用其他组合式函数。下面这个 useSearchList 把 useRequest、useDebounce 和分页逻辑拼装在一起:

import { ref, computed, watch } from 'vue'
import { useRequest } from './useRequest'
import { useDebounce } from './useDebounce'

export function useSearchList(fetcher, options = {}) {
  const keyword = ref('')
  const debouncedKeyword = useDebounce(keyword, 300)
  const page = ref(1)
  const pageSize = ref(options.pageSize ?? 20)

  const { data, loading, error, run } = useRequest(
    () => fetcher({
      keyword: debouncedKeyword.value,
      page: page.value,
      pageSize: pageSize.value
    }),
    { immediate: false }
  )

  // 关键词变化时回到第一页并重新请求
  watch(debouncedKeyword, () => {
    page.value = 1
    run()
  })

  // 页码变化时重新请求
  watch(page, run)

  const total = computed(() => data.value?.total ?? 0)
  const totalPages = computed(() => Math.ceil(total.value / pageSize.value))

  return { keyword, page, pageSize, total, totalPages, data, loading, error, run }
}

在使用它的组件里,只需要三行:

const { keyword, page, total, totalPages, data, loading } =
  useSearchList(api.fetchProducts, { pageSize: 24 })

组合式函数 vs mixins

维度 mixins 组合式函数
数据来源 隐式 属性被合并到 this 显式 从返回值中解构
命名冲突 容易冲突 后注册的覆盖前面的 不会 调用方自行重命名
可追溯性 差 不知道属性来自哪个 mixin 好 变量声明处一目了然
参数传递 不支持 只能靠约定 支持 就是一个普通函数
类型推导 困难 this 类型难以推断 完整 返回值类型清晰
单元测试 需挂载组件 可直接调用

六、依赖注入:provide / inject 打通组件层级

当组件层级变深,用 props 逐层透传(prop drilling)会变得极其笨重:中间三四层组件明明不需要这个数据,却必须原样接收再原样传递。组合式 API 提供了 provide / inject 来解决这个问题。

基础用法

// 祖先组件:提供数据
<script setup>
import { provide, ref } from 'vue'

const theme = ref('light')
function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}

// 提供响应式数据 + 修改方法
provide('theme', { theme, toggleTheme })
</script>
// 任意深度的后代组件:注入
<script setup>
import { inject } from 'vue'

const { theme, toggleTheme } = inject('theme')

// 提供默认值,避免找不到时报错
const config = inject('config', { locale: 'zh-CN' })
</script>

进阶:用 Symbol 作为 key,配合 TypeScript

字符串 key 有一个明显的问题:不同库可能用同一个字符串,造成意外覆盖;而且 IDE 无法提示有哪些可注入项。推荐的做法是把 key 抽到一个单独的文件里:

// keys.js
import { type InjectionKey, type Ref } from 'vue'

export interface ThemeContext {
  theme: Ref<'light' | 'dark'>
  toggleTheme: () => void
}

export const themeKey: InjectionKey<ThemeContext> =
  Symbol('theme')

// 提供方
provide(themeKey, { theme, toggleTheme })

// 注入方:自动获得完整类型提示
const { theme, toggleTheme } = inject(themeKey)!

实战:把 provide / inject 封装成组合式函数

直接暴露 provide / inject 会让调用方关心 key 的细节。更好的做法是封装成一对组合式函数:

// composables/useTheme.js
import { ref, provide, inject, computed } from 'vue'

const themeKey = Symbol('theme')

export function provideTheme() {
  const theme = ref('light')

  function toggleTheme() {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
  }

  const isDark = computed(() => theme.value === 'dark')

  provide(themeKey, { theme, isDark, toggleTheme })
  return { theme, isDark, toggleTheme }
}

export function useTheme() {
  const ctx = inject(themeKey)
  if (!ctx) {
    throw new Error('useTheme() 必须在 provideTheme() 的后代组件中调用')
  }
  return ctx
}

这样一来,使用方完全不需要知道 key 的存在,也获得了明确的错误提示。这个“provideXxx + useXxx”的组合模式,是 Vue 生态中非常成熟的实践——Vue Router 的 provideRouter、Pinia 的 createPinia 都遵循类似的思路。

provide / inject 的注意事项

  • 不要滥用:它适合“跨越多个层级、且与组件树结构强相关”的数据,比如主题、国际化、表单上下文、表格行索引。业务数据依然优先考虑 props 或状态管理。
  • 默认值只是兜底:inject(key, defaultValue) 的默认值不会响应式更新,如果确实需要响应式,应该显式提供一个 ref。
  • provide 的数据不会被自动解包:如果你 provide 的是一个 ref,inject 拿到的也是 ref,需要 .value 访问。
  • 调试:Vue DevTools 可以查看组件树的 provides/injects 关系,排查问题时非常有用。

七、组件通信与模板引用

组合式 API 对组件通信做了大量简化。特别是 Vue 3.4 引入的 defineModel,让自定义 v-model 从“三行样板代码”变成了“一行”。

defineProps 与 defineEmits

<script setup>
// 运行时声明
const props = defineProps({
  title: { type: String, required: true },
  count: { type: Number, default: 0 },
  tags: { type: Array, default: () => [] }
})

// 声明事件
const emit = defineEmits({
  change: (value) => true,
  submit: (payload) => payload && payload.id != null
})

// 触发事件
function handleClick() {
  emit('change', 42)
}
</script>

props 是只读的。如果你试图在子组件中直接修改 props.title = 'xxx',Vue 会在开发环境发出警告。需要修改时,正确的做法是:

  • 在子组件内部创建一个基于 props 的本地 ref,通过 watch 同步。
  • 或者使用 defineModel / 触发事件,把修改权交还给父组件。
  • 或者使用 computed 的 get / set 代理。

defineModel:自定义 v-model 的正确姿势

在 Vue 3.4 之前,实现一个自定义 v-model 需要声明 props、声明 emits、写 computed 的 get/set,三处样板代码。现在只需要一行:

<!-- 子组件 MyInput.vue -->
<script setup>
const model = defineModel()
</script>

<template>
  <input v-model="model">
</template>

<!-- 父组件 -->
<MyInput v-model="text" />
// 带类型与默认值
const model = defineModel({
  type: String,
  default: ''
})

// 具名 v-model:v-model:title
const title = defineModel('title')

// 修饰符:v-model.trim
const [model, modifiers] = defineModel({
  set(value) {
    return modifiers.trim ? value.trim() : value
  }
})

模板引用:ref 的新写法

在组合式 API 中,模板 ref 的声明和普通状态一样,用 ref(null),然后在模板里用同名的 ref 属性绑定:

<script setup>
import { ref, onMounted } from 'vue'
import MyChild from './MyChild.vue'

const inputEl = ref(null)  // 普通 DOM 元素
const childRef = ref(null)  // 子组件实例
const itemRefs = ref([])  // v-for 中的元素集合

onMounted(() => {
  inputEl.value.focus()
  childRef.value.open()  // 调用子组件通过 defineExpose 暴露的方法
  console.log(itemRefs.value)  // DOM 元素数组
})
</script>

<template>
  <input ref="inputEl">
  <MyChild ref="childRef" />
  <li v-for="item in list" :key="item.id" ref="itemRefs">...</li>
</template>

注意:子组件默认是“封闭”的,父组件通过模板 ref 只能拿到一个空对象。必须由子组件主动使用 defineExpose 暴露方法和属性:

<!-- 子组件 -->
<script setup>
const visible = ref(false)

function open() { visible.value = true }
function close() { visible.value = false }

// 只暴露需要给外部使用的方法
defineExpose({ open, close })
</script>

还有一种更“组合式”的做法:子组件在 onMounted 时通过 emit 把方法抛给父组件,或者用 provide 暴露。模板 ref 适合“父组件需要命令式控制子组件”的场景,比如打开弹窗、触发校验、重置表单。

实战:命令式弹窗组件

<!-- Modal.vue -->
<script setup>
const visible = ref(false)
const dialogEl = ref(null)

function open() {
  visible.value = true
  nextTick(() => dialogEl.value?.showModal())
}

function close() {
  dialogEl.value?.close()
  visible.value = false
}

defineExpose({ open, close })
</script>

<template>
  <dialog ref="dialogEl" @close="visible = false">
    <slot />
  </dialog>
</template>

八、实战:useRequest —— 数据请求的完整封装

数据请求是前端应用最高频的副作用。每个页面都在重复着 loading / data / error 的三件套,再加上竞态处理、请求取消、轮询、防抖……如果不做封装,这些代码会散落在每一个组件里。这一节我们动手写一个生产可用的 useRequest。

先明确需求

基础 自动维护 loading、data、error 三个状态
竞态 多次请求时,丢弃过期响应,只保留最后一次结果
取消 新请求发起时自动 abort 上一个;组件卸载时自动 abort
手动 支持 immediate 控制、支持手动 run / refresh
钩子 onSuccess / onError 回调,便于统一提示

完整实现

// composables/useRequest.js
import { ref, shallowRef, onUnmounted, unref } from 'vue'

export function useRequest(fetcher, options = {}) {
  const {
    immediate = true,
    initialData = null,
    onSuccess,
    onError,
    onFinally
  } = options

  // 用 shallowRef:接口返回的大对象不需要深度响应式
  const data = shallowRef(initialData)
  const error = shallowRef(null)
  const loading = ref(false)

  // 请求序号:用于丢弃过期响应
  let seq = 0
  let controller = null
  let destroyed = false

  async function run(...args) {
    const current = ++seq

    // 取消上一次未完成的请求
    controller?.abort()
    controller = new AbortController()

    loading.value = true
    error.value = null

    try {
      const result = await fetcher(...args, {
        signal: controller.signal
      })

      // 过期响应直接丢弃
      if (current !== seq || destroyed) return

      data.value = result
      await onSuccess?.(result)
      return result
    } catch (e) {
      // 主动取消不算错误
      if (e.name === 'AbortError' || current !== seq || destroyed) return

      error.value = e
      await onError?.(e)
    } finally {
      if (current === seq && !destroyed) {
        loading.value = false
        await onFinally?.()
      }
    }
  }

  function cancel() {
    controller?.abort()
    loading.value = false
  }

  // 组件卸载时自动清理,防止内存泄漏与状态更新警告
  onUnmounted(() => {
    destroyed = true
    controller?.abort()
  })

  if (immediate) run()

  return {
    data,
    error,
    loading,
    run,
    refresh: () => run(),
    cancel
  }
}

使用:一个商品详情页

<script setup>
import { toRef } from 'vue'
import { useRequest } from '@/composables/useRequest'
import { useToast } from '@/composables/useToast'

const props = defineProps({ id: [String, Number] })
const toast = useToast()

const { data: product, loading, error, refresh } = useRequest(
  async ({ signal }) => {
    const res = await fetch(`/api/products/${props.id}`, { signal })
    if (!res.ok) throw new Error('加载失败')
    return res.json()
  },
  {
    onError: (e) => toast.error(e.message)
  }
)
</script>

<template>
  <div v-if="loading">加载中……</div>
  <div v-else-if="error">
    <p>{{ error.message }}</p>
    <button @click="refresh">重试</button>
  </div>
  <div v-else>{{ product?.name }}</div>
</template>

进阶:轮询与自动重试

在 useRequest 的基础上,可以很容易扩展出轮询:

export function usePolling(fetcher, interval = 5000) {
  const request = useRequest(fetcher, { immediate: false })
  let timer = null
  let active = false

  async function tick() {
    if (!active) return
    await request.run()
    if (active) timer = setTimeout(tick, interval)
  }

  function start() {
    if (active) return
    active = true
    tick()
  }

  function stop() {
    active = false
    clearTimeout(timer)
    request.cancel()
  }

  onUnmounted(stop)

  return { ...request, start, stop }
}

注意 tick 里使用 setTimeout 而不是 setInterval 的原因:必须等上一次请求完成后再安排下一次。如果接口响应比轮询间隔还慢,setInterval 会导致请求堆积,而 setTimeout 天然避免这个问题。

踩坑记录:三个真实场景

坑一:卸载后 setState
请求返回时组件已卸载,仍去更新 loading.value,控制台出现警告,甚至导致内存泄漏。解决:在 onUnmounted 中标记 destroyed 并 abort。
坑二:快速切换导致结果错乱
用户快速点击不同分类,先发的慢请求后返回,覆盖了后发的快请求结果。解决:引入请求序号 seq,过期响应直接丢弃。
坑三:大对象深度响应式
接口返回上万条数据,用 ref 包装后被 reactive 深度代理,创建 Proxy 的开销极大,页面首屏卡顿数百毫秒。解决:改用 shallowRef。
正确心态
封装不是为了一次写完美,而是把这类问题收敛到一个文件里。组件层只管用,不用每次都重新想一遍竞态和清理。

九、实战:表单处理与自定义校验

表单是组合式 API 最能体现优势的领域之一。它天然包含“状态 + 派生 + 副作用”三种逻辑,正好对应 ref / computed / watch 的经典用法。

从零实现一个表单校验组合式函数

// composables/useForm.js
import { ref, reactive, computed } from 'vue'

export function useForm(initialValues, rules = {}) {
  const values = reactive({ ...initialValues })
  const errors = reactive({})
  const touched = reactive({})
  const submitting = ref(false)

  function validateField(field) {
    const rule = rules[field]
    if (!rule) return true

    const value = values[field]
    const list = Array.isArray(rule) ? rule : [rule]

    for (const r of list) {
      // 必填校验
      if (r.required && (value === '' || value == null)) {
        errors[field] = r.message || `${field} 不能为空`
        return false
      }

      // 正则校验
      if (r.pattern && value && !r.pattern.test(value)) {
        errors[field] = r.message || `${field} 格式不正确`
        return false
      }

      // 自定义校验函数(支持异步)
      if (typeof r.validator === 'function') {
        const result = r.validator(value, values)
        if (result !== true) {
          errors[field] = result || r.message
          return false
        }
      }
    }

    errors[field] = ''
    return true
  }

  function validateAll() {
    const fields = Object.keys(rules)
    const results = fields.map(validateField)
    return results.every(Boolean)
  }

  function handleBlur(field) {
    touched[field] = true
    validateField(field)
  }

  async function handleSubmit(onSubmit) {
    Object.keys(rules).forEach(f => touched[f] = true)
    if (!validateAll()) return

    submitting.value = true
    try {
      await onSubmit({ ...values })
    } finally {
      submitting.value = false
    }
  }

  function reset() {
    Object.assign(values, { ...initialValues })
    Object.keys(errors).forEach(k => errors[k] = '')
    Object.keys(touched).forEach(k => touched[k] = false)
  }

  const isValid = computed(() =>
    Object.keys(rules).every(f => !errors[f])
  )

  return {
    values, errors, touched, submitting, isValid,
    validateField, validateAll, handleBlur, handleSubmit, reset
  }
}

在组件中使用

<script setup>
import { useForm } from '@/composables/useForm'

const {
  values, errors, touched, submitting,
  handleBlur, handleSubmit, reset
} = useForm(
  { username: '', email: '', password: '', confirm: '' },
  {
    username: [
      { required: true, message: '请输入用户名' },
      { pattern: /^[a-zA-Z0-9_]{3,16}$/, message: '3-16 位字母数字下划线' }
    ],
    email: [
      { required: true, message: '请输入邮箱' },
      { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '邮箱格式不正确' }
    ],
    password: [
      { required: true, message: '请输入密码' },
      { validator: (v) => v.length >= 8 || '密码至少 8 位' }
    ],
    confirm: [
      {
        validator: (v, all) => v === all.password || '两次密码不一致'
      }
    ]
  }
)

async function onSubmit(data) {
  await api.register(data)
  alert('注册成功')
}
</script>

<template>
  <form @submit.prevent="handleSubmit(onSubmit)">
    <label>用户名</label>
    <input v-model="values.username" @blur="handleBlur('username')">
    <p v-if="touched.username && errors.username">{{ errors.username }}</p>

    <<!-- 其余字段同理 -->

    <button type="submit" :disabled="submitting">
      {{ submitting ? '提交中…' : '注册' }}
    </button>
  </form>
</template>

关于 v-model 修饰符的实战技巧

Vue 3 内置了 .lazy、.number、.trim 三个修饰符。但很多时候你需要自定义行为,比如“输入时实时显示,但只在失焦时校验”。这时可以拆开 :value 和 @input:

<input
  :value="values.email"
  @input="values.email = $event.target.value"
  @blur="handleBlur('email')"
  autocomplete="email"
  aria-describedby="email-error">
<p id="email-error" role="alert">
  {{ touched.email && errors.email }}
</p>

注意 role="alert":当错误信息出现时,屏幕阅读器会立即朗读这段文字。这是无障碍表单的关键细节——仅仅把文字变红,视障用户完全感知不到。

十、性能优化:响应式的代价与规避

响应式不是免费的。每一次 ref 创建、每一次属性访问、每一次依赖收集,都有成本。在中小型应用里这些成本可以忽略,但当数据量、组件数量、更新频率上去之后,它们会实实在在地体现为掉帧和卡顿。

shallowRef 与 shallowReactive

默认的 ref 会深度代理对象——它会递归遍历所有嵌套属性,为每一层创建 Proxy。对于接口返回的大型数据(列表、树、配置对象),这是巨大的浪费:

// ❌ 深层代理:1 万条数据会产生数万个 Proxy
const list = ref([])
list.value = await api.fetchBigList()

// ✅ 浅层响应式:只追踪 .value 的替换
const list = shallowRef([])
list.value = await api.fetchBigList()

// 需要局部更新时手动触发
import { triggerRef } from 'vue'
list.value[0].name = '新名字'
triggerRef(list)  // 强制触发更新
shallowRef 只对 .value 的替换做出响应
shallowReactive 只代理第一层属性
markRaw 永久标记对象,永不被代理
triggerRef 手动触发 shallowRef 的更新

markRaw:给不该被代理的对象“贴封条”

第三方库的实例——ECharts 实例、地图实例、Monaco Editor、three.js 的 scene——都不应该被 Vue 代理。它们内部有大量循环引用和私有属性,代理不仅浪费性能,还可能直接导致库报错:

import { markRaw } from 'vue'
import * as echarts from 'echarts'

const chart = shallowRef(null)

onMounted(() => {
  // ✅ 用 markRaw 包装,Vue 不会尝试代理它
  chart.value = markRaw(echarts.init(el.value))
})

// ❌ 如果直接 ref(echarts.init(...)),可能会遇到各种诡异问题

响应式 API 的代价对照表

API 代理深度 适用场景 性能
ref 深度 中小型对象、需要深层响应 中
reactive 深度 表单状态、配置对象 中
shallowRef 仅 .value 大列表、接口返回数据 优
shallowReactive 仅第一层 扁平配置、状态集合 优
markRaw 无 第三方实例、DOM 对象 最优
computed — 派生状态,依赖不变不重算 优
方法调用 — 无缓存,每次渲染都执行 差

v-memo:跳过整棵子树的更新

v-memo 接收一个依赖数组,只有当数组中任意一项变化时,才会重新渲染该元素及其子树。在长列表中可以显著降低更新开销:

<div v-for="item in list" :key="item.id"
     v-memo="[item.id, item.selected, item.updatedAt]">
  <ExpensiveRow :item="item" />
</div>

当列表中的某一项被选中时,只有 item.selected 变化的那一项会重新渲染,其余项完全跳过 diff。注意不要滥用——依赖数组写错会导致视图不更新,且难以排查。

v-once 与静态提升

Vue 的编译器本身已经做了大量优化,比如静态节点提升(hoistStatic)、补丁标志(patchFlag)。你可以通过 Vue SFC Playground 查看编译结果。对于绝对不变的内容,可以手动加 v-once:

<footer v-once>
  <p>© 2026 前端开发技巧</p>
</footer>

实战:一个高频更新的实时面板

假设有一个股票行情面板,每 100ms 更新一次数据,包含 200 支股票。如果用最朴素的写法,每次更新都会触发全量响应式代理和全量 diff。优化方案有三层:

第一层 用 shallowRef 承载列表,避免深层代理
第二层 用 computed 算出“显示的排序结果”,缓存排序开销
第三层 用 v-memo 让未变化的行跳过渲染
<script setup>
import { shallowRef, computed, onUnmounted } from 'vue'

const raw = shallowRef([])
const sortBy = ref('change')

// 排序结果缓存,只有 raw 或 sortBy 变化才重算
const sorted = computed(() => {
  return [...raw.value].sort((a, b) => b[sortBy.value] - a[sortBy.value])
})

let ws = null
onMounted(() => {
  ws = new WebSocket('wss://api.example.com/quotes')
  ws.onmessage = (e) => {
    // 整体替换引用,触发一次浅层更新
    raw.value = JSON.parse(e.data)
  }
})

onUnmounted(() => ws?.close())
</script>

<template>
  <div v-for="item in sorted"
       :key="item.code"
       v-memo="[item.price, item.change]">
    <QuoteRow :item="item" />
  </div>
</template>

性能排查的三个工具

  • Vue DevTools 的 Performance 面板:可以录制一段时间内的组件渲染次数,快速定位“谁在疯狂重渲染”。
  • Chrome DevTools Performance 面板:录制后查看火焰图,找到耗时最长的函数调用。
  • Vue 的 onRenderTracked / onRenderTriggered:只在开发环境使用,可以精确打印出“谁触发了这次渲染”。
// 仅在开发环境启用,快速定位过度渲染
if (import.meta.env.DEV) {
  onRenderTriggered((e) => {
    console.log('触发渲染的依赖:', e)
  })
}

十一、TypeScript 加持下的组合式 API

组合式 API 与 TypeScript 是天作之合。Options API 的 this 类型推断一直是老大难问题,而组合式 API 里的所有东西都是普通的变量和函数,类型推断自然、准确、无需额外声明。

ref 的泛型标注

import { ref } from 'vue'

const count = ref<number>(0)
const name = ref<string | null>(null)
const el = ref<HTMLInputElement | null>(null)

// 复杂类型建议抽出来
interface User {
  id: number
  name: string
  email?: string
  roles: string[]
}

const user = ref<User | null>(null)
const users = ref<User[]>([])

defineProps 的类型声明

Vue 3.3 之后,defineProps 支持用类型参数配合“外部类型导入”,不再局限于“同一个文件内定义的类型”:

<!-- UserCard.vue -->
<script setup lang="ts">
// 从外部文件导入类型(3.3+ 支持)
import type { User } from '@/types/user'

interface Props {
  user: User
  size?: 'small' | 'medium' | 'large'
  showAvatar?: boolean
  tags?: string[]
}

// withDefaults 提供默认值,同时保留类型推断
const props = withDefaults(defineProps<Props>(), {
  size: 'medium',
  showAvatar: true,
  tags: () => []
})

// props.size 的类型是 'small' | 'medium' | 'large'
</script>

defineEmits 的类型声明

<!-- 方式一:调用签名(推荐,表达力最强) -->
const emit = defineEmits<{
  (e: 'change', value: string): void
  (e: 'submit', payload: { id: number; name: string }): void
  (e: 'cancel'): void
}>()

<!-- 方式二:对象字面量 -->
const emit = defineEmits<{
  change: [value: string]
  submit: [payload: { id: number }]
}>()

// 两种方式都会对 emit 调用进行完整的类型检查
emit('change', 'hello')  // ✅
emit('change', 123)    // ❌ 类型错误

为组合式函数添加类型

组合式函数的类型定义,直接决定了调用方的开发体验。一个好的做法是把返回类型显式写出来:

// composables/useRequest.ts
import { ref, shallowRef, onUnmounted, type Ref, type ShallowRef } from 'vue'

interface UseRequestOptions<T> {
  immediate?: boolean
  initialData?: T | null
  onSuccess?: (data: T) => void | Promise<void>
  onError?: (error: Error) => void | Promise<void>
}

interface UseRequestReturn<T> {
  data: ShallowRef<T | null>
  error: ShallowRef<Error | null>
  loading: Ref<boolean>
  run: (...args: any[]) => Promise<T | undefined>
  refresh: () => Promise<T | undefined>
  cancel: () => void
}

export function useRequest<T>(
  fetcher: (signal: AbortSignal) => Promise<T>,
  options: UseRequestOptions<T> = {}
): UseRequestReturn<T> {
  // ... 实现
}

这样调用方在 const { data } = useRequest(api.getUser) 之后,data.value 会自动推断为 User | null,无需任何额外标注。

常见类型工具速查

工具类型 用途 示例
Ref<T> 标注 ref 的类型 const n: Ref<number>
ShallowRef<T> 浅层 ref 的类型 const l: ShallowRef<Item[]>
ComputedRef<T> computed 的返回类型 const c: ComputedRef<string>
InjectionKey<T> provide/inject 的类型安全 key const k: InjectionKey<Ctx>
PropType<T> 运行时 props 声明的类型 type: Array as PropType<User[]>
MaybeRef<T> 可能是 ref 也可能是裸值 function f(v: MaybeRef<number>)
unref() 解包 MaybeRef 的运行时工具 const n = unref(v)

十二、十二个高频陷阱与实战检查清单

这一节汇总了组合式 API 在实际项目中最容易踩的坑。每一条都来自真实的线上问题,值得逐条核对。

陷阱 1 解构 reactive 对象后失去响应性 —— 用 toRefs 或直接改成 ref
陷阱 2 解构 props 后失去响应性 —— 用 toRef 或直接写 props.xxx
陷阱 3 在 await 之后注册生命周期钩子 —— 回调永远不会执行
陷阱 4 在 watch 回调里修改被监听的状态 —— 造成无限循环
陷阱 5 忘记在 onUnmounted 清理定时器 / 事件 / 请求 —— 内存泄漏
陷阱 6 用 reactive 包装大型接口数据 —— 深度代理带来性能灾难
陷阱 7 把第三方库实例放进 ref —— 应该用 markRaw 或 shallowRef
陷阱 8 组合式函数中直接修改传入的参数 —— 破坏了纯函数的可测试性
陷阱 9 在模板中调用方法而不是用 computed —— 每次渲染都重新计算
陷阱 10 用 watch 做纯数据转换 —— 应该用 computed
陷阱 11 子组件直接修改 props —— props 是只读的,必须通过 emit 或 model
陷阱 12 忽略异步请求的竞态 —— 慢请求覆盖快请求的结果

需要展开说的三个陷阱

陷阱 2:解构 props 的隐藏问题

const props = defineProps({ count: Number })

// ❌ count 被解构成普通数字,父组件更新后这里不会变
let { count } = props

// ✅ 方案一:始终通过 props 访问
watch(() => props.count, ...)

// ✅ 方案二:用 toRef 保持响应性
const count = toRef(props, 'count')

// ✅ 方案三:Vue 3.5+ 的响应式 props 解构(需开启 experimental 或使用 3.5)
const { count } = defineProps({ count: Number })
// 3.5 起,编译器会自动把解构转换成 props.count 的访问

陷阱 4:watch 里的循环修改

错误写法
watch(a, () => { b.value = a.value * 2 })
watch(b, () => { a.value = b.value / 2 })
两个侦听器互相触发,页面直接卡死。
正确写法
用 computed 表达单向的派生关系,只保留一个“源”状态。真正需要双向同步时,加一个标志位或比较新旧值来打断循环。

陷阱 8:组合式函数的纯度

组合式函数应该像 React Hooks 一样,不修改传入的参数,也不依赖外部的可变状态。下面这个写法看起来没问题,但会让测试变得困难:

// ❌ 修改了传入的对象
export function useForm(values) {
  values.submitted = false  // 副作用污染了调用方的数据
  return { values }
}

// ✅ 内部拷贝,保持纯函数特性
export function useForm(initialValues) {
  const values = reactive({ ...initialValues, submitted: false })
  return { values }
}

实战检查清单

把下面这份清单贴在你的代码审查模板里,每次提交组件前扫一眼:

  • 响应式选型:状态用 ref,大对象用 shallowRef,第三方实例用 markRaw。
  • 派生状态:凡是由已有状态推导出来的值,一律用 computed,不要用方法或 watch。
  • 侦听器:能用 computed 解决的不用 watch;watch 只用于副作用。
  • 清理:所有 setTimeout / setInterval / addEventListener / fetch 都有对应的清理逻辑。
  • 竞态:异步请求有请求序号或 AbortController 保护。
  • 生命周期:所有钩子注册都在 await 之前完成。
  • props:不在子组件中直接修改 props,需要修改时通过 emit / defineModel。
  • 组合式函数:以 use 开头,返回 ref 组成的对象,不修改入参。
  • provide / inject:使用 Symbol 或 InjectionKey 作为 key,并提供明确的错误提示。
  • 类型:ref 有泛型标注,defineProps / defineEmits 使用类型声明。
  • 模板 ref:DOM 引用声明为 ref<HTMLElement | null>(null),访问前判空。
  • 性能:长列表使用 v-memo,静态内容使用 v-once。
  • 可测试性:核心逻辑抽成组合式函数,可以脱离组件单元测试。
  • 命名:组合式函数用 use 前缀,事件处理函数用 handle 前缀。
<!-- 一份可直接复用的组件结构模板 -->

<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRequest } from '@/composables/useRequest'

/* ---------- 1. props / emits ---------- */
interface Props {
  id: number
  title?: string
}
const props = withDefaults(defineProps<Props>(), {
  title: ''
})

const emit = defineEmits<{
  (e: 'loaded', data: unknown): void
}>()

/* ---------- 2. 响应式状态 ---------- */
const keyword = ref<string>('')
const containerRef = ref<HTMLElement | null>(null)

/* ---------- 3. 派生状态 ---------- */
const hasKeyword = computed(() => keyword.value.trim().length > 0)

/* ---------- 4. 数据请求 ---------- */
const { data, loading, error, refresh } = useRequest(
  () => api.getDetail(props.id),
  {
    onSuccess: (res) => emit('loaded', res)
  }
)

/* ---------- 5. 侦听器 ---------- */
watch(() => props.id, () => refresh())

/* ---------- 6. 生命周期 ---------- */
let observer: ResizeObserver | null = null

onMounted(() => {
  observer = new ResizeObserver(() => { /* ... */ })
  if (containerRef.value) observer.observe(containerRef.value)
})

onUnmounted(() => {
  observer?.disconnect()
  observer = null
})
</script>

<template>
  <div ref="containerRef">
    <input v-model="keyword">
    <div v-if="loading">加载中…</div>
    <div v-else-if="error">{{ error.message }}</div>
    <div v-else>{{ data }}</div>
  </div>
</template>

写在最后

组合式 API 的学习曲线,本质上是一条“从写法到思维”的曲线。刚开始你会纠结 ref 和 reactive 选哪个、.value 要不要写、watch 和 watchEffect 有什么区别;写了几十个组件之后,这些细节会变成肌肉记忆,你真正开始思考的是另外一些问题:

  • 这段逻辑应该放在组件里,还是抽成组合式函数?
  • 这个状态是“源”,还是可以从别的状态推导出来?
  • 这个副作用依赖什么?什么时候应该被清理?
  • 这个接口返回的大对象,真的需要深度响应式吗?

当这些问题成为你写代码时的下意识反应,组合式 API 才算真正被你掌握了。它给你的是组织复杂逻辑的能力,而不只是几个新的函数名。

最后送一句在 Vue 社区流传很广的话:“把组件当成视图,把逻辑放进组合式函数。”视图负责呈现,逻辑负责计算和副作用,两者通过清晰的返回值连接。这条边界一旦划清,你会发现代码的复杂度不再随功能数量线性增长,而是被拆解成一个个可以独立理解、独立测试、独立复用的小单元——这才是组合式 API 真正的价值所在。