Vue 3 组合式 API 最佳实践:如何写出可维护的代码
核心原则:组合式 API 不是"把选项拆开",而是"按关注点聚合"
很多团队从 Options API 迁移到 Composition API 后,只是把 data、methods、computed 拆成了零散的 ref 和 function,代码反而更乱了。
问题不在 API 本身,在于组织方式。
一、第一条铁律:按功能聚合,而非按选项类型聚合
❌ 反面教材:Options 思维的平移
javascript
js
// 按类型拆分------这是 Options API 的习惯,不是 Composition API 的用法
const name = ref('')
const age = ref(0)
const getFullName = computed(() => `${name.value} ${age.value}`)
const updateName = () => { /* ... */ }
const updateAge = () => { /* ... */ }
✅ 正确做法:按功能模块聚合
javascript
js
// useUser.js ------ 一个功能,一个文件
import { ref, computed } from 'vue'
export function useUser() {
const name = ref('')
const age = ref(0)
const fullName = computed(() => `${name.value} ${age.value}`)
function updateName(v) { name.value = v }
function updateAge(v) { age.value = v }
return { name, age, fullName, updateName, updateAge }
}
判断标准:如果你删除这个功能,应该只删一个文件,而不是从五个文件里各删一行。
二、Composables 的命名与边界
| 规则 | 说明 |
|---|---|
必须以 use 开头 |
useUser、useFetch、useTheme,这是社区约定,也是 Vue 生态的识别标志 |
| 只负责逻辑,不负责 UI | Composable 返回数据和方法,不返回 JSX 或模板 |
| 可组合、可复用 | 如果逻辑只在一个组件里用,直接写在 <script setup> 里就好,别硬拆 |
| 接受参数,不读全局 | 依赖通过参数传入,不要在 composable 内部读 inject 以外的全局状态 |
三、<script setup> 的黄金结构
xml
vue
<script setup lang="ts">
// 1. 引入 ------ 外部依赖
import { ref, computed, onMounted } from 'vue'
import { useUser } from '@/composables/useUser'
import { useFetch } from '@/composables/useFetch'
// 2. 逻辑层 ------ 所有 composable 调用
const { name, age, fullName } = useUser()
const { data, loading, error } = useFetch('/api/users')
// 3. 组件专属状态 ------ 只属于当前组件的东西
const showModal = ref(false)
// 4. 计算属性
const isAdult = computed(() => age.value >= 18)
// 5. 生命周期与副作用
onMounted(() => {
console.log('mounted')
})
// 6. 对外暴露 ------ 给父组件用的
defineExpose({ showModal })
</script>
顺序不是装饰,是阅读路径:先看依赖什么,再看逻辑做什么,最后看组件自己干什么。
四、响应式的精准控制
4.1 ref vs reactive:别纠结,记住这个
| 场景 | 选它 | 原因 |
|---|---|---|
| 原始值(string/number/boolean) | ref |
reactive 无法包裹原始值 |
| 明确知道是对象且不会整体替换 | reactive |
不用 .value,写起来干净 |
| 需要整体替换(如表单重置) | ref |
ref.value = {} 一步到位 |
| 团队统一 | ref |
一致性 > 便利性,ref 适用所有场景 |
推荐:全团队统一用 ref,少一个决策点。
4.2 toRef 和 toRefs:解构不丢失响应式
php
js
// ❌ 丢失响应式
const state = reactive({ count: 0, name: 'vue' })
const { count } = state // count 只是个普通数字
// ✅ 保持响应式
const { count } = toRefs(state) // count 是 ref
// 或
const count = toRef(state, 'count')
五、Props 与 Emits 的类型安全
用 TypeScript 时,永远不要手写 props 定义:
typescript
ts
// ❌ 手动写,容易和实际不一致
const props = defineProps<{ title: string }>()
// ✅ 用 withDefaults + 泛型,类型推断自动对齐
const props = withDefaults(
defineProps<{
title: string
count?: number
}>(),
{ count: 0 }
)
// ✅ Emits 也一样
const emit = defineEmits<{
(e: 'update', value: number): void
(e: 'delete', id: string): void
}>()
六、副作用的隔离:watchEffect vs watch
| API | 何时用 | 特点 |
|---|---|---|
watchEffect |
副作用依赖多个响应式值,且不关心旧值 | 自动追踪依赖,立即执行一次 |
watch |
精确控制:特定值变化时执行,需要旧值 | 懒执行,可选 immediate: true |
watch(..., { flush: 'post' }) |
需要在 DOM 更新后执行 | 替代 nextTick 的大多数场景 |
javascript
js
// 场景:name 变化时,同步到 localStorage
watch(name, (newVal) => {
localStorage.setItem('name', newVal)
}, { flush: 'post' }) // DOM 更新完再存,避免覆盖
七、避坑清单
| 坑 | 正确做法 |
|---|---|
Composable 里用 inject 读取全局状态 |
通过参数传入,保持纯函数特性 |
在 setup 里写几百行逻辑 |
拆成多个 composable,每个不超过 80 行 |
所有状态都用 ref 导致 .value 满天飞 |
确实会满天飞,这是代价,接受它 |
忘记 defineExpose 导致父组件拿不到方法 |
明确组件边界:哪些是内部的,哪些是对外的 |
computed 里写副作用 |
computed 必须是纯函数,有副作用用 watchEffect |
| 循环依赖的 composable | 抽离共享状态到第三个 composable,打破环 |
八、一个真实的文件结构参考
bash
src/
├── composables/
│ ├── useUser.ts # 用户相关逻辑
│ ├── useAuth.ts # 认证逻辑
│ ├── useFetch.ts # 通用请求封装
│ └── useTheme.ts # 主题切换
├── views/
│ └── UserProfile.vue # 只负责 UI + 调用 composable
└── App.vue
当你的 views 文件夹里每个文件不超过 100 行时,架构就对了。
结语
组合式 API 的最大价值不是"更灵活",而是让你有能力把代码按人能理解的方式组织起来。
ref 和 reactive 只是工具,真正的最佳实践是:
每个文件只做一件事,每个函数只改一个地方,每次阅读只需要理解一个模块。
做到这三点,你的 Vue 3 代码就已经超过 90% 的项目了。