2026年还在写一堆methods、computed、watch各占一区的Options API代码,说实话有点说不过去了。我自己从Vue 2时代一路写过来,刚开始接触组合式API(Composition API)时也觉得"这不就是换了个写法吗",直到在一个中大型后台项目里彻底重构了一遍之后,才真正体会到组合式API不是换个语法糖,而是从代码组织方式、复用模式到心智模型的全面升级。这篇文章不聊虚的,直接从我实际项目的重构经历出发,讲清楚为什么组合式API才是2026年写Vue的正确姿势,以及你现在就可以开始动手落到代码里的细节。
1. 先说清楚:Options API到底"错"在哪
1.1 业务逻辑被"切碎"成三段,这是最疼的
很多从Vue 2时代过来的朋友,第一次学Vue的时候接触的都是Options API:data里放数据、methods里放方法、computed里放计算属性、watch里放侦听器。这种按选项类型划分代码的方式,在小组件里面完全没问题,甚至可以说直观得一目了然。但是当组件超过200行、300行、500行的时候,问题就来了——一个业务功能的数据、方法、计算属性和侦听器被硬生生拆散在四个不同的区块里。
我举一个真实例子。之前做一个电商后台的订单列表页,里面有搜索、筛选、分页、导出、批量操作、状态联动这几个模块。在Options API里,这些模块的数据全堆在data里,方法全堆在methods里,computed里还混着好几个模块的计算逻辑,watch里更是各种状态互相监听。改一个订单状态的功能,你得在data里找到订单状态字段、在methods里找到修改状态的方法、在computed里找到依赖状态的计算属性、再在watch里找到跟状态相关的监听逻辑——一次简单的改动,要在四个区块之间来回跳转。这不是代码风格问题,这是实实在在的生产力损耗。
1.2 逻辑复用全靠mixins,埋了一堆"隐性地雷"
Options API时代,官方推荐的逻辑复用方案是mixins。我在项目里踩过太多mixins的坑了。最典型的一个问题是命名冲突:项目里有两个mixins,一个定义了initData方法,另一个也定义了同名方法,后引入的会静默覆盖先引入的,排查的时候极其痛苦。还有一个问题是数据来源不透明:组件里某个字段是data里自己定义的,还是从某个mixin混进来的,不点开mixin源码根本不知道。项目一大了,组件和mixins之间的关系就像一张蜘蛛网,看似复用了代码,实际是给自己埋了一堆地雷。
1.3 TypeScript支持是"附加分"而不是"原生能力"
2026年,TypeScript在Vue项目里已经算是标配了。Options API的问题在于,this上的属性是通过类型合并推导出来的,在复杂组件里,编辑器的类型提示经常跟不上,甚至出现this.xxx报错但运行正常的情况。而写类型定义的时候也很别扭——一个方法的参数类型、返回类型都要在选项对象里硬写,跟JavaScript的写法差异很大。说到底,Options API是为JavaScript时代设计的API形态,TypeScript只是"能用",谈不上"好用"。
2. 组合式API的核心设计哲学:逻辑聚合与显式数据流
2.1 从"按选项分类"到"按业务领域聚合"
组合式API最核心的思维转变,是把"按选项类型划分代码"变成了"按业务领域聚合代码"。同一个业务功能的数据、方法、计算属性、侦听器,全部放在一起写。这样代码的结构就跟业务的边界对齐了,而不是跟框架的语法对齐。
我打个比方。Options API就像你把自己的工具按类型收纳:所有螺丝钉放一个抽屉,所有扳手放一个抽屉,所有电钻放一个抽屉。但如果你的工作是组装一台柜子,你得不停地在三个抽屉之间来回拿东西。而组合式API就像按项目收纳:组装柜子的所有工具放一个箱子,组装桌子的工具放另一个箱子。哪个项目需要用到什么,打开对应的箱子就行。后者当然更贴合真实的工作场景。
2.2 逻辑复用从mixins升级为composables
组合式API把逻辑复用的方案从mixins升级成了自定义组合函数(composables)。一个composable就是一个普通的函数,内部可以用ref、computed、watch这些API组织逻辑,然后return出需要暴露给组件的东西。这种模式的好处是:
- 没有隐式的数据来源,所有数据都是显式传入和返回的
- 不会发生命名冲突,函数内部的状态是封闭的
- TypeScript类型推导非常自然,参数和返回值的类型一目了然
- 可以自由组合,多个composable之间互不干扰
2.3 数据流是显式的,不是"魔法"的
在Options API里,this.xxx的数据流向是很隐式的——它来自data、props、computed还是methods,你得靠记忆。而在组合式API里,你在setup函数中显式定义每个响应式数据,显式声明每个函数,显式return给模板使用。数据从哪里来、经过什么转换、到哪里去,整个链路在代码里就能看明白。这种显式性在团队协作和代码评审中特别有价值——review代码的时候不需要跳来跳去猜了。
3. 实战对比:同一个功能,Options API和组合式API分别怎么写
3.1 需求描述:一个带搜索和分页的用户列表
为了让大家直观感受两者的差异,我用一个真实项目里很常见的场景来做对比:一个用户列表页,需要支持搜索、分页、加载状态,还有一个"搜索条件变化时重置到第一页"的逻辑。以下是Options API版本:
<template> <div> <input v-model="keyword" placeholder="搜索用户" /> <button @click="handleSearch">搜索</button> <table> <tr v-for="item in filteredList" :key="item.id"> <td>{{ item.name }}</td> <td>{{ item.email }}</td> </tr> </table> <button @click="handlePrev" :disabled="page <= 1">上一页</button> <span>{{ page }} / {{ totalPage }}</span> <button @click="handleNext" :disabled="page >= totalPage">下一页</button> <p v-if="loading">加载中...</p> </div> </template> <script> export default { data() { return { keyword: '', rawList: [], page: 1, pageSize: 10, loading: false, }; }, computed: { filteredList() { let list = this.rawList; if (this.keyword.trim()) { list = list.filter((item) => item.name.toLowerCase().includes(this.keyword.toLowerCase()) ); } const start = (this.page - 1) * this.pageSize; return list.slice(start, start + this.pageSize); }, totalPage() { return Math.max( 1, Math.ceil( this.filteredList.length / this.pageSize ) ); }, }, watch: { keyword() { this.page = 1; }, }, methods: { async fetchList() { this.loading = true; // 模拟接口请求 this.rawList = await new Promise((resolve) => { setTimeout(() => { resolve([ { id: 1, name: '张三', email: 'zhangsan@example.com' }, { id: 2, name: '李四', email: 'lisi@example.com' }, // 更多数据... ]); }, 500); }); this.loading = false; }, handleSearch() { this.fetchList(); }, handlePrev() { if (this.page > 1) this.page--; }, handleNext() { if (this.page < this.totalPage) this.page++; }, }, mounted() { this.fetchList(); }, }; </script>这段代码功能上没问题,但是业务逻辑的分布在四个区块中:keyword、page、rawList这些字段在data里;filteredList和totalPage在computed里;fetchList和handleSearch在methods里;keyword的监听逻辑在watch里。实际上,这里至少有两个独立的业务模块——"搜索过滤"和"分页管理"——它们的代码是互相穿插的。
3.2 组合式API版本:逐行拆解
再看看组合式API下同一个功能怎么写:
<template> <div> <input v-model="keyword" placeholder="搜索用户" /> <button @click="fetchList">搜索</button> <table> <tr v-for="item in paginatedList" :key="item.id"> <td>{{ item.name }}</td> <td>{{ item.email }}</td> </tr> </table> <button @click="prevPage" :disabled="page <= 1">上一页</button> <span>{{ page }} / {{ totalPage }}</span> <button @click="nextPage" :disabled="page >= totalPage">下一页</button> <p v-if="loading">加载中...</p> </div> </template> <script setup> import { ref, computed, watch, onMounted } from 'vue'; // 搜索模块 const keyword = ref(''); const rawList = ref([]); const loading = ref(false); const filteredList = computed(() => { if (!keyword.value.trim()) return rawList.value; const kw = keyword.value.toLowerCase(); return rawList.value.filter((item) => item.name.toLowerCase().includes(kw) ); }); watch(keyword, () => { page.value = 1; }); async function fetchList() { loading.value = true; rawList.value = await new Promise((resolve) => { setTimeout(() => { resolve([ { id: 1, name: '张三', email: 'zhangsan@example.com' }, { id: 2, name: '李四', email: 'lisi@example.com' }, // 更多数据... ]); }, 500); }); loading.value = false; } onMounted(fetchList); // 分页模块 const page = ref(1); const pageSize = ref(10); const paginatedList = computed(() => { const start = (page.value - 1) * pageSize.value; return filteredList.value.slice(start, start + pageSize.value); }); const totalPage = computed(() => Math.max(1, Math.ceil(filteredList.value.length / pageSize.value)) ); function prevPage() { if (page.value > 1) page.value--; } function nextPage() { if (page.value < totalPage.value) nextPage; } </script>这段代码最大的变化是:搜索模块和分页模块的逻辑在物理位置上分开了,各有各的数据、计算属性和方法。如果你以后要单独把分页逻辑抽出去给别的组件用,直接把这十几行代码剪贴到一个usePagination函数里就行,几乎不用改逻辑。
3.3 为什么说组合式API更利于"读"和"改"
有人可能会说,这不就是"重新排了一下代码顺序"吗?功能不是一样的吗?表面上看确实如此,但在实际编码过程中,这个"重新排序"的价值非常大。以我的经验来看,代码的阅读时间一般是编写时间的三到五倍。在Options API里读一个功能,你要同时关注四个区块,大脑需要建立一张隐性的映射表。而在组合式API里读一个功能,只需要顺着从上到下读一遍就行——这个模块的数据、逻辑、界面反馈全在这一块区域里。
对于后续改动,组合式API的优势更明显。比如项目里后来又要加一个"按用户角色筛选"的条件。在Options API里,你得在data里加一个roleFilter字段、在methods里调整筛选逻辑、在computed里修改filteredList的依赖、可能还要在watch里加一条监听。来回折腾四个地方。而在组合式API里,大概率只需要在过滤逻辑那里加几行代码就够了。
4. 现在开始实操:Options API迁移到组合式API的关键路径
4.1 迁移第一步:先搭好Vue环境,确认版本
如果你还没有把项目升级到Vue 3,那组合式API暂时是体验不到的。检查一下你的项目的package.json,确认Vue版本。如果还在Vue 2,就需要先考虑升级。Vue 3的环境搭建其实很简单,我一般直接用官方推荐的构建工具:
npm create vue@latest这条命令会引导你创建一个基于Vite的Vue 3项目,默认就支持<script setup>语法。如果是已有项目升级,我建议分两步走:先把构建工具链(Vite或者Webpack的Vue 3插件)升级到位,再逐个组件迁移。Vue 3的Options API是兼容Vue 2的写法的,所以即使你暂时不想用组合式API,升级到Vue 3后你的代码依然能跑——这给了我们很大的缓冲空间。
4.2 写第一个composable:把独立逻辑抽出来
我的迁移经验是:不要一上来就大改特改,找一个逻辑相对独立的组件开始,把它的一块业务逻辑抽成composable。拿用户列表页举例,可以写一个useUserList:
import { ref, computed, onMounted } from 'vue'; export function useUserList() { const keyword = ref(''); const rawList = ref([]); const loading = ref(false); const page = ref(1); const pageSize = ref(10); const filteredList = computed(() => { if (!keyword.value.trim()) return rawList.value; const kw = keyword.value.toLowerCase(); return rawList.value.filter((item) => item.name.toLowerCase().includes(kw) ); }); const paginatedList = computed(() => { const start = (page.value - 1) * pageSize.value; return filteredList.value.slice(start, start + pageSize.value); }); const totalPage = computed(() => Math.max(1, Math.ceil(filteredList.value.length / pageSize.value)) ); async function fetchList() { loading.value = true; // 模拟接口请求 rawList.value = await new Promise((resolve) => { setTimeout(() => { resolve([ { id: 1, name: '张三', email: 'zhangsan@example.com' }, { id: 2, name: '李四', email: 'lisi@example.com' }, ]); }, 500); }); loading.value = false; } function prevPage() { if (page.value > 1) page.value--; } function nextPage() { if (page.value < totalPage.value) page.value++; } onMounted(fetchList); return { keyword, loading, paginatedList, totalPage, page, fetchList, prevPage, nextPage, }; }然后在组件里使用:
<script setup> import { useUserList } from '@/composables/useUserList'; const { keyword, loading, paginatedList, totalPage, page, fetchList, prevPage, nextPage, } = useUserList(); </script>模板部分几乎不用改。关键的是,useUserList这个函数变成了一个可复用的模块。以后项目里任何一个页面需要"列表+搜索+分页"的能力,直接import这个函数就行了,完全是"复制粘贴自己的代码"的升级版。
4.3 生命周期和this的替代方案:setup上下文
很多人在迁移时最不习惯的是生命周期钩子和this的差异。在<script setup>里,你是拿不到this的——因为setup函数根本不在组件实例上执行。但绝大多数原本依赖this.xxx的场景,都可以通过在setup中直接使用响应式变量和函数来解决。
生命周期钩子的变化很简单:mounted变成了onMounted,created直接不需要了,因为setup本身就是"在实例创建过程中执行"。beforeUnmount对应onBeforeUnmount,unmounted对应onUnmounted。还有watch变成了可单独导入的函数,computed也变成了可单独导入的API。
那this.$router、this.$route、this.$store怎么办?Vue 3里用useRouter()、useRoute()、useStore()(如果是Pinia则是useXxxStore())替换。这些都是组合式API里的辅助函数,用法上其实就是"从一个全局的上下文里取东西",比之前的this.$xxx更直观,也更利于TypeScript类型推导。
4.4ref和reactive怎么选:一条踩出来的经验
组合式API里有两个创建响应式数据的基础API:ref和reactive。很多新手在这里会纠结,我一开始也纠结了很久。实际经验是:
ref适用于所有基本类型和需要整体替换的场景,比如keyword是字符串,page是数字,用ref最合适。操作的时候要通过.value访问。reactive适用于对象、数组这类嵌套数据,且你经常要操作内部字段的场景。不过reactive在解构时会丢失响应式,所以如果你要解构出来用,最好还是用ref。- 我个人的习惯是:默认都用
ref,除非遇到一个明确的、内部字段经常被单独修改的深层对象才用reactive。ref有个好处是,强制你通过.value来访问,某种程度上也在提醒你"这是一个响应式变量",心智负担反而更小。
补充一个2026年很实用的经验:如果是在列表类数据上,ref([])在替换整个数组时非常方便——直接list.value = newArray就能触发更新。而如果用reactive管理数组,替换整个数组时要小心,直接赋值可能不会触发响应式更新,需要用splice或者整体替换的方式。这个坑我踩过不止一次。
5. 组合式API实战:路由、视频播放、状态管理多场景应用
5.1 组合式API + Vue Router:把路由逻辑也"组合"起来
Vue Router 4在Vue 3里已经是一个标准的组合式API风格的库了。以前在Options API里获取路由参数是这样:
this.$route.params.id在组合式API里是这样:
<script setup> import { useRoute, useRouter } from 'vue-router'; import { watch } from 'vue'; const route = useRoute(); const router = useRouter(); const id = route.params.id; // 监听路由参数变化,重新拉取数据 watch( () => route.params.id, (newId) => { fetchDetail(newId); } ); function goBack() { router.back(); } </script>一个跟路由相关的业务逻辑(参数解析、参数变化监听、跳转操作)在组合式API下天然地聚合在了一起。而且如果你有多个页面都需要"根据路由参数拉取数据"的逻辑,可以直接抽一个useRouteDetail出来。这比在Options API里每个组件都重复写一遍watch+mounted要优雅得多。
5.2 组合式API + 视频播放:封装一个m3u8播放器逻辑
这几年我在项目里经常遇到视频播放的需求,尤其是m3u8流媒体格式。很多朋友一上来就引入video.js这种大而全的库,其实完全可以用组合式API把这套逻辑封装成自己的composable。
先说一个简单的背景:m3u8是基于HTTP Live Streaming协议的视频流格式,浏览器原生<video>标签不支持直接播放,需要在JavaScript层做一层转换。社区常用hls.js来处理。组合式API很适合做这种"有状态、有生命周期、有副作用"的封装:
// useHlsPlayer.js import { ref, onMounted, onBeforeUnmount, watch } from 'vue'; import Hls from 'hls.js'; export function useHlsPlayer(videoElement, src) { const playing = ref(false); const error = ref(null); let hls = null; async function init() { if (!videoElement.value || !src.value) return; // 如果浏览器原生支持HLS(比如Safari),直接用原生播放 if (videoElement.value.canPlayType('application/vnd.apple.mpegurl')) { videoElement.value.src = src.value; } else if (Hls.isSupported()) { hls = new Hls(); hls.loadSource(src.value); hls.attachMedia(videoElement.value); hls.on(Hls.Events.ERROR, (_, data) => { error.value = data; }); } else { error.value = new Error('当前环境不支持HLS播放'); } } watch(src, () => { if (hls) { hls.destroy(); hls = null; } init(); }); onMounted(init); onBeforeUnmount(() => { if (hls) { hls.destroy(); hls = null; } }); return { playing, error }; }这个composable封装了m3u8播放的全部核心逻辑:初始化、兼容性判断、错误捕获、资源切换、销毁释放。使用它的组件代码非常干净:
<template> <div> <video ref="videoRef" controls></video> <p v-if="error">{{ error.message }}</p> </div> </template> <script setup> import { ref } from 'vue'; import { useHlsPlayer } from '@/composables/useHlsPlayer'; const videoRef = ref(null); const src = ref('https://example.com/path/to/playlist.m3u8'); const { playing, error } = useHlsPlayer(videoRef, src); </script>这里有个细节值得注意:videoRef和src都用了ref,这样在composable内部就可以用watch来响应它们的后续变化——比如用户切换了视频源,播放器会自动销毁旧的Hls实例,重新初始化新的实例。这比在Options API里手动调用播放器实例的方法要简洁可靠得多。
5.3 组合式API + 状态管理:Pinia是天然搭档
回到2026年,Vue社区的状态管理方案已经基本统一到Pinia了。Pinia本身就是围绕组合式API设计的,它的store定义方式跟composable几乎没有区别:
// stores/user.js import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; export const useUserStore = defineStore('user', () => { const name = ref(''); const role = ref('guest'); const isAdmin = computed(() => role.value === 'admin'); async function login(username) { name.value = username; role.value = 'admin'; } function logout() { name.value = ''; role.value = 'guest'; } return { name, role, isAdmin, login, logout }; });在组件中使用时:
<script setup> import { useUserStore } from '@/stores/user'; const userStore = useUserStore(); </script>注意Pinia store的响应式数据在解构后会丢失响应式,所以必须用storeToRefs来解构才能保持响应式:
import { storeToRefs } from 'pinia'; const { name, role } = storeToRefs(userStore);这是我在实际项目里踩过的坑之一。刚开始以为store解构出来就能用,结果发现UI不更新,查了半天才发现是解构导致响应式丢失。用storeToRefs之后就好了。
5.4 面试题视角:组合式API的高频考点
这些年帮团队面了不少前端候选人,发现组合式API已经成了Vue面试必问的方向。常见的几个高频题目,我建议你在学的时候就要有意识地准备:
- "Options API和组合式API的区别是什么?"核心考点是逻辑聚合、复用方式、类型推导这三个维度,而不是背定义。能结合一个具体的逻辑复杂组件说明就更好了。
- "ref和reactive怎么选?"面试官真正想听的是你对响应式原理的理解,而不只是使用偏好。能说出ref基于
value的包装、reactive基于Proxy、以及解构后响应式丢失的原因,基本都是加分项。 - "composable的设计原则有哪些?"这里可以聊聊单一职责、命名以
use开头、内部状态对外隔离、返回对象避免过度解构等。能结合自己项目里抽过的composable来谈,会比纯理论加分很多。
6. 组合式API的坑和排查技巧:真实踩过的路
6.1ref和reactive解构后响应式丢失
这个问题前面提过,但我想再展开一下,因为它太常见了。很多从Options API转过来的人,会习惯性地写:
const state = reactive({ name: '', age: 0 }); const { name, age } = state; // 丢掉响应式然后发现模板里改变name页面不更新。原因很简单:reactive返回的是Proxy对象,解构出来的是原始值的拷贝,不是Proxy代理。解决办法是用toRefs把reactive对象里的每个属性变成ref:
import { reactive, toRefs } from 'vue'; const state = reactive({ name: '', age: 0 }); const { name, age } = toRefs(state);这样name和age都是ref了,在模板里自动解包,在JavaScript里需要通过.value访问。我用下来的建议是:如果要在setup里解构,就直接用ref定义;如果不解构,整个对象用reactive也行。千万不要在解构和不解构之间反复横跳,代码风格要统一。
6.2watch的坑:监听ref对象和reactive对象的方式不一样
watch监听ref的时候,直接传ref变量就行:
const keyword = ref(''); watch(keyword, (newVal, oldVal) => { console.log('keyword changed:', newVal, oldVal); });监听reactive对象的某个属性时,要传一个getter函数:
const state = reactive({ keyword: '' }); watch( () => state.keyword, (newVal, oldVal) => { console.log('keyword changed:', newVal, oldVal); } );这个区别看起来不大,但是如果你把reactive对象的属性直接传给watch,它是不会正常触发的。另外,watch默认是懒执行的,不会立即运行回调。如果你需要"进入页面时先执行一次回调",要加immediate: true。这是我在项目中经常用到的一个小技巧。
注意:
watchEffect和watch的区别也要搞清楚。watchEffect会自动追踪回调里用到的所有响应式依赖,依赖变化时立即重新执行,且默认立即执行一次。适合"根据多个状态做副作用"的场景。而watch适合"监听某一个具体状态变化再做操作"的场景。我一般70%的场景用watch,30%用watchEffect。
6.3<script setup>中组件自引用和递归组件的坑
在<script setup>里,组件默认是局部注册的,不需要再在components选项里注册了——这是好事,省了很多样板代码。但有一个坑:如果组件要递归引用自己,直接在模板里写组件名是可以的,因为Vue会自动把文件名推断成组件名。如果你在项目里开了ESLint的vue/multi-word-component-names规则,可能单文件组件名只能有一个词的情况下会报错,需要调整规则或者重命名。
6.4 组合式函数中"副作用清理"必须养成习惯
Composable里面如果注册了全局事件监听、定时器、WebSocket链接等副作用,一定要在组件卸载时清理。Options API时代你在beforeDestroy里手动清,组合式API里对应的是onBeforeUnmount。但更优雅的方式是直接在composable内部用onScopeDispose——这个API能在组合式函数的作用域被销毁时自动调用清理逻辑,不需要外部组件显式去处理。看一个例子:
// useCountdown.js import { ref, onScopeDispose } from 'vue'; export function useCountdown(initial = 60) { const count = ref(initial); const timer = setInterval(() => { if (count.value > 0) count.value--; }, 1000); onScopeDispose(() => { clearInterval(timer); }); return { count }; }这样用useCountdown的组件完全不需要关心清理逻辑,组件卸载时定时器自动被清除。这是组合式API里一个非常实用但很多人没注意到的API。
6.5 排查"组合式API里模板渲染不更新"的通用思路
如果遇到"数据变了但模板不更新"的情况,按这个顺序排查:
- 确认变量是
ref或reactive创建的,而不是普通变量。 - 确认在
<script setup>中通过return(或者在setup函数中)暴露给了模板。 - 检查是否对
reactive对象进行了解构,解构后响应式丢失。 - 检查是否有非响应式的外部数据混入了响应式数据。
- 最后检查是否在同一事件循环中同步修改了多个响应式变量,有时需要
nextTick配合。
7. 给还在观望的朋友:组合式API是趋势也是基本功
最后再说一点个人体会。我见过很多开发者在Vue 3发布了好几年之后依然坚持用Options API,理由通常是"习惯了""项目已经这样了""不想折腾"。我完全理解这种心态,因为我自己也经历过。但2026年这个时间节点上,我的建议是:
如果你还在写Vue,组合式API不是"新技能",而是基本功。原因有三:第一,Vue 3生态里越来越多的库和工具默认使用组合式API,你不熟悉就看不懂别人的代码。第二,团队协作中,组合式API的代码结构更统一,逻辑边界更清晰,code review成本明显低。第三,从职业发展的角度,面试几乎必问组合式API,已掌握和能熟练讲解是两个不同的层次。
从一个实际的迁移路径来看,你不必一次性把整个项目推翻重写。可以先从新功能开始,新写的组件一律用<script setup>;然后挑一两个复杂度适中的老组件,把独立的逻辑抽成composable。这样慢慢过渡,你会发现自己回过头来再也不想用Options API写新组件了。代码是给人读的,而组合式API让代码的组织方式更贴合人的思维习惯,这大概就是它被称为"正确姿势"的原因。