Vue3 Composition API 组织方式:别把 setup 写成新的杂物间
setup 函数体里能塞进去的东西,比 Options API 时代任何一个选项字段都要多。带着组里几个人从 Options API 切到 Vue3 这半年,这一点越来越明显。Options API 时代,data、computed、methods、watch 靠选项名字天然分区,哪怕写得乱,好歹知道去哪一块找。setup 里没有这层强制约束,状态、请求、事件、监听、表单、弹窗、权限全都能塞进同一个函数体,写起来顺手,改起来就得从上到下翻半天。同一份组件逻辑,按业务能力组织和按声明顺序堆砌,读起来的成本能差出一个数量级。
组里从去年下半年开始把新建的中后台页面陆续换成 Vue3,年前这一批基本都过了一遍 code review。看下来问题不是大家不会用 Composition API,而是把它当成了"把 Options API 的选项拍平",该分的层次一个没留。这篇整理一下这段时间摸出来的几条组织习惯,也顺带记一下踩过的几个坑。
先按功能块组织,而不是按 API 类型组织
最常见的写法是这样:先把所有 ref 声明堆在最上面,接着一片 computed,再一片方法,最后是生命周期钩子。这其实是把 Options API 的分区习惯原样照搬了过来——只是从"按类型分区"变成了"按类型分段",业务逻辑该散的还是散。
一个列表页组件,搜索表单、分页、详情弹窗、删除确认、字典请求全放在一个 setup 里,跑起来没问题,两个月后要改分页逻辑,得先确认 page 这个变量在文件里出现的十几处哪几处跟分页有关。
更实际的组织方式是按业务能力靠近写:
1// 搜索相关的状态、派生值、方法放在一起 2const keyword = ref('') 3const page = ref(1) 4 5const query = computed(() => ({ 6 keyword: keyword.value.trim(), 7 page: page.value, 8})) 9 10function resetSearch() { 11 keyword.value = '' 12 page.value = 1 13} 14 15// 详情弹窗相关的状态和方法另起一段 16const detailVisible = ref(false) 17const currentRow = ref(null) 18 19function openDetail(row) { 20 currentRow.value = row 21 detailVisible.value = true 22} 23 24function closeDetail() { 25 detailVisible.value = false 26 currentRow.value = null 27}
这样读代码时能顺着一个业务点一路看完,不用在文件里来回跳。如果一个组件里能力比较多,用注释分区能救急,但注释只是标记,真正该做的是把每一块抽成函数,甚至抽成 composable。这次 code review 里理出来一条判断标准很实用:setup 里的空行应该标记业务边界,不该只是排版习惯留下的间隔——看到一处空行,应该能对应到"上一个业务点结束、下一个业务点开始",而不是纯粹为了视觉上不挤在一起。
composable 不是简单搬代码
抽 composable 的门槛比想象中高一点——不是把一段代码从组件里复制出去、包一层 function 就完事。VueUse 这类社区 composable 库这半年在国内前端圈子里讨论度挺高,我抽空翻过它的源码,发现它的每个 composable 输入输出都很干净,几乎不依赖调用方的隐性上下文。这一点值得学。
一个还算过关的 composable:
1export function usePagination(options = {}) { 2 const page = ref(1) 3 const pageSize = ref(options.pageSize ?? 20) 4 5 function resetPage() { 6 page.value = 1 7 } 8 9 return { 10 page, 11 pageSize, 12 resetPage, 13 } 14}
组件里使用:
1const { page, pageSize, resetPage } = usePagination({ pageSize: 10 })
它的输入是一个配置对象,输出是明确的几个响应式变量和方法,不偷偷读写组件内的其他状态。这种 composable 抽出去之后能单独测试,也能在别的组件里直接复用。
反过来,我见过一种"伪 composable":函数内部通过 getCurrentInstance() 或者直接读外部闭包变量去改组件状态,参数表面上很短,实际耦合一点没减,只是把复杂度从组件文件挪到了另一个文件里,读的时候还得来回切文件确认它到底改了什么。这种抽取除了让文件变多,没解决实际问题。
判断是否值得抽 composable,我现在主要看两点:
这段逻辑能不能独立命名,命名之后别人不看实现也能猜到它做什么。 命名困难往往说明这段逻辑本身职责就不单一。
是否会被复用,或者抽出去能明显降低组件的复杂度。 只被一个组件用、且和这个组件的 UI 强绑定的逻辑,留在组件里反而更清楚,硬抽出去只是多了一层间接。
ref 和 reactive 要克制选择
ref 适合基本值,也适合语义上是"单个独立状态"的值:
1const loading = ref(false) 2const keyword = ref('')
reactive 适合一组天然属于同一个对象的状态,比如一份表单:
1const form = reactive({ 2 name: '', 3 phone: '', 4})
有一种写法图省事,把整个组件的状态一股脑塞进一个 reactive:
1const state = reactive({ 2 loading: false, 3 keyword: '', 4 list: [], 5 dialogOpen: false, 6 currentRow: null, 7})
这样确实少写很多 .value,但本质上是把 Vue2 里那个巨大的 data 对象原样搬了过来,Composition API 按业务拆分逻辑的优势直接被抵消了。我在评审里遇到过这种写法,问作者为什么把弹窗状态和列表状态放进同一个对象,得到的回答通常是"反正都在同一个组件里"——这恰好是问题所在:状态该不该放一起,看的是它们是否属于同一个业务概念,不是看它们是否在同一个文件里。
还有一个绕不开的坑:解构 reactive 对象会丢响应式。
1const { name } = form 2// name 是普通变量,form.name 变了它不会跟着变
如果确实要解构使用,得用 toRefs:
1const { name, phone } = toRefs(form)
但我现在不会为了模板里少写几个字段前缀就到处套 toRefs。响应式的边界每绕一层,读代码的人就要多想一层"这个变量到底还联不联着原对象",绕太多反而增加认知负担。
watch 不要滥用
watch 能力很强,副作用也很容易变得隐蔽。举个例子:
1watch(keyword, () => { 2 page.value = 1 3 fetchList() 4})
这段代码能跑,问题在于半年后别人改 keyword 的赋值逻辑时,未必知道改一下这个值会顺带触发一次请求和分页重置。这种"看起来只是改了个值,其实牵动了一整条链路"的写法,排查起来很费时间——组里有同事就在这类代码上卡了一个多小时,最后靠全局搜索 watch(keyword 才找到触发点在哪。
我现在更倾向把用户的主动操作写成显式函数:
1function handleSearch() { 2 page.value = 1 3 fetchList() 4}
按钮绑定 @click="handleSearch",读代码的人一眼就能看出点击搜索按钮会发生什么,不需要再去反查有没有 watch 挂在 keyword 上。
watch 更适合处理"状态变化自然带来的副作用",比如监听路由参数变化去重新拉数据、同步外部输入源、响应窗口尺寸变化这类场景——这些场景里没有一个明确的"用户动作"作为入口,状态变化本身就是触发点,用 watch 是合理的。业务按钮点击、表单提交这类由用户主动触发的动作,写成显式函数更清楚,也更容易加日志、加埋点。
请求函数本身的错误处理也不该依赖 watch 外面的 try/catch,得在函数内部兜住:
1async function fetchList() { 2 loading.value = true 3 4 try { 5 list.value = await api.getList(query.value) 6 } catch (error) { 7 message.error(error.message || '列表加载失败') 8 } finally { 9 loading.value = false 10 } 11}
否则一旦请求在 watch 回调里触发失败,用户界面上什么反应都没有,loading 也可能卡住不复位——这个坑我们线上出过一次,页面看着像卡死,其实是请求失败后 loading.value = false 那行代码根本没被执行到。
生命周期里的副作用要考虑清理
Composition API 写副作用时,创建和清理要成对出现:
1onMounted(() => { 2 window.addEventListener('resize', handleResize) 3}) 4 5onBeforeUnmount(() => { 6 window.removeEventListener('resize', handleResize) 7})
如果这段逻辑抽成了 composable,清理逻辑得跟着一起进去,不能指望调用方记得清理:
1export function useWindowSize() { 2 const width = ref(window.innerWidth) 3 4 function update() { 5 width.value = window.innerWidth 6 } 7 8 onMounted(() => window.addEventListener('resize', update)) 9 onBeforeUnmount(() => window.removeEventListener('resize', update)) 10 11 return { width } 12}
谁创建的副作用,谁负责清理,这条原则不因为逻辑被抽到组件外面而失效。年前排查过一个内存增长的问题,起因就是一个抽出去的 composable 里加了事件监听,但对应组件被频繁挂载/卸载(一个 tab 页反复切换),监听器越攒越多,最后页面越用越卡。修法很简单,把 onBeforeUnmount 里漏掉的一行补上就好,但这种坑不写测试基本发现不了,只能靠 code review 时刻意去核对"这个 onMounted 有没有对应的清理"。
<script setup> 值不值得现在就上
Vue3 单文件组件里的 <script setup> 语法糖已经不是新闻了,去年下半年的版本里就能用,写法比手动 return 一堆变量要省事不少:
1<script setup> 2import { ref, computed } from 'vue' 3 4const keyword = ref('') 5const page = ref(1) 6 7const query = computed(() => ({ 8 keyword: keyword.value.trim(), 9 page: page.value, 10})) 11</script>
不用再手写 export default { setup() { ... return {...} } },模板里也能直接用顶层变量,省掉了一整层样板代码。组里对要不要现在就全面切过去还有分歧——一位同事的顾虑是团队里还有几个人对这个语法糖不熟悉,排查问题时看到 <script setup> 会下意识去找 setup() 函数体,找不到会愣一下;我自己的看法是新组件可以直接用,存量组件不用赶着迁移,两种写法混着过渡一段时间没什么大问题,模板引用组件的方式是一致的。真要说不方便的地方,是 <script setup> 里默认不暴露任何东西给父组件,需要通过 ref 拿到子组件实例调用方法时,得显式用 defineExpose 才行,这一点跟 Options API 里默认全部挂在实例上的习惯不一样,是切换过程中最容易漏掉的细节。
状态管理:Vuex 还是往后放一放
组件内部的状态组织理清楚之后,接下来自然会碰到"跨组件共享状态该怎么放"的问题。眼下项目里用的还是 Vuex 4,写法和 Vue2 时代差别不大,只是要在 setup 里通过 useStore() 拿实例。社区里这半年常有人提起一个叫 Pinia 的方案,说是比 Vuex 更贴合 Composition API 的写法,不需要 mutation 这层,直接在 store 里写方法改状态。我看过它的文档,写法确实更顺手,但目前还只是我们内部讨论时提过几句,没有真正拿到项目里跑过,现有的 Vuex store 也没到必须搬家的地步——多一层没验证过的依赖,对已经稳定运行的项目来说不是划算的交易,等它的生态和文档再成熟一些,或者有新项目想从零试一次,再认真评估也不迟。
TypeScript 搭配 Composition API 的几个细节
组里的 Vue3 项目基本都配了 TypeScript,去年底更新到 4.5 之后,模板字面量类型的推断比之前准了不少,配合 ref<T>() 这种手动标类型的写法,能少踩几个类型报错。实际写 Composition API 时最容易踩的还是 ref 的类型推断问题——一个初始值是 null 的 ref,TypeScript 会把它推断成 Ref<null>,后面赋值成对象就会报类型错误:
1const currentRow = ref(null) 2// TypeScript 推断成 Ref<null>,下面这行会报错 3currentRow.value = { id: 1, name: '张三' }
得手动标注类型:
1interface Row { 2 id: number 3 name: string 4} 5 6const currentRow = ref<Row | null>(null)
这条坑我们组几乎每个刚接触 Vue3 + TS 的人都踩过一遍,索性写进了内部的入门文档里。
我的组织习惯
写 Vue3 组件时,我现在大致按这个顺序走:
简单组件直接在 setup 里按功能块组织,块与块之间空一行,靠空行分出各自的业务范围;逻辑一旦超过一屏,先按业务能力分组,而不是急着抽函数;能独立命名、命名后不看实现也能猜到用途的逻辑,才考虑抽 composable;请求函数统一处理 loading 和错误,不依赖外层 watch 兜底;watch 只用来处理状态变化自带的副作用,用户主动触发的动作一律写成显式函数;所有事件监听、定时器、订阅,创建和清理必须成对出现,抽到 composable 里也不例外。
Composition API 给了更自由的组织方式,但自由从来不等于随便写。代码该围绕业务能力聚合,而不是围绕 API 的名字堆放——这条道理其实和 Options API 时代没什么本质区别,只是 Vue3 把约束交还给了写代码的人自己,用得好是真的顺手,用不好,setup 就是新的杂物间。