Vue 3 Composition API使用方法(AI 大模型评测平台的前端代码组织)

改一个需求要翻三处代码——评测列表、模型对拍、报告生成的逻辑纠缠,是 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 强制重建是比较省事的解法。

十四、组件拆分原则

平台组件分三类:

  1. 展示组件(无状态):<UserCard :user="user" />
  2. 容器组件(带状态):<UserList :filter="filter" /> 内部处理分页/加载
  3. 页面组件(路由级):一个 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 友好,不是性能。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
小码农的头像小码农认证作者

相关推荐

返回顶部