☰
Vue 3组合式API对比Options API:代码组织与逻辑复用优势详解
2026/9/29 17:49:43 网站建设 项目流程

1. 在真实项目里摸爬滚打后,才看明白两者差在哪儿

先说个场景。你接了一个运营后台项目,某个订单列表页从最初的三百行代码,被产品需求堆到了一千二百行。这个时候在 OptionsAPI 里找"导出的字段"是什么感觉?你得反复滚动到 data、computed、watch、methods 四个区块之间来回跳。更难受的是,这四个区块之间靠什么关联?靠 this,靠属性名,靠你脑子里记住的"字段A设置在 data 里,计算属性B负责加工它,方法C在 methods 里调用它们"。如果某一天你不太记得方法名,搜索"export"可能搜出来八个结果,你得逐个判断到底哪个是那个"列表导出"的方法。这就是 OptionsAPI 的先天性格:它是按"数据性质"组织代码的,不是按"业务逻辑"组织代码的。而业务在真实世界里从来不会按数据性质走,它是一整条线:先查列表数据、再处理筛选条件、然后应对分页变化、最后把结果渲染出来。这条线在 OptionsAPI 里被硬生生切成了 data 一块、computed 一块、methods 一块,就跟把一条流水线拆成不同楼层,你干完这道工序还得坐电梯去另一层找下一道工序。

Composition API 的出现,本质上是在说:我们能不能不按数据类型划分,而是按"这个功能需要哪些数据、哪些计算、哪些方法"来划分。用组合式 API 写同一个订单列表,你会把列表数据、加载状态、筛选条件、获取列表的方法、重置筛选的方法、还有相关的 computed,全都放进同一个逻辑块里。该功能需要的东西聚在一起,看起来就像一条完整的流水线摆在眼前。这个体感上的差别,恰恰是 Vue3 的 Composition API 相对 OptionsAPI 最核心、也最容易被忽略的价值。

不过这里我得泼一盆冷水:如果你觉得"哦就是把代码按功能重新摆了一下",那还是没抓住重点。Composition API 不是一种代码排版风格,它是一种从"组件选项容器"转向"逻辑表达单元"的机制变化。后者带来的东西,包括真正的逻辑复用、更可靠的类型推导、更明确的依赖关系,远比"代码排列整齐"重要得多。

2. 用同一段代码说话:列表页在两种写法下的样子

我见过太多介绍文章,只给结论不给证据,结果新手看完除了记住"Composition API 更好"这句话,其他什么都没记住。我们直接上一段稍微具体一点的代码,实现一个常见的场景:一个需要搜索、筛选、分页的列表页。先用 OptionsAPI。

export default { name: 'OrderList', data() { return { page: 1, pageSize: 10, keyword: '', status: '', list: [], total: 0, loading: false } }, computed: { filteredParams() { const params = { page: this.page, pageSize: this.pageSize } if (this.keyword) params.keyword = this.keyword.trim() if (this.status) params.status = this.status return params } }, watch: { keyword() { this.page = 1 this.fetchList() }, status() { this.page = 1 this.fetchList() } }, methods: { async fetchList() { this.loading = true try { const res = await fetch('/api/orders', { method: 'POST', body: JSON.stringify(this.filteredParams) }).then(r => r.json()) this.list = res.data.list this.total = res.data.total } finally { this.loading = false } }, handlePageChange(newPage) { this.page = newPage this.fetchList() }, handleReset() { this.keyword = '' this.status = '' this.page = 1 this.fetchList() } }, mounted() { this.fetchList() } }

这段代码不长,但你已经能感受到分流:keyword、status、page 这些数据在 data 里,fetchList 在 methods 里,filteredParams 又独立在 computed 里。当你要给这段逻辑新增一个"时间范围筛选"时,你要动三个地方:data 里加开始时间和结束时间、computed 里加参数拼接、methods 里把新字段塞进 fetchList 的调用。三个分隔区来回改,这还算是小型场景。真到了复杂业务组件,代码块会拉得很长,这种割裂感会被放大。

再看同样功能用 Composition API 配合<script setup>怎么写。

<script setup> import { ref, reactive, computed, watch, onMounted } from 'vue' const page = ref(1) const pageSize = ref(10) const keyword = ref('') const status = ref('') const list = ref([]) const total = ref(0) const loading = ref(false) const filteredParams = computed(() => { const params = { page: page.value, pageSize: pageSize.value } if (keyword.value) params.keyword = keyword.value.trim() if (status.value) params.status = status.value return params }) async function fetchList() { loading.value = true try { const res = await fetch('/api/orders', { method: 'POST', body: JSON.stringify(filteredParams.value) }).then(r => r.json()) list.value = res.data.list total.value = res.data.total } finally { loading.value = false } } watch([keyword, status], () => { page.value = 1 fetchList() }) function handlePageChange(newPage) { page.value = newPage fetchList() } function handleReset() { keyword.value = '' status.value = '' page.value = 1 fetchList() } onMounted(fetchList) </script>

你对比一下,功能完全等价,但组合式版本把"列表相关的状态"和"列表相关的函数"放在同一个作用域里面,它们天然共享作用域变量,不再需要 this 的中转。这就是我说的"机制变化":它不是把代码换个位置那么简单,而是取消了"所有方法挂到组件实例上"这个强制要求。方法就是普通函数,数据就是普通变量,全都在一个作用域里自然流动。这种模型对于理解代码的人、审查代码的人、接手代码的人来说,心智负担直接降一档。

3. 从 Mixin 到自定义 Hooks:逻辑复用方式终于从补丁变成了原生方案

这个点我认为是 Composition API 最甜蜜的果实。在 OptionsAPI 时代,我们做逻辑复用靠什么?靠 Mixin。Mixin 这个方案有几个一直没解决的硬伤。

第一个是来源不明。假设项目里有mixins: [paginationMixin, statusMixin, auditMixin],模板里突然出现一个visibleFlag字段,你根本不知道这个字段是从哪个 Mixin 里注入进来的。IDE 跳转也经常失效,只能靠全局搜索。第二个是命名冲突。两个 Mixin 如果恰好有同名属性或方法,后者覆盖前者,而且 Vue 在运行时不会对你发出任何警告。这个 bug 的隐蔽性极高。第三个是隐式数据耦合,Mixin 内部的 methods 会依赖组件里的某些 data 字段,你复制一个 Mixin 到另一个组件里,经常出现"方法有了但数据没有"的尴尬,需要在组件里额外补字段,补完才发现另一处又冲突了。可以说 Mixin 这套复用机制,长期处于一种"能用但很疼"的状态。

Composition API 推出的自定义 Hook(通常叫 composable)从根上改变了这个局面。它首先是一个普通函数,函数天然接受参数、返回结果,所有数据依赖都通过参数传入,不再凭空"注入"组件的命名空间。其次,返回的每一个响应式变量和函数,调用方都清楚它来自哪里,因为是你亲手调用的这个函数。我们看一个实际的例子。

// usePagination.js import { ref, computed } from 'vue' export function usePagination({ getList, initialTotal = 0 } = {}) { const page = ref(1) const pageSize = ref(10) const total = ref(initialTotal) const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) async function fetchPage() { const res = await getList({ page: page.value, pageSize: pageSize.value }) total.value = res.total return res.list } function reset() { page.value = 1 return fetchPage() } function setPage(val) { page.value = val return fetchPage() } return { page, pageSize, total, totalPages, fetchPage, reset, setPage } }

然后在任意组件里,我只需要三行就能把分页能力接进来。

<script setup> import { usePagination } from '@/hooks/usePagination' const { page, pageSize, total, totalPages, reset, setPage } = usePagination({ getList: fetchOrderList }) async function fetchOrderList({ page, pageSize }) { // ... } </script>

不同组件之间如果要共享同一个加载态 Hook,写法也几乎一样。比如useLoading、useRequest、useDebounce、useLocalStorage,全都是同一套思路。这种复用方式的优势不仅在于代码量减少,更在于"我明确知道这一个 Hook 管的是什么逻辑"。团队在 code review 时看到const { list, loading, refresh } = useListApi('/api/orders'),不需要深入函数内部,就已经能大致猜到整个页面的数据流走向。这种可读性才是工程上最值钱的东西。

4. 依赖关系越发清晰,watchEffect 与 watch 各自的本职工作

经常看到 Vue3 新人困惑 watch 和 watchEffect 到底该用哪个。其实放到 Composition API 的语境下,这个选择变得非常自然,因为它们俩响应的是两种不同的开发需求。

先说说 Vue3 的响应式底层:Proxy 代理。在 OptionsAPI 里,数据通过data()返回后由 Vue 遍历劫持,这个时代用的是Object.defineProperty,你无法精确追踪新增属性、删除属性,改数组下标也得走$set。到了 Vue3,ref底层用对象包装了一个 value,reactive底层用new Proxy代理整个目标对象。这个变化带来的直接后果是:追踪变得"精确且中立"。你可以对一个对象新增属性、删除属性、对数组按下标赋值,响应式系统都能捕获到,不需要再记各种特殊的 API 了。

在这个机制上,Composition API 的 watch 用法和 OptionsAPI 基本一致,就是明确指定我观察谁,然后执行副作用。而 watchEffect 则是"我不指定具体看谁,只要这个回调里用到了任何响应式数据,它们一变我就重跑"。这个概念如果你只听不做,会觉得 watchEffect 太省事了,什么都替你盯着。但它最大的问题是:你不知道它到底依赖了哪些东西,尤其在代码稍微复杂一点之后,你很难预判回调何时触发。所以我的原则很简单:如果是"某条件变化后执行一个明确动作",比如筛选条件变化后重新拉列表,用 watch,并且精确指定 watch 的来源;如果是"立即执行一次,并且任何相关数据变化时都同步状态的副作用",例如根据当前 props 和状态推导出 document.title,或者往 localStorage 里同步写入一份状态副本,用 watchEffect 更省心。这里有个很容易忽略的细节:watchEffect 一上来就会执行一次回调,所以直接把"初始化赋值"和"后续更新同步"两件事合并了,这在 OptionsAPI 里你得写 mounted 加 watch 两处。

再强调一个实战中特别容易误解的东西:computed 的依赖收集。在 Vue3 里 computed 返回的是一个ComputedRef对象,使用时注意.value。有些人在 computed 内部用了不是响应式的普通变量,期望它"自动更新",结果当然不行。而有些人反过来,在 computed 内部写了一大堆副作用,比如发请求、改别的 ref,这其实是滥用了 computed,它本质应该是"纯计算"。副作用请放到 watch 或 watchEffect 里,或者放到事件处理函数中。这个原则不仅 Vue 适用,React 里对 memo 的要求也几乎一模一样。理解了这个,你才算摸到了响应式框架的通用心法。

5. 代码复用之外的另一块版图:TypeScript 类型推导与模块化边界

Composition API 另一个被低估的优势是它对 TypeScript 的亲和度。Vue3 本身就是用 TypeScript 重写的,Composition API 的所有 API 签名都自带完善类型定义,这一点和 OptionsAPI 有天壤之别。在 OptionsAPI 时代,写 TypeScript 经常要靠装饰器提案或者类组件库来维持类型,体验并不好。到了 Composition API,一个ref(0)自动推导出Ref<number>,一个reactive({ name: '', age: 0 })自动推导出对应对象类型,等于把类型系统直接嵌入到了代码里。如果你定义一个小型 Hook,还能借助泛型把类型传进去,这样调用方拿到的返回值也完全带类型。

这种类型推导带来的强约束,不只是解决"拼错属性名"这种低级问题,更体现在大型团队协作上。一个组件被五个人前后修改,只要你把数据结构用类型定义清楚,任何新增字段在编译阶段就会被揪出来。比如说你定义了interface OrderItem { id: number; amount: number },在模板里误写item.amt,TypeScript 会直接报错。在 OptionsAPI 的 this 类型推断里,这样的检查很难做到同样的深度和自然度。

不过类型推导背后还有一层值得你重视的东西:模块边界。Composition API 把逻辑拆分成了无数个小函数、小 Hook,这意味着你可以把代码从"大组件文件"里解放出来,装进一个个独立文件里。这对代码审查、单测、重构都是巨大的帮助。比如你做了一个useOrderStatus,它管状态流转、超时自动关闭、异常状态下发提醒。这个 Hook 量级大约五十行,可以被单独测试、单独复用,与组件 UI 完全解耦。而 OptionsAPI 里的业务逻辑分散在 mixin 和组件方法里,你想单独测某个功能线,需要先构造一整个组件实例,测试成本高出一个量级。逻辑可测试性和模块边界,在工程上的价值往往比你在 demo 里感受到的更加实际。

6. 适用场景判断题:哪些项目真的应该迁,哪些项目暂时别动

现在回到最实际的问题:我一个 Vue2 项目,到底要不要迁到 Composition API。我的建议是分场景判断,别盲目跟风。

先说不怎么需要迁的情况。如果你的项目是短生命周期的小工具页面,比如内部的一个报表页面,总共就一个组件,没有跨组件共享逻辑,也没有复杂状态流,几十行代码就写完了。这种情况下你用 OptionsAPI 写没问题,因为它的心智模型简单、文档多、团队里随便谁都能改。项目成本收益比最优解往往不是"最先进的技术",而是"在该复杂度下最省事的技术"。强行引入 Composition API,反而要给每个 ref 手动管理,增加了思维负担。还有一种情况是项目已经稳定运行了好几年,现有代码全是 OptionsAPI,而团队规模很小、项目没有新增复杂逻辑的迹象,那就没有必要做一次大迁移,因为迁移本身并不产生用户价值。

再说适合迁的场景,我做一个粗略的判断表给你参考。

场景特征建议
组件代码超过五百行,经常在 data/methods/computed 之间反复跳强烈建议迁 Composition API
存在多个组件共享同一套逻辑(分页、列表加载、表单校验)建议用自定义 Hook 重构
项目需要使用 TypeScript,且类型要求严格建议迁移,收益非常明显
团队规模大,多人并行维护同一模块建议至少新代码采用 Composition API
仅仅是单个小页面,逻辑非常简单可以不迁
团队所有成员对 OptionsAPI 已经极熟,短期学习成本偏高建议渐进式引入,先从一个页面试点

如果是老项目想渐进式迁移,我给一条比较稳的路径。第一步,把现有逻辑拆分成独立的 composable 函数,注意这个步骤可以不动组件的 OptionsAPI,只是把我们想要的新逻辑抽象成 Hook,放进hooks目录。第二步,在组件里新增的代码用<script setup>方式写,而不是先把老代码一次性改写。第三步,通过一段时间的观察,把 OptionAPI 里的方法迁移到 Hook 中,最后逐步清空 OptionsAPI 的区块。这样迁移的风险会被控制在一个小范围内,每次发布都能测试,不会出现"一夜之间重构完毕"的大事故。

7. 关于 setup 语法糖和响应式丢失的坑,我踩过也复盘过

最后聊聊<script setup>语法糖。很多人第一次接触 Composition API 被它的this消失搞懵了 —— 没错,在<script setup>里你不能再使用this,所有方法、变量都必须声明在 setup 作用域内,然后模板直接绑名字。这是一个非常干净的模型:没有组件实例混入,没有隐性代理,模板能访问的变量全部是显式声明的。这种"显式"看似啰嗦,实则是安全感的来源。

但正是因为 setup 作用域下的代码如此"普通",你失去了 Vue 在 OptionsAPI 里自动绑定 this 的能力,因此特别容易碰上一个经典问题:响应式丢失。举个例子,你写const { list, loading } = useListApi(),如果这个 Hook 内部是用reactive({ list: [], loading: false })定义的,那么解构出来之后list和loading就不再具有响应性。为什么?因为 reactive 代理的是这个对象本身,你把属性取出来赋值给普通变量,这个变量的值只是当时那个时刻的快照,后续改动跟它没有关系。解决方案有两个:要么内部用ref定义每个字段,因为 ref 是用.value访问的,解构出来的仍是一个 Ref 对象,具有响应性;要么对 reactive 对象用toRefs转换成一组 ref,再解构。

再分享一个 project 级的经验:不要在<script setup>里把所有的逻辑都堆在顶层。我见过有人把整个页面三百行逻辑全塞进 setup 里,这虽然也是"写在同一个函数作用域",但从组织性上还不如 OptionsAPI 的方案。正确做法是:页面里的通用逻辑尽量抽象成 Hook,组件顶层不过是对 Hook 的"装配"。比如订单列表页,顶层可以只调useOrderList()、useOrderFilter()、usePaginator()三个 Hook,页面的 setup 只有三五行装配代码,其他细节全部下沉到 Hook 内部。这样组件读起来像目录,逻辑在各自文件里完整呈现,团队协作成本最低。

还有一个真实的坑,是事件监听器的清理。在 OptionsAPI 里,我们在 beforeDestroy 生命周期注销全局事件。到 Composition API 里则是onUnmounted(() => { window.removeEventListener('scroll', handler) })。但很多人写 Hook 时容易忘记在内部主动注册清理逻辑,导致组件卸载后监听器还挂在 window 上,造成内存泄漏。所以我个人在写任何 Hook 时,都会在 Hook 内部把所有需要清理的资源一次性处理好,确保外部调用方不需要额外操心。这就像写一个工具函数连文档一起交付,属于工程素养问题。

另外,在异步函数里使用 ref 也要小心。比如在 await 之后修改 ref 的值,代码是可运行的,但如果组件已经卸载,这个赋值操作就会被忽略,Vue3 默认不会报错。这种情况可以借助一个简单的isMounted标志位来判断,或者在 Hook 里维护一个onUnmounted清理的 flag。这个细节在长时间运行的页面上很容易变成隐患。

最后补充一个我在培训时反复强调的建议:不要为了"看起来高级"而强行使用 Composition API。它确实是 Vue3 的未来,但比起它代表的技术方向,你更应该关注的是它解决的具体问题——代码组织混乱、逻辑复用困难、类型推导缺失。如果你当前的项目没有这些问题,用 OptionsAPI 并不可耻。如果项目确实有这些病,那就大胆迁移,从一个小模块开始,用真实的代码体感去对比。等你真的把一个三百行组件拆成三个 Hook、再把它们移植到另一个页面只用了十行代码时,你自然就明白这个设计为什么值得存在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询