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 写出来,代码会像这样分布:
结果是:改一个功能,要在文件里上下横跳五次。当你想要把“收藏”逻辑复用到另一个页面时,会发现它和搜索、分页的代码缠在一起,根本拆不出来。这就是 Options API 最核心的痛点——代码按“选项类型”切分,而不是按“业务逻辑”切分。
Composition API 的答案:按逻辑组织
组合式 API 的核心主张只有一句话:把同一个功能的状态、计算、方法、副作用写在一起。同一个商品列表组件,用组合式 API 可以重写成三块:
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 对照
点击下面的按钮,对比同一段逻辑在两种范式下的写法差异。
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。原因有三:
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 会自动解包,所以写 {{ count }} 而不是 {{ count.value }}。但在 <script> 里必须写 .value。这个差异是新手最常见的心智负担,习惯之后反而会觉得清晰——因为 .value 的存在明确提醒你“这是一个响应式引用”。
computed:缓存才是它的灵魂
很多人把 computed 当成“写在模板里的函数”的替代品,这是误解。它最大的价值是基于依赖的缓存:只要依赖没变,重复读取不会重新执行。
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 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 存在的意义。
三、生命周期钩子:组合式 API 的时序地图
组合式 API 保留了完整的生命周期能力,但调用方式从“选项”变成了“注册函数”。这些函数只能在 setup 的同步执行阶段调用——因为它们依赖当前正在初始化的组件实例。点击下面每个阶段,看看它对应什么时机、适合做什么。
完整的生命周期对照表
| 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 同步数据:
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(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(() => {
// 用到谁就自动监听谁
localStorage.setItem('kw', keyword.value)
localStorage.setItem('page', page.value)
})
// 拿到停止函数,手动终止侦听
const stop = watchEffect(() => { ... })
stop() // 停止监听
// 清理副作用:上一次执行的回调先跑
watchEffect((onCleanup) => {
const timer = setTimeout(() => { ... }, 300)
onCleanup(() => clearTimeout(timer))
})
选择建议
flush 选项:控制执行时机
这是 watch 里最容易被忽略、却对性能影响最大的参数:
flush: 'pre'(默认):组件更新前执行。此时 DOM 还是旧的,但可以访问到最新的状态。flush: 'post':组件更新后执行。适合需要读取更新后 DOM 的场景,等同于把逻辑放进onUpdated但更精确。flush: 'sync':同步执行。数据一变立刻触发,性能开销大,除非万不得已不要用。
watch(messages, () => {
listEl.value.scrollTop = listEl.value.scrollHeight
}, { flush: 'post' }) // 必须等 DOM 更新完才能拿到正确的 scrollHeight
实战:带清理的防抖搜索
把前面的知识组合起来,写一个健壮的搜索侦听器:
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 好得多——没有隐式的属性合并、没有命名冲突、来源清晰可追溯。
约定:组合式函数的三条规则
实战一:useMouse —— 最简单的组合式函数
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 —— 带持久化的响应式状态
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:
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 { 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 }
}
在使用它的组件里,只需要三行:
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 抽到一个单独的文件里:
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 的细节。更好的做法是封装成一对组合式函数:
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
// 运行时声明
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,三处样板代码。现在只需要一行:
<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 属性绑定:
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 适合“父组件需要命令式控制子组件”的场景,比如打开弹窗、触发校验、重置表单。
实战:命令式弹窗组件
<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。
先明确需求
完整实现
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
}
}
使用:一个商品详情页
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 的基础上,可以很容易扩展出轮询:
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 天然避免这个问题。
踩坑记录:三个真实场景
loading.value,控制台出现警告,甚至导致内存泄漏。解决:在 onUnmounted 中标记 destroyed 并 abort。
seq,过期响应直接丢弃。
ref 包装后被 reactive 深度代理,创建 Proxy 的开销极大,页面首屏卡顿数百毫秒。解决:改用 shallowRef。
九、实战:表单处理与自定义校验
表单是组合式 API 最能体现优势的领域之一。它天然包含“状态 + 派生 + 副作用”三种逻辑,正好对应 ref / computed / watch 的经典用法。
从零实现一个表单校验组合式函数
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
}
}
在组件中使用
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:
: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。对于接口返回的大型数据(列表、树、配置对象),这是巨大的浪费:
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) // 强制触发更新
markRaw:给不该被代理的对象“贴封条”
第三方库的实例——ECharts 实例、地图实例、Monaco Editor、three.js 的 scene——都不应该被 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 接收一个依赖数组,只有当数组中任意一项变化时,才会重新渲染该元素及其子树。在长列表中可以显著降低更新开销:
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:
<p>© 2026 前端开发技巧</p>
</footer>
实战:一个高频更新的实时面板
假设有一个股票行情面板,每 100ms 更新一次数据,包含 200 支股票。如果用最朴素的写法,每次更新都会触发全量响应式代理和全量 diff。优化方案有三层:
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 的泛型标注
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 支持用类型参数配合“外部类型导入”,不再局限于“同一个文件内定义的类型”:
<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) // ❌ 类型错误
为组合式函数添加类型
组合式函数的类型定义,直接决定了调用方的开发体验。一个好的做法是把返回类型显式写出来:
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 在实际项目中最容易踩的坑。每一条都来自真实的线上问题,值得逐条核对。
toRefs 或直接改成 ref
toRef 或直接写 props.xxx
await 之后注册生命周期钩子 —— 回调永远不会执行
onUnmounted 清理定时器 / 事件 / 请求 —— 内存泄漏
reactive 包装大型接口数据 —— 深度代理带来性能灾难
ref —— 应该用 markRaw 或 shallowRef
需要展开说的三个陷阱
陷阱 2:解构 props 的隐藏问题
// ❌ 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 真正的价值所在。