
这几年只要聊到 Vue 项目的代码组织“组合式 APIComposition API”基本上就是绕不开的话题。不管是从 2.x 升级到 3.x 的老项目还是新项目一开始就打算走工程化路线的团队几乎都把组合式 API 当成了默认选项。但我也见过很多团队从选项式 API 切过来之后代码只是形式上换了口味本质上还是把一堆setTimeout、watch、ref堆在script setup里看起来比之前更自由了真要维护起来反而更容易失控。这篇文章想聊的是我在实际项目中反复验证过的组合式 API 最佳实践怎么理解它背后的设计初衷怎么拆分可复用的组合式函数怎么处理响应式、生命周期、依赖注入这些细节以及在一个几万行甚至几十万行的大型代码库里怎么配合 AI 辅助工具比如 Claude Code 这类编码工具把工程化质量做实。适用的人群是有 Vue 基础、想在团队里建立统一开发规范的开发者或者是正准备重构代码、希望从“能跑”走向“好维护”的技术负责人。1. 组合式 API 是怎么解决“代码组织”这个老问题的1.1 选项式方案的天花板为什么大功能一多就乱先把时间拨回 Vue 2 时代。一个普通的业务组件表格页尤其典型data里面挂十来个字段computed里六七个派生状态methods里十几个处理函数watch里还得盯着几个异步依赖。单看每一个选项都很清晰但当你需要了解“一个搜索功能到底牵扯到哪些状态、哪些计算、哪些方法”的时候就得在这四个选项之间来回跳。这种碎片化问题在组件小的时候不明显一旦组件超过 300 行维护成本就开始指数级上升。我记得有一次排查一个表单联动 bug现象是“切换供应商之后价格输入框没有重置”。我翻了data里的supplierId、price看了methods里的handleSupplierChange又去watch里找有没有对supplierId的特殊监听最后发现还漏掉了computed里一个带缓存的displayPrice。光定位问题就花了快两个小时改起来只需要两行。这种痛苦经历应该很多人都体验过。选项式 API 本身没有错它的问题在于它把代码组织维度固定成了“数据类型”而不是“业务逻辑”。真实业务里的一个功能往往是横跨数据、计算、方法、监听多个选项的。你按数据、方法这样的维度切等于把一个完整的故事撕成了好几块再分别塞进不同的抽屉里。1.2 组合式 API 的核心理念按逻辑维度组织代码组合式 API 给出的答案是“按逻辑关注点组织代码”。同一个业务逻辑相关的状态、计算属性、函数、侦听器物理上放在同一个位置。这样你在理解“搜索逻辑”的时候不需要在组件里四处跳转只需要看这一段连续代码就好。我举个最朴素的对比。一个搜索功能选项式写法大概是export default { data() { return { keyword: , list: [], loading: false } }, computed: { filteredList() { /* ... */ } }, methods: { async fetchList() { /* ... */ }, handleSearch() { /* ... */ } }, watch: { keyword() { /* 防抖搜索 */ } } }换成组合式写核心逻辑可以抽成一个useSearch函数// useSearch.ts export function useSearch() { const keyword ref() const list ref([]) const loading ref(false) const filteredList computed(() { /* ... */ }) async function fetchList() { /* ... */ } function handleSearch() { /* ... */ } watch(keyword, () { /* 防抖搜索 */ }) return { keyword, list, loading, filteredList, fetchList, handleSearch } }组件那边只需要const { keyword, list, loading, filteredList, handleSearch } useSearch()组件变薄了可读性反而上去了。更重要的是这套逻辑可以在不同的组件之间复用。选项式时代我们只能靠 mixins但 mixins 有几个致命问题命名冲突、隐式依赖、来源不清晰。组合式函数是普通函数输入输出都是显式的复用时逻辑完全透明这是本质差别。1.3 工程化视角下的选型建议从工程化角度看组合式 API 带来的不仅是组织代码的新姿势还有几个非常实际的好处。第一它天然适合 TypeScript。组合式函数的入参、返回值的类型可以非常精确地推断。选项式里this的类型推导一直绕来绕去组合式函数就是普通函数refnumber、computedboolean编辑器里的提示干净利落。第二它让代码更容易被测试。组合式函数不依赖组件实例你可以直接写const { keyword, filteredList, handleSearch } useSearch() await handleSearch() expect(filteredList.value.length).toBeGreaterThan(0)不需要再去 mount 一个完整组件、等nextTick、触发事件单测成本直线下降。第三它让团队协作的分工更清晰。新人接手一个模块先读组合式函数的文件名基本就能知道这块组件在干嘛。我们团队现在的目录结构里composables文件夹基本就是一本“业务模块地图”。不过也要泼一盆冷水组合式 API 只是给了你更好的组织能力并不代表代码会自动变好。如果抽象能力不够照样能写出一个几百行的useXXX函数内部塞满各种不相关逻辑。所以后面几节的“最佳实践”才更有价值。2. 响应式基础与组合式 API 的实操细节2.1 ref 与 reactive 怎么选、为什么组合式 API 的实操第一个绕不开的问题就是ref还是reactive。我见过不少团队内部为此争来争去其实核心原则没那么复杂你的值是原始类型string、number、boolean就用 ref你的值是纯对象且内部结构稳定追求少写.value就用 reactive。但推荐默认 ref。为什么默认 ref我自己踩过几个深坑之后总结出来一是 ref 在 TypeScript 里的类型体验更顺畅。reactive的返回值跟源对象是“同一份”的类型上需要UnwrapNestedRefs之类的辅助ref 则简单直接RefT一目了然。二是解构场景。组合式函数本质是函数返回值需要被调用方解构出来使用。reactive对象一旦解构响应性就断掉了因为解构出来的是普通值。ref 反而安全就算解构出来它还是RefT赋给模板时照样自动解包。这一点直接决定了组合式函数在返回共享状态时的可靠性。三是嵌套和替换。用reactive包裹一个对象想整体替换某个嵌套字段时容易写出一堆Object.assignref 配合ref.value newObj则非常自然。我现在的选择策略大致是这样的场景推荐方案原因表单数据、复杂对象、需要整块替换ref包一个对象字面量替换赋值简单类型清晰多个字段需要频繁单独更新reactive少写.value模板里也更清爽作为组合式函数返回给外部统一返回ref或readonly(ref)避免调用方意外破坏响应式链路在 JS 逻辑内部频繁读写的临时状态reactive顺手代码可读性更好还有个小技巧对象数据在reactive和ref之间互相转换也很容易。ref(obj)之后修改ref.value.xxx底层走的还是同一个响应式代理把reactive值传给ref也一样。所以初期拿不准的时候先选一个写下去后面再顺手调整成本并不高。2.2 计算属性与侦听器的时机边界组合式 API 里computed和watch的使用频率非常高但很多人分不清它们的边界。我的经验是一句话能用 computed 表达的不要用 watch能用 watch 触发的不要用 computed 强行联系。computed的核心价值是“派生状态”。只要目标值可以完全由其他响应式状态推导出来就应该用计算属性。比如const price ref(0) const quantity ref(1) const total computed(() price.value * quantity.value)这里做 watch 反而傻——你得先设置total状态再去监听price、quantity的变化手动更新total。两步操作还容易漏而且中间态还可能不一致。计算属性自动追踪依赖任何变化都会同步刷新这是真正的声明式编程。那watch什么时候用我的判断标准是需要在副作用里执行操作时。典型的场景包括一个状态变化后需要请求接口比如搜索防抖状态变化后要 sync 到localStorage或写到document.title状态变化后要调用非响应式 API比如图表实例的update()需要拿到变化前后的值做 diff还有一个我自己养成的好习惯watch尽量用getter语法别直接watch(reactiveObj, ...)。如果你监听的是reactive对象里的某个字段直接写watch(() form.name, ...)比watch(form.name, ...)语义清晰得多能有效避免“监听整个对象导致依赖混乱”的问题。关于flush选项多说一嘴。默认watch的回调是在组件更新之前执行的pre如果你需要在 DOM 更新之后再操作要么await nextTick()要么直接watch(source, cb, { flush: post })。flush: sync能拿到最新值但会牺牲批处理性能一般不建议用。2.3 用 provide/inject 组织跨层级逻辑组合式 API 的provide/inject和选项式最大的区别是它不再依赖this可以跟组合式函数无缝结合。尤其在跨层级共享逻辑的时候我非常推荐用它来避免“props 逐层转发”的尴尬。典型场景是页面级的配置对象。比如一个数据报表页面顶层组件统一管理筛选条件下面两三层的展示组件都需要读取筛选值但不需要知道怎么改筛选。传统做法是逐层传 props中间层组件需要声明一堆自己根本用不到的 props纯粹当搬运工。provide方案简洁得多// 顶层组件 const filter ref({ dateRange: [], departmentId: }) provide(reportFilter, filter)// 底层任意组件 const filter injectRefReportFilter(reportFilter)用的时候有几个要点注入键用Symbol或者一个统一导出的InjectionKey常量别用裸字符串。团队里很容易拼错字符串而且编辑器对Symbol有更好的类型提示。推荐这么定义// injectionKeys.ts import type { InjectionKey, Ref } from vue export const ReportFilterKey: InjectionKeyRefReportFilter Symbol(report-filter)inject最好设置默认值。组件一旦被挪到没有provide的上下文里没有默认值会直接炸。要克制。provide是隐式依赖滥用之后组件的独立性会下降。只对“这个组件本来就不可能脱离父级单独使用”的场景开放比如表格组件里的行组件、表单组件里的字段组件。2.4 组合式函数中的生命周期管理组合式函数本质是普通函数函数内是可以调用 Vue 生命周期钩子的。这给代码复用打开了新空间——你可以在一个useXXX里“注册”需要清理的资源。我自己处理事件监听的写法是这样的export function useWindowSize() { const width ref(window.innerWidth) const height ref(window.innerHeight) function update() { width.value window.innerWidth height.value window.innerHeight } onMounted(() window.addEventListener(resize, update)) onUnmounted(() window.removeEventListener(resize, update)) return { width, height } }这种把“开启”和“关闭”放在同一个组合式函数里的写法是组合式 API 最优雅的表达它能自包含、自清理。调用方不需要关心内部细节在哪个组件里用生命周期就自动跟着那个组件走。但有个坑要特别说明组合式函数的生命周期钩子必须在同步执行阶段调用。也就是说你不能在setTimeout里、也不能在await之后调用onMounted。因为 Vue 需要在当前实例的setup()同步上下文里收集钩子。我见过有人写async function setupSomething() { const data await fetchData() onMounted(() { /* 这样是错的 */ }) }这会导致警告而且钩子不生效。解决办法是调整结构把异步的数据获取交给watch或onMounted内部的异步逻辑钩子本身放在同步位置。还有一个容易忽略的点同一个组合式函数在同一个组件里被调用两次注册的钩子也会执行两次。比如useMouse()如果用两次就会绑定两个监听器。这个问题可以通过给组合式函数加一个模块级的单例状态解决也可以接受“双份逻辑”的代价。具体取决于你的业务如果只是获取鼠标位置两份监听没问题如果是绑定全局事件并且对性能敏感就得小心了。3. 在真实业务中拆组合式函数的完整示例3.1 目录结构与命名规范组合式 API 最佳实践不是代码风格层面的事工程化落地通常从目录规划开始。给一套我验证过挺好用的结构src/ composables/ useSearch.ts usePagination.ts useTableActions.ts useFormRules.ts useAuth.ts useDebounce.ts modules/ users/ components/ UserFormModal.vue UserTable.vue useUserList.ts useUserForm.ts api.ts dashboard/ useDashboardStats.ts原则是通用可复用的放composables跟具体业务模块绑定的放modules/{模块名}/下面。这样新人看目录就能分清“工具逻辑”和“业务逻辑”。命名上我强烈建议统一加use前缀。这是社区约定俗成的规则非常有用搜文件时输入use能快速把所有组合式函数都筛出来。返回的对象尽量是“对象”而不是数组因为数组返回要求调用方严格按位置解构一旦将来增加一个返回值所有调用处都要改对象则天然兼容这种情况。3.2 从业务场景出发设计 composable 接口组合式函数的设计最忌讳的是从 API 出发比如看到一个接口就写一个useXXXApi。我见过最糟糕的项目每个接口对应一个 composable里面就是一句fetch加几个ref组件里照旧是一堆逻辑。更好的拆法是从业务场景出发。拿一个用户管理列表页来说如果从接口出发你会写出useUserApi、useRoleApi、useDepartmentApi……最后组件里还是什么都耦合在一起。如果从场景出发我会这样设计// useUserList.ts import { ref, computed, watch } from vue import { usePagination } from /composables/usePagination import { useSearch } from /composables/useSearch export function useUserList() { const keyword ref() const departmentId ref() const { page, pageSize, total, setPage } usePagination() const { run, loading, result } useRequest(fetchUserList) const list computed(() result.value?.list ?? []) async function load() { await run({ page: page.value, pageSize: pageSize.value, keyword: keyword.value, departmentId: departmentId.value }) } watch([keyword, departmentId, page], load) return { keyword, departmentId, list, loading, page, pageSize, total, load } }看到没这个组合式函数承载的是一个完整的“列表页业务场景”而不是单纯某一接口。它把关键词、部门筛选、分页、请求、列表渲染整合成一套自洽逻辑。组件里只需要const { keyword, list, loading, page, pageSize, total, load } useUserList()还是那个原则组件负责模板和事件绑定业务状态与规则全在组合式函数里。3.3 组合式函数之间的协作与物料复用组合式函数最美妙的地方在于它和普通函数一样可以嵌套组合。useUserList内部使用usePagination和useRequest上层函数只是把底层函数的能力组织起来。这就像搭积木一个积木能搭出无数种业务场景。为了让协作顺畅我给几个可复用的“基础积木”固定了接口形状。分页模块要稳定export function usePagination(initialPage 1, initialSize 20) { const page ref(initialPage) const pageSize ref(initialSize) const total ref(0) function setPage(p: number) { page.value p } function setPageSize(s: number) { pageSize.value s page.value 1 // 切换每页条数后回到第一页 } return { page, pageSize, total, setPage, setPageSize } }请求模块要支持竞态处理。我强烈建议在组合式函数里加一个简单的请求序号防止“老请求覆盖新请求”export function useRequest(requestFn: (...args: any[]) Promiseany) { const loading ref(false) const error refError | null(null) const result refany(null) let seq 0 async function run(...args: any[]) { const currentSeq seq loading.value true error.value null try { const data await requestFn(...args) if (currentSeq seq) { result.value data } } catch (e) { if (currentSeq seq) { error.value e as Error } } finally { if (currentSeq seq) { loading.value false } } } return { run, loading, error, result } }这个竞态保护代码在真实项目里太重要了。用户快速切换搜索条件时如果旧请求比新请求晚返回页面会展示一份错误的数据。加这个seq序号只有最新请求的结果会被写入老请求直接丢弃。这是我在生产环境踩过坑之后补上的。3.4 给团队提供一致的“组合式品控”清单抽象能力是需要积累的团队新人未必一开始就能拆出好用的组合式函数。所以我整理了一份“组合式品控清单”每次 code review 时对照检查文件名和职责要对齐。usePagination就是分页别顺手在里面塞个鉴权逻辑。返回的响应式值全部用ref或readonly(ref)避免调用方解构后丢失响应性。不要在组合式函数里写大段 DOM 操作逻辑。那是组件的职责组合式函数的职责在当前是状态和规则。异步请求统一走useRequest包装不要每个函数手写一套loading/error/result三件套否则样式和行为会越飘越远。必须有 TypeScript 类型。入参、返回值、InjectionKey都要写清楚。每个组合式函数尽量在 100 行以内。超过就拆拆不动说明边界没找好。对外暴露的只读引用用readonly包裹。比如return { count: readonly(count), increment }这样外部组件只能调用increment修改状态彻底堵住了“组件拿到count后直接count.value”这种越权操作。这份清单不需要一次性强制执行可以先挑两三条重点推动等团队形成习惯后再逐步补全。4. 大型代码库中的 AI 辅助工程化实践4.1 为什么 AI 工具适合组合式 API 代码库最近“Claude Code 在大型代码库中的最佳实践”这类话题很热我的体验确实也不错。大型代码库典型的问题就是文件多、引用关系复杂而组合式 API 代码库又有自己的特点逻辑被切散到很多小型组合式函数里依赖关系隐藏在函数调用中。这时候 AI 辅助工具反而特别好用因为它的语义理解能力和上下文检索能力刚好匹配这类代码的组织方式。我拿 Claude Code 类比它本质上是一个能读整个代码库源码的结对程序员。你不用把手指划到第 14 个文件才能理解useUserList的来龙去脉你可以直接问它“这个组合式函数的所有调用点都在哪些组件里有没有组件在解构后直接修改了返回状态”它能把答案整理好甚至给出修改建议。组合式 API 代码库天然更适合 AI 辅助还有一个原因单元边界清晰、命名统一。AI 理解和推理以“函数”为颗粒度比理解以“组件”为颗粒度要容易得多。4.2 用自然语言驱动代码库探索和组合式函数提取我最近在大型代码库中经常这样用 Claude Code让它找出所有包含相同“搜索-请求-渲染”模式的文件然后基于这个模式生成统一的useSearchList组合式函数。让它把一个 400 行的旧组件按逻辑拆分哪些状态属于表单、哪些状态属于列表、哪些是纯展示计算函数分别提出成useUserForm和useStatTable。让它分析一个组合式函数的版本历史影响范围改动这个方法会影响哪些页面。一个很实用的 prompt 模板是请阅读 src/modules/users 目录下的所有文件找出重复出现的表单校验规则和请求逻辑 将它们提取为一个 useUserForm.ts 组合式函数要求 1. 保留现有组件对外行为不变 2. 返回类型用 TypeScript 明确导出 3. 在提取重构前先画一个依赖关系清单列出每个状态和函数的被引位置这一步非常关键先让 AI 列出影响清单再让它动手改代码。如果直接让它“重构”它可能改得很激进牵一发动全身。我在实践中总会要求它“先分析再动手”而且分析结论会先人工过一遍再放行。4.3 产检让 AI 做 review 和边界 case 寻找器代码 review 也是 AI 辅助的黄金场景。组合式函数很容易踩几个隐蔽的坑响应式丢失、生命周期未清理、watch 的深层监听副作用、依赖循环定时器泄漏。人眼很难集中精神抓这些细节但 AI 可以较稳定地发现。我给 Claude Code 的 review 指令一般是这样请 review src/composables 下的代码重点检查 - ref 是否被解构后失去了响应式链接 - 事件监听或 setInterval 是否在 onUnmounted 里清理 - provide/inject 是否遗漏默认值 - 是否有可以直接用 computed 替代的 watch 逻辑 - 异步请求是否存在竞态覆盖问题它的输出往往能帮我补上几处低级纰漏。不过要注意AI 给出的建议虽然聪明却未必匹配当前场景的业务限制。比如它的“用 computed 替代 watch”建议合理但它不了解这个watch的 callback 里有localStorage副作用。所以我的底线是AI 的输出永远是候选方案最终执行与验证的还是人。4.4 团队落地 AI 辅助时需要守住的底线AI 辅助不是“全自动”。我在团队中总结了几条底线算是工程化最佳实践里的软约束AI 建议的代码必须有人 reviewreview 标准跟人工代码一样。重构类任务要有测试兜底。组合式函数是纯函数逻辑测试成本很低重构完跑一遍单测比什么 review 都可靠。禁止让 AI 决定架构方向。提取不提取某个组合式函数、跨模块依赖怎么设计这些是架构决策得由熟悉业务的开发者拍板。AI 可以做候选分析不能做最终决策。把 AI 的使用沉淀成团队的规范说明文档。比如“什么类型的重构可以交给 AI”、“prompt 里必须包含哪些检查项目”所有人都遵守同一套流程。Claude Code 这类工具的价值不是取代工程师而是把我们从“在几千个文件里人肉回溯引用关系”的低级劳动里解放出来把精力放在真正的微架构设计和代码评审上。5. 常见问题与排查速查5.1 问题列举与根因分析我在各种项目里遇到过的组合式 API 问题基本都能归入下面这 5 类。每类都给出根因分析和解决方案。问题一解构后响应性丢失根源用reactive创建对象然后从组合式函数返回调用方解构赋值时解出来的是普通值不再有响应式代理。排查如果页面里某个字段更新后视图不变先检查这个字段是不是从reactive对象上解构出来的。解决统一用ref返回状态ref解构后依然保有响应链接或者限制只返回整体reactive对象组件内不要解构它直接state.xxx使用。问题二watch 深层监听导致性能崩溃根源watch(reactiveObject, cb)默认是深度监听对象一旦非常大每次子属性变化都会触发回调。排查如果页面操作明显卡顿先打开 devtools 的性能面板看一下 watch 回调触发频率。解决明确监听目标用watch(() obj.specificField, cb)避免整对象监听。问题三请求返回顺序错乱根源多个异步请求并发时老请求后返回覆盖新数据。前面useRequest里的seq就是干这个的。排查在result被修改的地方打印日志或者直接看数据是不是“闪回”到旧值。解决给请求函数加竞态保护或者用AbortController取消旧请求。问题四组合式函数里用setInterval不清理根源忘记了onUnmounted或者把setInterval写在异步回调里。排查切换页面路由后用 devtools 的记时器和事件监听列表看看有没有残留。解决setInterval统一绑在onMounted/onUnmounted对里复杂一点的可以用一个onCleanup帮助函数把清理逻辑注册成队列export function useInterval(callback: () void, delay 1000) { let timer: ReturnTypetypeof setInterval | null null onMounted(() { timer setInterval(callback, delay) }) onUnmounted(() { if (timer) clearInterval(timer) }) }问题五把“能复用的”硬拆拆出反模式组合式函数根源为了用组合式 API 而用明明是组件内部私有逻辑硬拆成useCardThing这种文件名带具体组件名的函数。排查如果一个组合式函数只有一个调用点并且函数内部超过 80% 的代码只服务于单个组件那大概率属于过度抽取。解决先让代码在组件内自然生长等出现第二个真实需要复用的场景再抽取组合式函数。过早抽象是工程化里最费钱的行为之一。5.2 正确使用 Vue Devtools 检查响应式链路排查组合式 API 问题Vue Devtools 是好助手。打开组件树点击对应组件在“Setup”面板里你能看到这个组件所有组合式函数的响应式状态。重点看两个信息哪些状态是Ref哪些是Reactive对象。某个computed的依赖列表里有没有出现预期之外的字段。这个依赖列表特别有用。比如某个computed计算的是totalPrice结果你在依赖列表里看到searchKeyword说明computed里不小心引用了不相关状态计算一变这个computed也会重算白白消耗性能。5.3 几个值得长期坚持的好习惯最后分享几个我从实战里沉淀下来的习惯谈不上惊天动地但真的能帮你少走弯路组合式函数里不要直接写console.log调试。用debugger也行但更好的方式是返回一个包装好的日志函数或者依赖 DevTools。不然代码上了生产一堆调试日志很难收场。给组合式函数写 JSDoc 注释的时候重点写“业务约束”而不是“代码功能”。比如“这个函数只能在用户登录状态下调用”这类业务前提代码本身看不出来但新人必须知道。每次从组合式函数中返回新的响应式数据都想想调用方真的需要修改它吗如果不需要就用readonly包一下。这个习惯帮我挡住了非常多莫名其妙的隐性耦合。组合式函数的入参如果需要依赖响应式数据尽量设计成传ref而不是传value// 不推荐 useFormatPrice(price.value) // 推荐 useFormatPrice(price)因为传value的话函数内部看到的是“某个瞬间的值”后续变化它感知不到传ref进去函数内部可以用computed追踪变化。设计组合式函数 API 时这一点非常影响复用能力。最后一条关于 AI 辅助给 Claude Code 提供上下文时不要只贴一段代码再问问题。把相关的文件、类型定义、基础设施限制一起贴给它比如“系统约定所有接口统一返回ResDataT请基于这个约束设计”。上下文越完整AI 给出的方案离可用就越近。在我实际的项目经验里组合式 API 真正上了轨道之后团队写代码的节奏是组件模板拿来即用业务逻辑在 composables 里像搭积木一样生长新功能基本是组合、调整、验证三个动作而不是堆代码。做到这一步靠的不是某个炫酷工具而是对响应式原理的理解、对业务边界的把握、以及一套能落地的工程化规范。希望这篇文章的内容能帮你把自己的 Vue 项目也打磨到这样的状态。