改一个需求要翻三处代码——评测列表、模型对拍、报告生成的逻辑纠缠,是 Vue 2 时代留下的最典型结构问题。对比了 Options API、mixin 和 Composition API 三条路之后,我选了 Composition API + script setup 的组合——不是因为新,而是因为它把”按功能组织代码”这件事做成了语法层面的事,而不是靠团队自觉。下面分享这一年的沉淀。
一、为什么选 Composition API
Vue 2 时代主流是 Options API(data/methods/computed 分散),Composition API 把相关逻辑聚合成函数,更适合复杂业务。平台选 Composition API 的原因:
- 逻辑复用:跨组件共享逻辑用 composable(
useXxx.ts),不用 mixin; - 类型推导:TS 支持优于 Options API;
- 代码组织:相关代码写一起,不像 Options 那样按”类型”分块;
- 按需引入:tree-shaking 友好。
这里补一句 mixin 的对比:我们早期项目用过 mixin 做复用,后来吃够了隐式依赖的亏——一个字段不知道从哪个 mixin 来,两个 mixin 同名方法互相覆盖还找不到原因。composable 把依赖关系显式地放在参数和返回值里,新人看代码不用翻文件。类型推导这点对我们这种接口多的平台尤其重要,接口返回的结构变了,IDE 直接标红,不用等运行时报错。
二、Vue 模板逻辑段 语法糖
平台所有组件都用 Vue 模板逻辑段:
<template>
<div>{{ count }} {{ double }}</div>
<button @click="inc">+</button>
</template>
<!-- 模板逻辑段 (TS) -->
import { ref, computed, onMounted } from 'vue'
const props = defineProps<{ initial?: number }>()
const emit = defineEmits<{ (e: 'change', v: number): void }>()
const count = ref(props.initial ?? 0)
const double = computed(() => count.value * 2)
function inc() {
count.value++
emit('change', count.value)
}
onMounted(() => {
console.log('mounted')
})
<!-- 模板结束 -->
特点:
- 无需
export default,顶层变量/函数自动暴露给模板; defineProps/defineEmits是编译器宏,无需 import;lang="ts"直接写 TS。
这个写法带来的收益是减少了模板和逻辑之间的”接线”成本。Options API 里每加一个方法都要在 methods 里写一遍再在模板里引用,逻辑多了模板部分会很臃肿。script setup 里顶层声明的变量天然就是模板变量,命名一致,心智负担小很多。迁移那段时间我们统计过,同样一个功能,新写法平均少写三分之一样板代码。
三、核心 API 速查
Composition API 的 API 数量不多,但选型容易纠结。下面这张表是平台内部给新人用的速查表,把用途和典型用法都列出来了:
| API | 用途 | 平台示例 |
|---|---|---|
ref |
基本类型响应式 | const count = ref(0) |
reactive |
对象响应式 | const state = reactive({...}) |
computed |
计算属性 | const double = computed(() => count.value * 2) |
watch |
侦听器 | 监听 props 变化 |
watchEffect |
自动追踪 | 副作用自动收集依赖 |
readonly |
只读包装 | 给父组件传只读子对象 |
toRef/toRefs |
解构保持响应性 | toRefs(props) |
provide/inject |
跨层级传值 | 全局配置 |
defineAsyncComponent |
异步组件 | 路由懒加载 |
我们的使用经验是:基本类型一律 ref,对象数据结构稳定就用 reactive,需要解构或传给 composable 时统一转 ref。判断标准很简单——看这个状态是要”整体替换”还是”局部修改”,前者用 ref,后者用 reactive。
四、组合式函数(Composables)
平台把可复用逻辑抽成 useXxx.ts,比如分页逻辑在所有列表页都用得上,就抽了一个:
// composables/usePagination.ts
import { ref, computed } from 'vue'
export function usePagination<T>(fetcher: (page: number, size: number) => Promise<T[]>,
pageSize = 20) {
const page = ref(1)
const list = ref<T[]>([])
const total = ref(0)
const loading = ref(false)
async function load() {
loading.value = true
try {
const res = await fetcher(page.value, pageSize)
list.value = res
} finally {
loading.value = false
}
}
const hasMore = computed(() => list.value.length < total.value)
function next() {
if (hasMore.value) {
page.value++
load()
}
}
return { page, list, total, loading, hasMore, load, next }
}
这段解决的是”每个列表页重复写分页状态管理”的问题。以前这种逻辑在每个页面复制一遍,改个分页大小要全局搜索。使用侧现在长这样,组件里只关心它需要的字段:
const { list, loading, load, next } = usePagination(
async (page, size) => await api.get('/api/batch', { page, size })
)
用 composable 有个注意点:返回的字段要克制,只暴露使用者需要的,别把内部状态全部倒出去,否则调用方和实现就耦合了。另外 composable 内部可以用生命周期钩子,比如自动监听路由变化,这比在外面拼装省事得多。
五、生命周期钩子
import { onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
onMounted(() => {
// 组件挂载
})
onBeforeUnmount(() => {
// 组件销毁前清理(事件监听、定时器、订阅)
clearInterval(timer)
window.removeEventListener('resize', handler)
})
平台对每个长连接组件都做”卸载清理”,避免内存泄漏。这条规则是在一次线上内存告警之后定下的:有个评测进度组件里挂了一个轮询定时器,组件切走了定时器还在跑,一晚上堆了几千个未清理的句柄。后来代码规范里明确规定,凡是 onMounted 里开的东西,onBeforeUnmount 必须关。
六、响应式原理注意
// 1. 解构会丢响应性
const { count } = reactive({ count: 0 }) // ❌ count 是普通值
// 用 toRefs
const { count } = toRefs(reactive({ count: 0 })) // ✅ count 是 ref
// 2. reactive 整体替换不响应
let state = reactive({ count: 0 })
state = reactive({ count: 1 }) // ❌ 引用变了
// 改用 Object.assign 或 ref
state.count = 1 // ✅
// 3. ref 与 reactive 混用
const a = ref(0)
const b = reactive({ a }) // b.a 是 ref
b.a++ // b.a.value++
这三条是新人常踩的响应式坑。第一条解构丢响应性,我们几乎每周都能在评审里看到一次;第二条整体替换不响应,常见于”重置表单”的场景;第三条混用,记住了规律其实不复杂——reactive 里包 ref,访问时要带 .value。
七、provide/inject 跨层级
// 父
const config = ref({ theme: 'light' })
provide('config', config)
// 子(任意深度)
const config = inject<Ref<{ theme: string }>>('config')
平台用 provide/inject 传全局配置(主题、用户信息、API 客户端),避免逐层 prop 传递。这个方案解决的是”props 逐层钻透”的问题——评测页到结果展示组件中间隔了四五层,每层都要透传一遍用户配置,改一个字段改一排组件。用 provide/inject 之后只在顶层注入、底层取用。注意 provide 的值要包 ref 才具备响应性,直接传对象的话,子组件拿到的是快照。
八、性能最佳实践
评测平台有大量列表和图表,性能问题都是真金白银优化出来的。先看渲染控制的对比:
1. v-show vs v-if
<!-- v-show:DOM 一直在,仅切 display(适合频繁切换) -->
<div v-show="visible">...</div>
<!-- v-if:DOM 真的创建/销毁(适合很少切换) -->
<div v-if="visible">...</div>
2. computed 缓存
const expensive = computed(() => {
// 依赖不变就不重算
return list.value.filter(...).map(...)
})
3. v-for 加 key
<div v-for="item in list" :key="item.id">
{{ item.name }}
</div>
不写 key 会触发”原地复用”导致状态错乱。这个坑我们踩得印象深刻:模型列表加了个”已收藏”标记,没写 key,翻页之后标记全乱了,排查了一下午才定位到是 key 的问题。
4. shallowRef / shallowReactive
// 大列表只关心整体替换,不关心内部响应
const bigList = shallowRef<Item[]>([])
// 改 bigList.value = newList 不触发内部响应,性能更好
5. 异步组件
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))
路由级懒加载首屏只加载必要的。这几条里收益比较明显的是异步组件——评测报告页里那个图表组件体积大、初始化慢,拆成异步之后首屏时间降了一截。
九、TS 集成
// 1. defineProps 类型
const props = defineProps<{
list: Item[]
initial?: number
}>()
// 2. defineEmits 类型
const emit = defineEmits<{
(e: 'change', value: number): void
(e: 'update', item: Item): void
}>()
// 3. ref 类型
const count = ref<number>(0)
const user = ref<User | null>(null)
// 4. 模板 ref 类型
const inputRef = ref<HTMLInputElement | null>(null)
有了类型之后,组件之间传参的错误在编译期就能暴露。特别是 defineEmits 用类型定义事件签名,父组件监听时参数类型也对上了,少了一类”事件参数在运行时才发现类型不对”的问题。
十、常见反模式
1. setup 里写副作用
// ❌ 反模式:setup 里直接发请求
const data = await api.get('/api/foo')
// ✅ 正确:onMounted 里发
onMounted(() => api.get('/api/foo').then(d => data.value = d))
2. watch 的 deep 滥用
// ❌ 深度监听大对象性能差
watch(() => state.bigObj, () => { ... }, { deep: true })
// ✅ 监听具体字段
watch(() => state.bigObj.id, (newId) => { ... })
3. 模板里调用函数
<!-- ❌ 每次重渲都调用 -->
<div>{{ formatDate(item.createdAt) }}</div>
<!-- ✅ 用 computed 缓存 -->
<div>{{ formattedDate }}</div>
三条反模式里,模板调函数这条比较隐蔽。表面上代码能跑,但列表一长,每次重渲染都要重新格式化一遍所有日期,性能损耗被数据量放大。改成 computed 之后,只有依赖变化才重算。
十一、与 Pinia 配合
// store 在 setup 里 use
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// state 用 storeToRefs 解构
const { isLogin, displayName } = storeToRefs(userStore)
这里有个细节:直接解构 store 会丢掉响应性,所以状态一定要用 storeToRefs 包一层;而 store 里的 action 方法本来就不具备响应性,直接解构没问题。这个区分我们写进了团队规范,避免新人写错。
十二、与 Vue Router 配合
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
// 读 query
const id = computed(() => route.query.id as string)
// 跳转
function goDetail(id: number) {
router.push({ name: 'EvalDetail', params: { id } })
}
注意 route.query 取出来是 string | string[] | null 的联合类型,直接当成 string 用会在类型检查时被拦下来,所以页面里统一用 computed 包一层做收敛。
十三、踩过的坑
- script setup 不能用
this:用普通变量/函数替代。 - ref 自动 unwrap 只在模板里:JS 里要
count.value。 - provide 非响应:用
ref包一层。 - 路由组件实例复用:params 变化组件不重建。用
:key="route.fullPath"强制重建。 - defineEmits 类型推导失败:升级 vue-tsc 到最新版本。
- 异步 setup + Suspense:早期没用 Suspense 包裹导致 loading 闪烁。
这里面路由组件实例复用那条比较坑:从评测 A 跳到评测 B,组件以为还是同一个,数据没刷新。加 key 强制重建是比较省事的解法。
十四、组件拆分原则
平台组件分三类:
- 展示组件(无状态):
<UserCard :user="user" /> - 容器组件(带状态):
<UserList :filter="filter" />内部处理分页/加载 - 页面组件(路由级):一个 page 只组织布局,不写复杂逻辑
复杂逻辑下沉到 composable 或 store。
这个分类法配合 Composition API 用起来很顺:展示组件就是纯 props + 模板,容器组件用 composable 组装状态,页面组件只做布局和路由跳转。逻辑该放哪,看它的复用范围就能定。
常见问题(FAQ)
Q1:Composition API 能用 mixin 吗?
能但不推荐。Composable 是更好的复用方式,无命名冲突、无隐式来源。
Q2:什么时候用 Options API?
不推荐。Vue 3 时代新组件一律 Composition API + Vue 模板逻辑段。
Q3:Composition API 性能更好吗?
差不多。Composition API 的优势是逻辑复用和 TS 友好,不是性能。