Vue3 Composition API 组织方式:别把 setup 写成新的杂物间

setup 函数体里能塞进去的东西,比 Options API 时代任何一个选项字段都要多。带着组里几个人从 Options API 切到 Vue3 这半年,这一点越来越明显。Options API 时代,datacomputedmethodswatch 靠选项名字天然分区,哪怕写得乱,好歹知道去哪一块找。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 的类型推断问题——一个初始值是 nullref,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 就是新的杂物间。