上周帮团队review一个五年多的Vue2老项目,有个支付结果页的代码让我印象很深:data里躺着二十几个字段,methods里四五个业务块互相穿插调用,computed里塞了七八个派生状态,watch还监听了好几个token相关的字段。整个组件两千多行,但真正要改一个小需求时,根本不敢动——因为同一个功能的代码被拆到了五个不同的选项块里。说实话,这种状态在2026年已经很难自洽了。Vue官方在Vue 3早就把组合式API(Composition API)作为默认推荐,生态里主流组件库、后台框架、面试题也都在围绕它转,可我发现身边还是有不少人习惯性写着Options API。这篇文章我想从一线开发者的视角,把组合式API到底解决了什么、迁移时怎么下手、以及哪些场景其实可以继续用Options API,一次性说透。
1. 先别急着站队:Options API到底输在哪里
1.1 代码组织:按选项划分而不是按功能划分
很多人觉得Options API挺好用的,因为data、methods、computed这些选项结构清晰,一看就知道哪里放数据、哪里放方法。但问题恰恰出在这个“清晰”上——它按类型组织代码,而不是按功能组织代码。
举个例子,一个典型的订单列表页往往同时包含这些逻辑:
- 搜索表单的数据和提交方法
- 列表数据的拉取和分页
- 多选行的状态管理
- 导出Excel的组装逻辑
- WebSocket推送的订阅与销毁
用Options API写,你会得到这样的结构:
export default { data() { return { keyword: '', dateRange: [], status: '', list: [], page: 1, pageSize: 20, total: 0, loading: false, selectedRows: [], socket: null, isExporting: false } }, computed: { filteredList() { /* ... */ }, isAdmin() { /* ... */ }, canExport() { /* ... */ } }, watch: { keyword() { this.page = 1; this.fetchList() }, status() { this.page = 1; this.fetchList() } }, methods: { fetchList() { /* 列表拉取 */ }, handleSearch() { /* 搜索 */ }, handleReset() { /* 重置 */ }, handleSelectionChange() { /* 多选 */ }, handleExport() { /* 导出 */ }, initSocket() { /* WebSocket */ }, destroySocket() { /* 销毁 */ }, // ... 还有十几个方法 }, mounted() { this.fetchList() this.initSocket() }, beforeUnmount() { this.destroySocket() } }看着整齐对吧?但当你需要维护“搜索”这一个小功能时,你得同时打开data、watch、methods、computed四个区域来回跳。搜索相关字段散落在二十几个字段中间,搜索对应的handler和普通方法混在一起,搜索引起的副作用藏在watch里。你的视线被迫在文件的各个角落来回漂移,大脑需要手动“拼接”这些碎片,才能真正理解一个功能的完整链路。
组合式API恰恰是把这些碎片重新归位。你可以把搜索相关的所有字段、方法、副作用写在一块儿,列表相关的写在一块儿,多选相关的写在一块儿。代码库的阅读单位从“文件的选项块”变成了“功能块”。这看似只是代码摆放位置的调整,实际上直接影响了你接手一个陌生组件时的认知成本。
1.2 逻辑复用的天花板:mixin的三宗罪
如果说代码组织还只是“别扭”,那逻辑复用就是Options API真正感觉无力的地方。Vue 2时代最常用的复用方案是mixin,我参与过的项目里几乎都踩过它的坑。
第一宗罪是命名冲突不可控。多个mixin同时定义了data里的同名字段或者methods里的同名方法,最终生效的是谁,取决于混入顺序,而且Vue的合并策略是组件自身优先于mixin,两个mixin之间则是后者覆盖前者。这个规则背起来容易,但在多人协作、复用第三方mixin时,根本防不住。我在一个项目里就亲眼见过,一个mixin定义了init()方法,组件自己也写了init(),结果mixin的代码被静默覆盖,页面某个初始化逻辑悄悄失效,排查了整整一天。
第二宗罪是来源不透明。看模板里的filteredList,你根本不知道它来自哪个mixin、来自哪个全局混入、还是组件自己定义的。IDE跳转经常跳到一堆抽象层里,一个看似简单的方法背后可能横跨五六个mixin。对于新接手的人来说,这几乎是灾难。
第三宗罪是隐式依赖。mixin之间可以互相依赖对方的字段和方法,但这种依赖没有任何显式声明。A mixin用了B mixin里的this.userInfo,一旦B被移除或者改名字,A就悄悄坏掉。这种问题在编译期完全不报错,只有运行到特定路径才暴露。
组合式函数(composable)解决的正是这三个问题。它的核心思想很简单:把一段完整的业务逻辑,连同它的状态和方法一起封装成一个函数。调用方显式传入参数、显式接收返回值,没有隐式共享,没有命名空间污染,一眼就能看出数据从哪里来、方法属于谁。
1.3 类型推导:Options API在TypeScript下的无力感
2026年的项目基本默认TypeScript,这一项几乎成了很多人转向组合式API的决定性原因。
Options API在TypeScript下的核心问题是this。this依赖选项合并的隐式类型,Vue官方需要通过复杂的defineComponent宏和类型推导才能让this上的数据和方法正确推断。可一旦遇到mixin、全局属性、自定义$bus之类的东西,类型就变得非常吃力。写代码时满屏any、调方法时频频报错、IDE提示不可靠,这些都是“你明明在写TS,却享受不到类型保护”的典型症状。
组合式API的思路完全不同。它在setup或<script setup>里直接使用普通函数和变量,没有this的魔法。你写const keyword = ref(''),它的类型就是Ref<string>;你写const fetchList = () => {},它就是普通的函数类型。整个数据流和类型流是显式、确定的,TypeScript可以精确推导,IDE也能给出精准的提示。
这不仅仅是“开发者体验更好”的层面,更是工程层面的优势。类型安全意味着重构时能靠编译器兜底,意味着API边界被强制表达清楚,意味着大型团队协作时每个函数签名本身就是一份文档。
2. 组合式API的设计内核:把“功能”当作一等公民
2.1 setup 到底解决了什么问题
组合式API的核心入口是setup函数。在<script setup>这个语法糖普及之前,你需要手动写setup()并返回所有需要暴露给模板的变量和方法,略显繁琐。现在官方推荐的写法已经完全被<script setup>全面接管。
但无论哪种写法,setup的本质是一样的:它让组件逻辑不必再被“选项”这个外壳约束。你可以这样理解——Options API像是一个分好类的文件柜,data是一个抽屉、methods是一个抽屉、computed是一个抽屉,你必须把东西拆开按类别放进去;而Setup像一张空白的桌面,你怎么摆放完全由你说了算,更准确的说是由“功能边界”说了算。
看一个最简单的对比:
<script setup> import { ref, computed, onMounted } from 'vue' // 搜索功能:字段 + 方法 + 副作用 都在一起 const keyword = ref('') const page = ref(1) const search = () => { page.value = 1 fetchList() } const reset = () => { keyword.value = '' page.value = 1 fetchList() } // 列表功能:字段 + 方法 + 生命周期 也在一起 const list = ref([]) const loading = ref(false) const fetchList = async () => { loading.value = true try { const res = await getOrderList({ keyword: keyword.value, page: page.value }) list.value = res.data } finally { loading.value = false } } onMounted(() => { fetchList() }) </script>这段代码最大的变化不是少写了几个选项,而是阅读路径变短了。你想看搜索功能,往下扫一眼就能看到它完整的链路:数据、行为、副作用都在一起。这跟人类理解业务的方式是一致的——我们本来就不是按“变量”“方法”“计算属性”来理解业务的,我们按“登录功能”“购物车功能”“支付功能”来理解。
2.2 用组合式函数组织复杂页面
如果说setup只是把代码重新摆放了位置,那组合式函数(composable)才是组合式API真正的高级形态。它把可复用的业务逻辑抽成一个带状态的函数,命名上约定以use开头,内部使用ref、reactive、computed、生命周期钩子等响应式API,最终返回需要暴露的状态和方法。
前面说的订单列表页,用组合式函数可以抽成这样的结构:
// composables/useOrderList.js import { ref, onMounted, onBeforeUnmount } from 'vue' export function useOrderList() { const list = ref([]) const loading = ref(false) const fetchList = async () => { // ... } onMounted(fetchList) return { list, loading, fetchList } }// composables/useOrderSearch.js import { ref, watch } from 'vue' export function useOrderSearch(fetchList) { const keyword = ref('') const status = ref('') const page = ref(1) watch([keyword, status], () => { page.value = 1 fetchList() }) return { keyword, status, page } }然后在页面组件里组合:
<script setup> import { useOrderList } from './composables/useOrderList' import { useOrderSearch } from './composables/useOrderSearch' import { useTableSelection } from './composables/useTableSelection' const { list, loading, fetchList } = useOrderList() const { keyword, status, page } = useOrderSearch(fetchList) const { selectedRows, handleSelectionChange } = useTableSelection() </script>这样抽的好处是立竿见影的:第一,页面组件只剩“组装”和“展示”的职责;第二,每个useXxx都可以单独测试;第三,如果另一个页面也需要列表+搜索+多选,改改参数直接复用;第四,新成员看代码时只需要逐行读useXxx的实现,不需要理解那个组件庞大的整体。
我个人的经验是,一旦你开始用组合式函数,再回到Options API写页面会有种“手被绑住”的感觉——因为你会清晰地意识到,很多逻辑原本根本不该堆在组件里。
2.3 响应式API选择:ref 还是 reactive 的取舍
这是组合式API最容易被纠结的点,也是面试高频题。ref和reactive都能创建响应式数据,但正确选型可以省掉很多麻烦。
先说我的结论:业务代码里90%的情况优先用ref。原因有几个:
ref对类型的支持天然友好,Ref<T>可以直接推导;ref在<script setup>模板中会自动解包,写法上依然流畅;reactive有一个很隐蔽的坑——解构后响应性丢失。
// reactive 解构丢失响应性的反面教材 const state = reactive({ keyword: '', page: 1, total: 0 }) // 在另一个方法里解构 const { keyword, page } = state keyword.value = 'xxx' // 这不会更新 state.keyword有人会用toRefs来解决解构问题,但这等于给每个字段额外包了一层,反而多了一些心智负担。而ref从一开始就没这个问题:
const keyword = ref('') const page = ref(1) const total = ref(0)当然,reactive也不是一无是处。当你要维护一个结构固定、字段较多的对象(比如表单模型)时,reactive能让代码更像“操作一个普通对象”:
const form = reactive({ username: '', password: '', rememberMe: false, captcha: '' }) // 提交时直接收集 const submit = () => { api.login({ ...form }) }对于这种场景,reactive表达力更强。我的建议是:用一个reactive组织一组强相关的字段,用多个ref表达独立的状态。不要在一个组件里上百个ref平铺,也不要试图用一个巨大的reactive装下整个世界。按功能域拆分后,每个useXxx内部的字段数量自然就控制住了。
3. 从 Options API 迁移到组合式API的实操路线
3.1 最小侵入迁移:setup入口先跑通
很多人想迁移,但被“重构整个组件”这件事吓住了。其实没必要一上来就推倒重来。组合式API和Options API在同一个组件里是可以并存的。Vue 3允许你返回setup的响应式数据和方法,同时继续使用data、methods、computed这些选项。两者通过this共享数据。
所以最稳妥的迁移策略是:在保留原有Options API结构的同时,新写的逻辑一律用setup或组合式函数处理。比如老组件要加一个“批量导出”功能,你可以直接新建一个useExport.js,在组件里调用,完全不碰原来的data和methods。这样既不会破坏现有逻辑,又让新代码走上了正确路线。
当组件里的新逻辑逐渐增多后,你会自然产生“把旧逻辑也搬进去”的冲动。到那个阶段再分步迁移也不迟。
3.2 按功能域抽取composables的具体手法
抽取并不是简单地“把代码挪进函数”,而是有手法可循的。根据我重构多个页面的经验,核心步骤是这几步:
第一步,画功能地图。别急着写码。打开老组件,把页面包含的功能域列出来。比如:购物车列表、优惠券选择、库存校验、金额计算、提交订单、倒计时提醒。每个功能域标注涉及的数据字段、方法、计算属性、watch、生命周期钩子。
第二步,以功能为单位搬运。先挑一个功能域,把它涉及的所有内容整体搬到一个useXxx函数里。这一步的关键是:暂不管这个功能域是否会被复用,先让它“内聚”起来。哪怕只在一个组件里使用,组合式函数也能显著提升可读性。
第三步,显式处理依赖关系。原Options API里功能之间经常通过this隐式互访,抽成函数后必须显式传递。比如useOrderSearch需要触发fetchList,那就作为参数传进去。这种“显式化”的过程,本质上是在帮你理清代码的隐藏耦合。
第四步,整理返回值和类型。组合式函数的返回值要克制。只返回这个功能域对外暴露的状态和方法,内部中间变量不要暴露。如果使用TypeScript,给每个返回参数标注具体类型。
一个典型的功能地图大概长这样:
| 功能域 | 核心状态 | 行为 | 副作用来源 |
|---|---|---|---|
| 搜索 | keyword, status, page | search, reset | watch触发fetchList |
| 列表 | list, loading, total | fetchList | mounted拉取 |
| 多选 | selectedRows, isAllSelected | handleSelectionChange | - |
| 导出 | exporting, exportStatus | handleExport | 手动触发 |
| WebSocket | socket, latestMessage | initSocket, destroySocket | mounted / beforeUnmount |
这张表就是你改造的施工图。
3.3 逐步替换的路线图与优先级
如果你负责的是一个关键业务系统,不可能一次性把所有组件全部改造完。我建议按以下优先级推进:
- 新功能新页面直接用组合式API。这是零成本的一步,从今天就可以开始。
- 高复用逻辑优先抽取。比如权限判断、分页、搜索、表单校验、WebSocket连接,这些跨页面都在用的逻辑,抽成
usePermission、usePagination、useFormValidation、useWebSocket后收益最大。 - 复杂度高的老组件逐步迁移。从最复杂的那个页面开始,先抽功能地图,然后一个功能域一个功能域地搬。
- 简单组件保持不变。一个只有三五个变量、两个方法的展示型组件,用Options API完全没问题,没必要为了“新”而“新”。
另外提一个容易忽略的点:如果你们还在用Vue 2.7,其实不必等到Vue 3就能用上组合式API。Vue 2.7官方正式支持组合式API,组件库层面也有很多兼容方案。这意味着即便老项目暂时不能升级Vue 3,你依然可以在现有代码里逐步引入<script setup>风格和组合式函数,提前为将来升级铺路。
4. 我的真实项目复盘:一个复杂页面的重构前后对比
4.1 重构背景和页面功能清单
为了说清楚,我拿我们团队前阵子重构的一个“对账单管理”页面举例。这个页面在后台管理系统里属于中型偏复杂的页面,功能清单如下:
- 搜索条件:订单号、商户名称、结算状态、时间范围,合计6个查询参数
- 表格列表:服务端分页,默认按时间倒序,支持按金额排序
- 多选:支持跨页多选,顶部显示已选数量
- 批量导出:选中数据导出Excel,带导出进度提示
- 详情抽屉:点击某行弹出详情,详情里包含若干子表
- WebSocket推送:当有新的结算完成时,列表头显示“有更新”角标,点击后刷新列表
重构前这个组件写在单文件里,<template>部分800行,<script>部分650行,<style>部分200行。data里有34个字段,methods里35个方法,computed里13个计算属性,watch里6个监听器。代码的复杂程度已经明显影响排期——每次改动这个页面,前后端联调时间都得额外加半天。
4.2 重构前后的代码组织对比
我们花了两个迭代的时间,把这个页面按功能域拆分成了5个组合式函数:
useSearchForm:6个查询参数 + 重置 + 触发搜索useTable:列表数据 + loading + 分页 + 排序useSelection:跨页多选 + 已选计数useExport:导出状态 + 导出进度 + 触发导出useRealTimePush:WebSocket连接 + 新消息角标 + 销毁
每个useXxx文件大约80到150行,页面主组件的<script>从650行降到了180行左右。这180行里绝大部分是“接线”代码:调用组合式函数,把数据和事件绑定到模板。
对比一下重构前后的差异:
| 维度 | 重构前(Options API) | 重构后(Composition API) |
|---|---|---|
| 主组件script行数 | 650行 | 约180行 |
| 功能间耦合 | 通过this隐式互访 | 显式传参 |
| 新成员理解成本 | 需要通读全组件 | 按composable逐个阅读 |
| 可测试性 | 需要挂载组件 | 可直接测试组合式函数 |
| 逻辑复用 | mixin,冲突风险高 | 组合式函数,无冲突 |
峰值体验来自一次需求变更:产品要求把“跨页多选”从对账单页复制到另一个“退款记录”页面。按以前的做法,得把那一堆选中逻辑连同几十行状态一起复制过去,然后把字段名全部改一遍。这次我们只做了一件事:在退款记录页引入useSelection,传入不同的标识字段,完事。
4.3 踩过的坑和性能实测
迁移不是没有代价的,这里记录几个我们踩过的坑。
坑一:watch监听reactive对象时,旧值不是深拷贝。在Options API里,watch一个对象的习惯是直接监听整个对象。搬到组合式API后,我们用watch(() => state.filter, (newVal, oldVal) => ...),结果发现oldVal和newVal指向同一个引用,对比失效。原因在于组合式API的watch默认不会深拷贝旧值。解决方案是手动浅拷贝:watch(() => ({ ...state.filter }), ...)。这个细节官方文档有提,但实际写的时候很容易忽略。
坑二:ref在reactive对象里会被自动解包。有一次我们把多个ref放进一个reactive对象统一管理,结果发现模板里访问时不需要.value,但脚本里访问又需要.value,来回切换非常容易出错。后来我们统一了规范:组合式函数之间传递状态时,优先传递ref本身,而不是把ref塞进reactive。
坑三:定时器和WebSocket的清理时机。组合式函数里的onMounted注册的定时器,如果不在onBeforeUnmount里清理,组件销毁后依然会运行。我们曾有一个页面路由切换后WebSocket还在后台推送,控制台刷屏。排查后发现是组合式函数里只写了onMounted(initSocket),忘了销毁。现在团队规范明确规定:在组合式函数里注册的任何定时器、事件监听、网络连接,必须在同函数内清理。
性能方面,重构前后这个页面的首屏渲染时间没有明显变化——这也是正常的,组合式API本身不带来性能上的突破性优势。它带来的收益是开发和维护效率,而不是运行时性能。如果有人告诉你“组合式API更快”,那是在误导你。
5. 组合式API的边界与理性建议(2026年视角)
5.1 什么时候可以继续用Options API
这可能是很多人不敢问的问题:难道所有地方都必须用组合式API吗?我的答案是不至于。
有几种情况用Options API完全没有问题:
- 极简单的展示型组件。一个组件里只有两三个
props、一个emit、一段简单的展示逻辑。用Options API能写得很紧凑,用组合式API反而显得“杀鸡用牛刀”。 - 团队里有Vue 2历史包袱的老项目。如果项目还没升级Vue 3,且短期内没有升级计划,在Vue 2里强行写一堆组合式API只会增加额外成本。这时候更现实的路线是用Vue 2.7渐进迁移。
- 配Options API写起来更直观的场景。比如一个数据字典类型的单项选择组件,数据量小、交互简单,
data+methods+computed三件套足够清楚。
这不是“和稀泥”,而是实事求是。组合式API是更强大的工具,但工具的强弱不代表每个场景都必须用它。关键是你要有判断力:这个组件会变复杂吗?这段逻辑会复用到别处吗?团队能接受新的心智模型吗?
5.2 组合式API搭配TypeScript和Pinia的完整姿势
2026年的Vue项目,标准的三件套已经比较清晰:组合式API + TypeScript + Pinia。Pinia本身就是组合式风格的状态管理库,和组合式API配合起来非常顺畅。
这里有个实操建议:Pinia的store也建议用setup语法来写。Pinia支持defineStore传入一个类似setup的函数,也能传入Options风格的配置。我推荐setup语法,因为这个模式下store内部的状态、getter、action完全是组合式API的写法,可以在store里调用其他store,也可以复用业务里的组合式函数,灵活性更强。
import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useUserStore } from './user' export const useOrderStore = defineStore('order', () => { const orders = ref<OrderItem[]>([]) const loading = ref(false) const userStore = useUserStore() const totalAmount = computed(() => orders.value.reduce((sum, item) => sum + item.amount, 0) ) async function fetchOrders() { loading.value = true try { const res = await api.getOrders({ userId: userStore.userId }) orders.value = res.data } finally { loading.value = false } } return { orders, loading, totalAmount, fetchOrders } })这样写的好处是:store内部的逻辑不再被state、getters、actions三个区块割裂,业务内聚原则贯穿到了状态管理层。
TypeScript方面,我特别推荐给组合式函数写清晰的入参和出参类型。不要为了省事把所有函数都写成(params: any) => any。组合式API最大的优势之一就是类型推导,你不利用起来,等于亲手扔掉了这个工具最大的价值。
5.3 团队落地组合式API的规范建议
如果你们团队决定全面转向组合式API,建议提前定好规范,避免每个人风格不同导致代码混乱。
以下几点是经过多个项目验证的规范经验:
- 组合式函数统一放入
composables目录,按模块或领域分子目录,命名统一use开头。 - 组合式函数的返回值尽量以对象形式返回,除非只有单个返回值才直接用
ref。这样调用方可以自由解构,而解构ref对象是安全的。 - 组合式函数的职责边界要清晰。一个
useXxx最好只负责一个功能域。如果一个函数超过200行,考虑拆成更小的函数。 - 副作用必须自带清理逻辑。定时器、事件监听、WebSocket、IntersectionObserver等都在函数内部注册时一并注册清理逻辑,组件卸载时自动执行。
- UI状态与业务状态分离。像
isLoading、isModalVisible这类纯UI状态可以保留在组件内部,不需要都抽出去;而像订单列表、用户信息、权限集合这类业务状态才值得放进组合式函数或Pinia。 - 代码评审时重点看响应式边界。比如是否有人把
reactive对象当作普通对象解构使用,是否有人在组合式函数外修改了函数内部创建的ref,这些都是容易产生隐蔽bug的地方。
这些规范不是僵硬的规定,而是为了减少团队成员之间的认知差异。组合式API给了开发者极大的自由,但这个自由也需要边界才能形成稳定生产力。
最后再说一点实际的体会。我见过有人把组合式API当成“新玩具”,不管什么组件都硬拆成七八个composable,结果代码比原来还难读。组合式API的核心不是“把文件拆小”,而是“以业务功能为单位组织代码”。当你发现自己写代码时不再需要记住“这个字段在data里、那个方法在methods里”,而是能顺着一个功能的完整链路一路读下去,那才是真正掌握了它的正确姿势。
如果现在你手里有个写着费劲的老组件,给它画一张功能地图,挑一个最独立的功能域抽成useXxx,跑通之后你就能直观体会到差别。别等整个项目统一升级了再动手,从下一个需求、下一个页面开始,用组合式API写出来,上手这件事真的没你想象中那么难。