从 Options API 迁移到组合式 API 之后我才真正理解了 Vue 3 为什么要做这么大的改动。很多人第一次看到 setup 函数会觉得无从下手或者觉得这不过是把 data、methods、computed 换个地方写而已。今天我想围绕“组合式 API 最佳实践”这个主题把我在多个真实项目中总结出来的编排思路、逻辑复用原则、响应式细节和 TypeScript 协作方式一次性讲清楚。这套内容适合正在从 Vue 2 转 Vue 3 的朋友也适合已经上手组合式 API、但总感觉代码写着写着就变成一锅粥的开发者。1. 为什么我们最终选择了组合式 APIOptions API 的痛点与组织方式的改变很多团队刚开始抗拒组合式 API理由很一致“Vue 2 写得好好的为什么非要换”但只要你实际维护过一个几百行的业务组件就会明白 Options API 在逻辑组织上的天花板有多低。1.1 Vue 2 时代的逻辑复用困局Vue 2 的组件代码是按照“选项类型”强制切分的数据放在 data方法放在 methods侦听放在 watch计算属性放在 computed。这种切分在组件很小的时候没有问题但一个真实的业务组件往往同时包含多个关注点比如“搜索结果列表”和“筛选条件联动”和“分页加载”其实是三组独立的逻辑。在 Options API 里这三组逻辑的数据会全部堆在 data 中方法会全部堆在 methods 中。你看着代码根本说不清某个响应式字段到底服务于哪块业务。更重要的是当你想把“分页加载”复用到另一个组件时Options API 几乎没有任何体面的手段。复制粘贴最直接但后续维护就是噩梦。用 mixin 可以做复用可是 mixin 存在三个硬伤来源不透明、命名冲突不可控、依赖关系隐式化。你在组件里用了 this.pageNum但组件本身没有声明这个字段时连 Vue 的模板编译都不会提醒你等到运行时才发现属性 undefined排查成本极高。1.2 Composition API 的本质不是“没有 this”而是“组织自由”组合式 API 给我最大的冲击不是语法上少了个 this而是代码的组织维度从“选项类型”切成了“业务关注点”。这个转变在官方文档里没有被足够强调但它才是组合式 API 真正值钱的地方。举个最简单例子一个倒计时按钮在 Options API 里你可能要把 countdown 放进 data把 start 放进 methods把清理定时器的逻辑塞进 beforeDestroy。而组合式 API 下我通常会写一个叫 useCountdown 的组合式函数把倒计时的所有状态、计算逻辑、生命周期清理全部收拢在一起。组件里只写一行 const { current, isRunning, start } useCountdown(60)业务意图一目了然。所以我想强调的第一个观点是不要为了“写法新”而用组合式 API要用它来解决实际的组织混乱问题。你的 setup 里如果只是把 data 和 methods 平铺在一起那等于在用新工具干旧活代码质量不会有任何提升。2. setup 的编排设计把代码放对位置的三个核心习惯setup 函数是一个自由度过高的环境没有强制切线、没有选项块所有代码都可以往里塞。这种自由对新手很不友好因为自由意味着需要更强的自控力。我在代码评审里见过最典型的反模式就是 setup 里从上到下连续写几百行状态、计算、监听、方法全部混在一起变量命名只有 a、b、c。2.1 状态声明ref 与 reactive 的选型逻辑组合式 API 的响应式数据有两种声明方式ref 和 reactive。很多教程会告诉你说“改对象用 reactive、改基础类型用 ref”但我在实际项目里越来越倾向于统一用 ref这个选择背后有很具体的原因。reactive 最大的问题是它的解构陷阱。当你把一个响应式对象的属性解构给一个局部变量时局部变量会失去响应式绑定因为 reactive 返回的是对象本身解构出来的属性对应的其实是原始值。只有通过 toRefs 才能把每个属性转换成独立的 ref但团队里并不是每个人都能时刻记住这条规则。ref 不一样ref 在模板里会自动解包在 JavaScript 里通过 .value 访问它的心智模型简单直接。而且 ref 也可以持有对象const state ref({ count: 0 })需要整体替换时直接用 state.value newObj这比 reactive 的替换语义更明确。我给出的实用建议是默认用 ref只有在明确需要深层嵌套对象且不打算解构时才考虑 reactive。2.2 计算属性与监听器把派生逻辑放在该放的位置computed 和 watch 是组合式 API 里最容易写乱的部分。我见过不少项目把所有的 computed 全部堆在 setup 顶部把所有的 watch 堆在底部完全抹掉了它们和具体业务的关系。正确的做法是让每个业务关注点在 setup 内部拥有一个“小区域”状态、计算、监听都收拢在这一块。比如做一个搜索组件关键词状态、防抖后的关键词、以及触发搜索的 watch这三者应该放在相邻的位置中间不要穿插其他模块的代码。这其实就是借鉴了组合式函数的思想即使你还没有把代码抽成独立函数也应该在 setup 里用空行和注释做分区编排。我自己写 setup 的时候还会用简单的/ --- 搜索相关逻辑 --- /注释做视觉分隔对后来者特别友好。watch 的使用还有一个容易被忽略的细节回调函数的清理。Vue 3 的 watch 回调可以返回一个清理函数每次响应式源变化触发下一步时Vue 会先调用上一次回调返回的清理函数。这个机制很适合处理接口竞态问题连续输入关键词时上一次的请求还没有返回下一次触发就先把上一次标记为过期返回后不再更新数据。watch(keyword, async (val) { let canceled false const res await fetchList(val) if (!canceled) { list.value res } return () { canceled true } })2.3 生命周期与作用域释放的进阶技巧组合式 API 里最容易被低估的 API 是 onScopeDispose。如果你在组合式函数内部注册了监听器、定时器、或者事件总线绑定退出函数所在组件的生命周期时必须要清理掉。Vue 3 提供了用 onScopeDispose 统一处理能力的机制。它可以在组合式函数内部调用当组件卸载时自动执行清理逻辑。这意味着你封装的 useXxx 函数可以做到“自带清理”组件的 onUnmounted 里什么都不用写。这个设计模式直接解决了 mixin 时代无法解决的隐式依赖问题。3. 组合式函数设计的六个原则怎么抽才能用得爽组合式函数也就是 composables是组合式 API 真正意义上的“复用单元”。我在团队里定的规矩是任何一段超过三行的、可以被命名复用的业务逻辑都必须考虑抽成 useXxx 函数。但怎么抽很多人是拍脑袋决定结果抽出来的函数要么参数复杂得看不下去要么根本没法在其他组件里复用。下面这六条原则是我在多次重构和评审中沉淀下来的。3.1 一个函数承载一个完整的业务关注点不要按“数据”和“方法”的维度去切函数要按“能力”切。useTableMaintenance 会比 useTableData 加 useMaintenanceForm 加 usePagination 更好理解因为它对应的是一组高度关联的状态和操作。你写代码的时候要想的是“如果另一个组件也需要这个能力它需要的最碎边界是什么”一个函数最好只回答一个问题。3.2 入参接受 ref 而不是原始值这是组合式函数设计中最关键的一条如果组合式函数需要外部传入一个状态尽量接收 ref 或 getter 函数而不是在函数内部复制一份原始值。比如实现一个防抖搜索列表你希望传进来的是 keyword 这个 ref这样函数内部 watch 的响应式源才能和外部数据源保持同一条引用链。如果传的是 keyword.value你拿到的只是当前快照后续外部状态变化函数内部完全感知不到。这一点非常反直觉很多从 Vue 2 转过来的开发者都会踩坑。3.3 返回值设计成可解构的结构对外返回时尽量把所有响应式状态和方法摊平成普通对象让调用方可以用解构拿走自己需要的部分。如果返回的是响应式对象本身并整体赋值给组件里的变量在使用时就需要多写两层引用体验会打折扣。我在设计 useCountdown 时返回的 current 是一个 ref方法 start、stop 是函数不会因为响应式包装而阻塞解构。3.4 内部状态不直接对外暴露通过行为操作状态组合式函数的内部状态可以被读取但不要允许外部直接篡改内部维护的 ref。更好的做法是通过函数暴露的方法去修改状态。还是拿倒计时举例外部不应该直接改 current.value而应该由 start() 或 stop() 内部来处理 current 和 interval 的联动。这种约束避免了很多“不知道哪个组件改脏了全局状态”的排查地狱。3.5 命名 useXxx让函数成为能力的宣言约定以 use 前缀命名的原因不只是规则要求更重要的是useXxx 让调用方的代码变得像宣言。组件里写 const list useMaintenanceList()读代码的人会立刻意识到这里有“获取维保列表”的能力。这种语义上的清晰度本质上比普通抽函数高一个层级。3.6 组合式函数内的副作用必须自带释放机制我要求团队里每个非纯函数的组合式函数都要在函数内部注册必要的清理动作。定时器、事件监听、全局状态订阅都算副作用。统一用 onScopeDispose 处理外部使用者不需要也不应该关心清理细节。下面是一个完整的倒计时组合式函数示例集中展示了以上原则import { ref, computed, onScopeDispose } from vue export function useCountdown(initialSeconds 60) { const remaining ref(initialSeconds) const isCountingDown ref(false) const displayTime computed(() { const minutes Math.floor(remaining.value / 60) const seconds remaining.value % 60 return ${String(minutes).padStart(2, 0)}:${String(seconds).padStart(2, 0)} }) let timer null const stop () { if (timer) { clearInterval(timer) timer null } isCountingDown.value false } const start (duration initialSeconds) { stop() remaining.value duration isCountingDown.value true timer setInterval(() { if (remaining.value 1) { stop() } else { remaining.value - 1 } }, 1000) } const reset () { stop() remaining.value initialSeconds } onScopeDispose(stop) return { remaining: remaining, isCountingDown: isCountingDown, displayTime: displayTime, start: start, stop: stop, reset: reset } }组件里使用这段代码的时候只需要三行const { displayTime, isCountingDown, start } useCountdown(60) // 点击发送验证码时调用 start() // 模板中直接显示 displayTime 即可组件的 onUnmounted 里什么都不用做因为 useCountdown 内部已经通过 onScopeDispose 兜底清理了定时器。这就是组合式函数“自包含”的典型范例。4. 响应式细节props 解构、watch 与 watchEffect 的选择、defineModel 的进阶用法组合式 API 的响应式边界有很多暗坑这些细节决定了代码是总出 bug还是能和 Vue 配合得严丝合缝。4.1 props 解构失效问题与 toRefs/toRef 的兜底Vue 3 的 props 本身是响应式对象但如果你在 setup 里直接解构 props解构出来的属性就会失去响应式。这是我认为组合式 API 里最容易被踩的坑之一。纠错的标准做法是使用 toRefs 或 toRef。toRefs 可以把 props 的每一个属性都转换为独立的 ref这样你在模板和逻辑中都能安全解构。请注意一个适用场景的差异toRefs 只会转换已经存在的属性如果是可选 props建议用 toRef 单独指定属性名避免 undefined 的情况。在使用 TypeScript 时这种转换带来的类型推导也能保持完整。在父组件传值更新子组件时子组件里拿到的最新值就会因为这个转换而保持同步。我遇到过不止一次编辑弹窗里子组件从父组件接收一个 row 对象直接解构 row 后发现第一次打开有值、第二次打开却还是旧值根本原因就是对同一引用对象进行修改而没有产生新的引用导致响应式链路断了。4.2 watch 与 watchEffect 的选型判断标准watchEffect 和 watch 在组合式 API 里经常被混淆。简单区分方式是watchEffect 会自动追踪其内部用到的所有响应式数据数据变化时立即重新执行watch 则需要你显式指定要监听的数据源而且可以拿到变化前后的值。我的选型标准是三条需要访问新旧值时只能用 watch。需要明确监听哪个响应式源、其他数据变化不关心时用 watch。需要立即执行一个依赖多个响应式源的逻辑时用 watchEffect 更简洁。4.3 v-model 与 defineModel 自定义组件双向绑定的新写法Vue 3.4 引入的 defineModel 宏把自定义组件的 v-model 实现大幅简化。在 Vue 3 早期版本中一个自定义输入组件要支持 v-model需要手动声明 props 里的 modelValue并在事件里 emit(update:modelValue, newValue)。现在只需要在子组件里写script setup langts const modelValue defineModelstring({ default: }) /script模板里直接使用 modelValue父组件 v-model 就能顺畅工作。defineModel 同时支持多个 v-model 绑定为表单场景带来了很好的体验。面试题里如果问到“defineModel 的原理”其实就是对 props 和 emit 的语法糖封装但这个封装让组件 API 的声明收敛了很多。5. 与 TypeScript 协同让组合式 API 的类型推导真正发挥作用组合式 API 相比 Options API 在 TypeScript 下的优势是压倒性的。Options API 里 this 的隐式类型有时会很绕而组合式函数直接通过参数和返回值推导类型清晰直接。5.1 泛型组合式函数让请求函数获得完整类型信息最实用的场景是设计一个 useRequest 函数通过泛型把请求返回的数据类型传递出来让使用者获得完整提示。import { ref, type Ref } from vue type RequestResultT { data: RefT | null loading: Refboolean error: RefError | null execute: () Promisevoid } export function useRequestT(fetcher: () PromiseT): RequestResultT { const data refT | null(null) const loading ref(false) const error refError | null(null) const execute async () { loading.value true error.value null try { data.value await fetcher() } catch (e) { error.value e as Error } finally { loading.value false } } return { data, loading, error, execute } }调用时传入一个返回类型明确的函数const { data, loading, execute } useRequestMaintenanceItem[](() fetchMaintenanceList())data 会被自动推导成 RefMaintenanceItem[] | null模板里的自动提示也随之生效。 TypeScript 的意义不只是类型安全更是让阅读代码的人从函数签名里就能找到数据的形状。5.2 script setup 中的宏声明Vue 3.3 之后 defineProps 和 defineEmits 都支持泛型和类型参数。我的建议是使用类型声明方式而不是运行时声明这样不仅类型更精确IDE 提示也更友好。const props defineProps{ id: string mode?: create | edit }()如果需要设置默认值配合 withDefaults 使用const props withDefaults(defineProps{ status: draft | published }(), { status: draft })这些写法在实操中能减少大量的类型守卫和运行时校验。6. 我在项目中踩过的坑与实战修正最后这部分没有系统性的方法论是我在真实项目中走过的弯路和修出来的结论希望对你有帮助。6.1 把 setup 写成上千行“大杂烩”的教训早期我在团队推广组合式 API 时有一个同事把整个页面组件的所有逻辑全部平铺在 setup 里大约一千四百行。这个组件的状态之间没有任何隔离不同业务模块的变量插在彼此之间代码评审根本无法开展。后来我们一起重构把所有逻辑拆成了若干个组合式函数。为了逼自己拆干净我们约定setup 里能看到的业务操作只能是通过函数名表达的能力组件自身只保留模块编排逻辑和必要的局部状态。拆完之后那个组件的 setup 降到不到一百行而每个 useXxx 函数都对应一个可以独立测试的文件。这次重构对我的启示是组合式 API 的自由度要求写代码的人有更强的拆分意识否则它会比 Options API 更快地变成不可维护的沼泽。6.2 组合式 API 配合中大型项目的分层习惯如果项目规模中等建议再加一层 store。Pinia 可以作为跨组件的共享状态层而组件内和业务紧密相关的短周期状态则用组合式函数管理。划界线的标准很简单这个状态是否只服务于当前组件是就进组合式函数需要被多个组件共享就提升到 store。用 storeToRefs 取出 Pinia 里的状态之后再作为参数传给组合式函数依然能保持响应式链路完整。6.3 组合式 API 面试高频考点提炼如果你正处于面试准备期围绕组合式 API 的高频考点其实高度集中我把值得说清楚的点整理一遍ref 与 reactive 的本质区别和应用场景setup 的执行时机与生命周期钩子的对应关系watch 与 watchEffect 的差异和选型依据为什么组合式函数能替代 mixin 解决复用问题defineModel 的语法糖原理组合式函数内部如何完成副作用清理。把这些点用项目中的真实代码讲清楚比背任何模板都有效。我实际用下来组合式 API 最有价值的地方是它让 Vue 组件的代码可以像读文章一样按“段落”阅读。每个组合式函数就是文章里的一个小节读的人可以根据自己的需要跳到对应段落而不是从头扫到尾去拼凑某个逻辑的碎片。如果你们团队正在做 Vue 2 向 Vue 3 的迁移我建议不要只是机械地把 data 搬进 setup而是借这个机会把组件里所有逻辑重新整理一遍用组合式函数的思维拆干净你会看到 Vue 3 真正想要的长远形态。