"Vue3 组合式API"这短短几个字,应该是前端群里这两年被讨论最多、争议也最大的话题之一。无论是面试问"Options API 相比 Composition API 的优缺点",还是老项目改造时犹豫要不要升级到 Vue3,最后都会落到这一块。我今天不想照搬官方文档,想从一个接手上线过 Vue2 旧项目、也在新项目里从零搭过 Vue3 组合式代码结构的人的角度,把这套东西到底解决了什么问题、常用的 ref/computed/watch 怎么用才顺手、真实业务里代码怎么组织,以及从 Vue2 迁移过来最容易踩的坑,分块讲清楚。无论你是刚接触 Vue3 的新手,还是正打算改造旧项目的同学,看完应该都能直接上手。
1. 组合式API并不是多了一种写法,而是把代码组织方式改对了
1.1 Options API 的问题,从来不是"代码量多"
先回想一下在 Vue2 里写一个带搜索筛选、分页、关键词高亮的列表页,代码大概长什么样。data 里放一堆状态,methods 里放请求和事件处理,computed 里放派生数据,watch 里监听某个值变化再触发另一件事。一个"搜索列表"功能,数据在 data、初始化逻辑在 created、搜索方法在 methods、结果计算在 computed、监听在 watch。我经常用一个比喻:这就像一栋楼按楼层分房间,但你要修通一套水暖管道,得在 1 层、5 层、地下室之间来回跑。
这还不是最难受的。真正的痛点是当两个组件需要共享一段逻辑时,你只能复制粘贴,或者用 mixins。mixins 的问题大家心里都有数:组件里用了某个方法,你根本不清楚它来自哪个 mixin;两个 mixin 如果定义了同名的 data 字段或方法,后者直接静默覆盖前者;而且混入的变量在模板里出现时,追溯来源全靠记忆。项目小的时候凑合能用,一旦业务复杂起来,维护成本会指数级上升。
1.2 组合式API的核心思路:按业务切分,而不是按选项切分
组合式API做的事情其实很朴素:把"一个功能模块相关的东西"收拢到同一个区域。setup 就是那个区域,在 setup 里你可以用 ref、reactive 定义状态,用 computed 定义派生值,用 watch 定义副作用,用生命周期钩子定义挂载和卸载逻辑。然后按业务维度把这些逻辑拆成一个个名为 useXxx 的函数。
比如下面这个计数器,逻辑上它只是"一个计数状态 + 两个操作 + 一个派生值",组合式写法把它收敛成一个函数:
// hooks/useCounter.js import { ref, computed } from 'vue' export function useCounter(initial = 0) { const count = ref(initial) const double = computed(() => count.value * 2) function increment() { count.value++ } function decrement() { count.value-- } return { count, double, increment, decrement } }组件里使用时,一行代码就把状态和行为全部引进来。对比 mixins,这种做法的好处是:依赖关系通过参数显式传递,返回什么由调用处自己决定,命名冲突被收敛在 useCounter 这个作用域里,不会再出现"A mixin 覆盖了 B mixin 的方法"这种玄学 bug。逻辑还能被 TypeScript 完整地推导出来,不需要装饰器也不需要魔法。
2. 组合式API基础工具,用熟这六点就够应付绝大多数业务
2.1 ref 和 reactive:怎么选、什么时候解构、哪里容易丢响应式
先说最基础也是最容易出问题的两个 API。ref 接收一个值,返回一个响应式引用,在 script 里你要通过.value访问,但在模板里 Vue 会自动解包,可以直接写count。reactive 接收一个对象,返回深层响应式代理,使用体验上更接近 Vue2 的 data,script 里不需要.value。
我自己的选择标准很简单:标量值、或者需要整体替换的值,用 ref;嵌套结构清晰、平时只会修改某个字段而不会整体赋值的大对象,用 reactive。比如一个表单对象,我用 reactive;一个要 push 和 splice 的列表,我也用 reactive;但一个需要被重新覆盖的 token、当前页页码,就用 ref。
最容易翻车的点是解构。reactive 对象解构后,拿到的字段就是一个普通值,操作它不会触发任何更新:
import { reactive, toRefs } from 'vue' const state = reactive({ keyword: '', page: 1 }) // 错误做法:解构后 keyword 是普通字符串 // const { keyword } = state // keyword = 'vue3' // 不生效 // 正确做法:用 toRefs 保底,或者干脆用 ref const { keyword, page } = toRefs(state)另外记住两个比较隐蔽的规则:ref 被 reactive 对象包含时,访问属性会自动解包,但数组里不会解包。也就是说const arr = reactive([ref(1)])时,arr[0] 拿到的还是一个 ref 对象,不是 1。遇到这种情况尽量别混用,直接在数组里放原始值。
2.2 computed 和 watch:缓存与副作用各管各的
computed 依赖响应式状态,自动收集依赖,返回一个新值。它有两个关键特性:惰性求值和缓存。只有读取它的值,并且依赖发生变化时,它才会重新计算。我建议记住一句话:computed 里永远不要写有副作用的逻辑,比如请求、数组赋值、状态修改。它应该是一个纯函数,给什么依赖就返回什么结果。一旦你开始在里面改别的 state,代码就会变得非常难调试。
watch 则完全不同,它是在响应式状态变化后执行副作用的地方。默认是懒执行的,也就是说只有在被监听的值发生变化时才会触发。如果你想一开始就执行一次,用 immediate: true。需要监听对象内部嵌套变化时,要加 deep: true,但要意识到 deep 监听会递归遍历对象,性能开销不小,能监听到具体路径就尽量把监听的源写精确:监听一个 getter 返回具体字段,而不是整个大对象。
watch( () => state.list.length, (newLen, oldLen) => { console.log(`列表长度从 ${oldLen} 变为 ${newLen}`) } )watchEffect 则是立即执行一次,并在依赖变化时自动重新执行,适合你只关心"每次依赖变化后都同步做某件事"的场景,比如根据 currentUser 刷新权限列表。
2.3 生命周期钩子、依赖注入和模板引用
Vue3 组合式API里,生命周期钩子改成了 onBeforeMount、onMounted、onBeforeUpdate、onUpdated、onBeforeUnmount、onUnmounted,名称直观多了。最关键的映射关系是 Vue2 的 beforeDestroy -> onBeforeUnmount、destroyed -> onUnmounted。如果你在 Vue2 里习惯在 destroyed 里清理定时器、取消请求,迁移时一定要记得改。
依赖注入方面,provide 和 inject 的作用和 Vue2 基本一样,但组合式API里建议配合 readonly 使用,防止子组件无意中修改注入的响应式数据:
const globalConfig = ref({ theme: 'light' }) provide('config', readonly(globalConfig))模板引用的写法也值得一提。过去是this.$refs.xxx,现在是:
const inputRef = ref(null) onMounted(() => { inputRef.value?.focus() })模板上写ref="inputRef"。注意这个 ref 变量现在只是个 null,真正挂载完成后才会被赋值,所以要放在 onMounted 里访问。v-for 循环里的模板引用在 Vue3 里也支持,但拿到的会是一个数组。
2.4 setup 语法糖下,defineProps / defineEmits / useSlots 怎么配
现在绝大多数新项目都在用<script setup>,组件声明直接写在里面,对应的 props 和 emits 通过编译宏来定义。defineProps 和 defineEmits 不需要从 vue 里 import,它们是编译阶段的宏:
const props = defineProps({ title: { type: String, default: '默认标题' } }) const emit = defineEmits(['change', 'submit']) function handleClick() { emit('change', props.title) }如果你想要类型推导,还可以用泛型写法配合 withDefaults:
interface Props { title?: string count: number } const props = withDefaults(defineProps<Props>(), { title: '默认标题' })useSlots 和 useAttrs 这两个 API 容易被忽略但很实用。它们分别在模板中$slots和$attrs对应的位置:
import { useSlots, useAttrs } from 'vue' const slots = useSlots() const attrs = useAttrs() console.log(slots.default?.()) console.log(attrs.class)注意 slots 不是响应式的,别把它塞进 watch 或 computed 里。attrs 倒还可以,但通常也是一次性读取。
3. 三个真实场景,看组合式代码怎么自然长出来
3.1 购物车场景:数量不小于0、总价自动算、刷新不丢
这是我实际项目里封装过的一个场景,正好覆盖 ref、computed、watch 和 localStorage。业务要求很常见:购物车里每行商品有数量,点击加减时数量最小为 0;任意一行数量变化,总价要实时更新;刷新页面后购物车数据还在。
// hooks/useCart.js import { ref, computed, watch } from 'vue' export function useCart() { const items = ref([]) // 初始化时从本地存储恢复 try { const saved = localStorage.getItem('cart-items') if (saved) items.value = JSON.parse(saved) } catch (e) { console.warn('读取购物车缓存失败', e) } function updateCount(id, delta) { const item = items.value.find(i => i.id === id) if (!item) return item.count = Math.max(0, item.count + delta) } function removeItem(id) { items.value = items.value.filter(i => i.id !== id) } const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.count, 0) ) watch(items, val => { localStorage.setItem('cart-items', JSON.stringify(val)) }, { deep: true }) return { items, updateCount, removeItem, total } }这里有一个值得展开的点:total 完全没有被手动维护过,它是从 items 里派生出来的。只要 items 变了,total 自动重算。「数量不小于 0」也不是在模板里写<el-input-number :min="0">就完事了,一定要在数据层面兜底,因为接口回传的数据可能直接就是负数。Math.max(0, next) 这一行写上去,数据边界就收住了。
watch 持久化这里,deep: true 是必须的,否则修改 items 里某个 item 的 count 不会触发监听。但如果整个数组会被整体替换(比如 removeItem),deep 反而可以去掉,能省一点开销。实际项目里我会把"从缓存读"和"写入缓存"这两段逻辑再抽一个小函数,方便未来换成 localStorage 的 key 前缀或版本号。
3.2 动态增删表单行:索引即 key 的陷阱
电商后台里经常有这种需求:用户点"添加一行"就多出来一行商品规格,点删除就移除当前行,每行还有若干字段,比如商品名、数量、日期,日期还要做校验。组合式API下的写法很直接:
import { reactive } from 'vue' const form = reactive({ orders: [ { id: 1, goods: '', count: 1, date: '' } ] }) function addRow() { form.orders.push({ id: Date.now(), goods: '', count: 1, date: '' }) } function removeRow(index) { if (form.orders.length === 1) return form.orders.splice(index, 1) }配合 element-plus 的 el-form 时,最关键的其实是表单校验的 prop 要动态对应到行索引:
<el-form-item v-for="(row, index) in form.orders" :key="row.id" :label="`商品${index + 1}`" :prop="`orders.${index}.goods`" :rules="{ required: true, message: '商品不能为空', trigger: 'blur' }" > <el-input v-model="row.goods" /> </el-form-item>很多人在动态表单上踩的第一个坑就是 prop 写死了orders.goods,结果校验永远失效,因为 el-form 要靠 prop 路径找到对应字段。第二个坑是日期校验,rules 里如果写type: 'date',那么值必须是 Date 对象,而 el-date-picker 默认返回的是字符串,这就容易造成校验一直不过。要么把 type 去掉只保留 required,要么写一个自定义 validator,先判断字符串能转成有效日期。
第三个坑是删除中间的某一行后,索引错位会导致上一行的输入内容映射到下一行。所以 key 一定不要用 index,我在每行加了一个 id,用 Date.now() 生成,保证它是唯一且稳定的。
3.3 搜索条件保留:把"状态同步"收敛成一个自定义 hook
后台管理系统的列表页都有这样的诉求:填了一大堆筛选条件,点搜索,然后不小心刷新页面,条件全丢了,页码也回到第一页。组合式API可以把这部分状态同步逻辑直接封装成一个通用 hook:
// hooks/usePersistState.js import { reactive, watch } from 'vue' export function usePersistState(key, initialState) { const stored = JSON.parse(sessionStorage.getItem(key) || 'null') const state = reactive(stored || initialState) watch(state, val => { sessionStorage.setItem(key, JSON.stringify(val)) }, { deep: true }) return state }页面上这样用:
const searchForm = usePersistState('order-search-form', { keyword: '', status: '', dateRange: [] }) const pagination = usePersistState('order-pagination', { page: 1, pageSize: 20 })这个 hook 的价值不在于省几行代码,而在于它把"持久化同步"这件事的复杂度完全封装了。调用方不需要知道底层用了 sessionStorage 还是 localStorage,也不需要关心 watch 的细节。团队里如果有人提出需求"要不要改成刷新后保留到标签关闭",只需要改这一个 hook。
需要提醒的是,SSR 环境下 sessionStorage 不存在,使用前要判断 typeof window。另外不要用这个 hook 保存密码、token 等敏感信息,sessionStorage 只是会话级,跨标签页不共享,但也不是安全存储。
4. 成熟项目从 Vue2 迁移到 Vue3,别想着一步到位
4.1 组合式API可以和选项式共存,迁移思路决定成败
热词里经常有人问"成熟项目 Vue2 能转 Vue3 吗",答案是可以,但直接把整个项目一次性重写是最糟糕的策略。Vue3 的 setup 只是组件里的一块区域,它完全可以和原来的 data、methods、computed 并存。这意味着你可以保持一个组件大部分选项式写法不动,只把那些需要跨组件复用的逻辑抽成 hook,放进 setup 里接入。
我建议的迁移顺序是:先把项目里那些高频复用的 mixin 改造成 useXxx hook,因为 mixin 是组合式API要解决的核心痛点;再挑几个核心业务组件,把其中的请求逻辑、状态管理逐步挪进 setup;最后才考虑把全部组件改成<script setup>。每一步都能独立上线验证,风险可控。如果团队里有人不适应,选项式API在 Vue3 里依然能用,兼容性不错。
4.2 必须注意的 API 变化清单
| 场景 | Vue2 写法 | Vue3 写法 |
|---|---|---|
| 父子双向绑定 | v-model+:value+@input | 单个v-model,多个用v-model:xxx |
| 属性修饰符 | xxx.sync | v-model:xxx |
| 原生事件绑定 | @click.native | 普通@click,组件内用 emits 声明 |
| 事件总线 | this.$on/this.$off | 推荐 mitt、provide/inject 或 pinia |
| 过滤器 | filters: { format } | 改 computed 或方法 |
| 全局属性 | Vue.prototype.$http | app.config.globalProperties.$http |
| 异步组件 | component: () => import() | defineAsyncComponent |
| 实例方法 | this.$children | 用 ref 或 provide/inject |
这里面最容易踩的是事件总线。Vue2 里$on/$emit用起来很顺手,但在 Vue3 里这些 API 被移除了。很多人用惯了总线,迁移时突然发现兄弟组件通信没地方写,就直接上了全局响应式变量,结果状态逻辑又变成意大利面。建议要么改用 pinia 做跨组件状态,要么用一个轻量的 mitt 事件总线。
4.3 TypeScript 和组合式API搭配的类型坑
热词里出现"若依 vue3 ts 报错",这几乎是所有从 Vue2 + JS 直接跳到 Vue3 + TS 的团队都会遇到的。常见的几个坑分别是:defineProps 宏在编辑器里不被识别、ref 赋给 reactive 里嵌套字段时类型推导异常、provide/inject 类型不匹配。
第一个问题的解法是确认项目里安装了 vue-tsc 且编辑器使用 Volar 插件,不要再用 Vetur。第二个问题意思是说,ref 本身是 Ref ,放进 reactive 对象里会触发 unwrap,类型推导可能变成 T,但你在 script 里仍然要按 ref 访问,这时类型就报错了。这种情况最好的办法就是不要混用,一个对象里要么全是普通值走 reactive,要么用 ref 单独管理需要替换的字段。
provide/inject 建议总是用 InjectionKey 来约束类型:
import type { InjectionKey, Ref } from 'vue' export const configKey: InjectionKey<Ref<UserConfig>> = Symbol('config') // 提供方 provide(configKey, configRef) // 注入方 const config = inject(configKey) // 类型自动推导为 Ref<UserConfig> | undefined注意 inject 返回可能是 undefined,要继续用的话要么兜底,要么显式抛错。
5. 高频问题排查:从监听不到到拖拽失效
5.1 on-success / onError 这类回调为什么监听不到
常见于图片上传、表单提交这类组件。很多时候同仁会把组件文档里的on-success当作事件,用@on-success="handleSuccess"去绑,结果永远不触发。原因在于 on-success 是 Upload 组件的 prop,不是事件,正确写法是:on-success="handleSuccess"。组件内部可能额外 emit 了 success 之类的事件,但那是另一回事。
排查顺序是这样的:先看文档里这个字段是 prop 还是 event;再看你是否在子组件里声明了 emits;最后确认回调参数顺序。像 el-upload 的 on-success 接收三个参数 response、uploadFile、uploadFiles,你只写一个参数的话拿到的是 response 而不是你想的文件列表。还有一点,在组合式API里回调直接传箭头函数或具名函数都行,不要依赖 this,this 在 setup 里本来就拿不到组件实例。
5.2 拖拽排序不生效:大多数情况是装错库
项目里要实现列表拖拽排序,很多人会自然想到 sortablejs 或 vue.draggable。如果你在网上抄了一个老代码,安装的是vuedraggable的最新稳定版,但在 Vue3 项目里 import 之后发现组件不工作或在控制台报sortable is not defined,大概率是装到了 Vue2 版本。Vue3 对应的包要装vuedraggable@next,或者直接用@vueuse/integrations里的 useSortable。还有一个隐蔽的坑:排序后原始数组没有同步更新。拖拽只是改了 DOM 顺序,你要在 change 回调里重新对源数组排序,如果列表 key 不稳定,排序就很容易乱套。建议每一项都带一个持久 id 作为 key。
5.3 Vite dev 局域网打开空白
热词里有"vue3 vite dev 局域网打开空白",这个在新手上手时高频出现。现象是本地 localhost:5173 能打开,但手机或公司局域网内另一台电脑用http://192.168.x.x:5173访问时直接白屏。原因通常是 Vite 默认监听localhost,不监听局域网地址。解决方法是改 vite.config.ts:
export default defineConfig({ server: { host: '0.0.0.0', port: 5173 } })另一个隐蔽原因是页面里加载了用 localhost 写的接口地址,比如 API baseURL 是http://localhost:8080,在局域网设备访问时,请求会打到那台设备自己身上,当然就报错或白屏。这种情况要把接口地址改成局域网 IP 或者用环境变量区分。如果生产环境是 history 路由模式,直接访问某个子路由返回 404 也是白屏,需要 nginx 配一下 try_files 回退到 index.html。
5.4 登录后不跳转的排查顺序
"vue3 登录不跳转"是很典型的坑。先看 router.push 之后 URL 有没有变。如果 URL 变了但页面空白,检查路由组件有没有正确注册,是不是异步路由还没 addRoute 完就执行了 push。如果 URL 都没变,八成是全局前置守卫把跳转拦了。这时候要看用户信息到底存哪了:刷新后 pinia 会重置,如果 token 只放在 store 里没做持久化,刷新后被守卫再次弹回登录页,那登录"成功"只是瞬间的。排查顺序建议是:打印 router.push 的返回值,看有没有 catch 到错误;在全局守卫里 console.log 到哪一步被拦截;最后验证 store 里的用户状态是否在刷新后依然存在。
5.5 第三方库与响应式代理的边界
热词里有"基于 vue3 + three.js + typescript 机房",也有"vue3 + onlyoffice 在线编辑"。这些项目里最常见的组合式API误区,是把第三方库的实例对象直接塞进 reactive。比如:
const threeScene = reactive(new THREE.Scene())这是非常糟糕的做法。three.js 的 scene 内部有大量需要保持原生引用的对象,被 Proxy 代理后性能会明显下降,甚至直接出现渲染异常。正确思路是:实例对象用普通变量或者 shallowRef 管理,放进组件非响应式字段里,需要触发重新渲染时再手动改一个普通 ref 状态。onlyoffice 的在线编辑同样如此,文档编辑器实例一旦被 deep reactive 代理,编辑器内部状态会乱。这些库的实例本质上不需要响应式,它们跟 Vue 的渲染周期应该通过明确的回调事件进行通信。
5.6 一些容易被误判为 Vue3 问题的平台现象
热词里还有一条"vue3 项目在 edge 浏览器中有时候无法关闭浏览器右上角的最小化按钮"。这类问题和组合式API基本没有关系,大概率是 iframe 嵌套、全屏 API、弹窗覆盖层或浏览器自身 bug 导致的点击区域被遮挡。排查时先看是否只发生在特定页面、特定弹窗打开后,而不是一上来就怀疑框架。类似的还有 tabs 标签页样式、pdf 预览、百度离线地图这些集成类问题,都是具体生态工具的接入问题,不要在组合式API上钻牛角尖。把问题定位到"哪一层引入的",比盲目改代码要高效得多。
最后再说点实际的
组合式API给我最大的感受,不是代码量变少,而是"代码的分组方式"终于跟人的思维方式对齐了。一个功能涉及的状态、计算属性、副作用、清理逻辑放在同一个 hook 里,三个月后回头看,解释成本低很多。我现在的习惯是:每个组件里如果出现"这段逻辑我想在其他页面复用"的念头,就直接抽成 useXxx;如果只是想给当前组件减负,也用 hook 包一层,但绝不为了抽象而抽象。老项目迁移也一样,别想着一次重写,先挑一个跨页面复用的逻辑抽成 hook,跑通一两个模块再逐步推。看得见的收益,比任何"最佳实践"的口号都更能推动团队往前走。