Vue 3 发布到现在,Options API和Composition API的争论就没停过。你在技术群里问一句"新项目应该用哪个",下面一定分成两派吵半天:老手会说 Composition API 才是 Vue 3 的灵魂,新手翻着文档一脸懵——我明明用 Options API 写得好好的,为什么要改?这背后其实不光是写法变了,而是组织代码的思维方式变了。这篇文章我会把这套东西讲透:两套 API 各自的定位、设计哲学、实际写起来有什么差别、什么时候该用哪个,以及我这两年从 Vue 2 老项目一路迁移过来踩过的一堆坑。适合刚接触 Vue 3、正在纠结选型的朋友,也适合团队里需要统一代码规范的人。
1. 先弄清楚:为什么会有两套 API 风格
1.1 Options API 是怎么来的,它擅长什么
Options API 其实就是 Vue 2 时代大家每天都在写的那套东西:一个组件就是一个对象,里面按照data、computed、methods、watch、生命周期钩子等固定选项分开写。Vue 在内部帮你把这些选项组合成一个组件实例,再通过this把数据和方法暴露给模板和逻辑。
这套设计在 Vue 2 时代非常成功,尤其是对新手极其友好。它的心智模型很简单:数据放在data里,函数放在methods里,需要根据数据变化做点什么就写watch,页面初始化要干啥就写created。刚入门前端的人,看着文档照葫芦画瓢都能写出能跑的小组件。这也是为什么直到今天,很多公司里的老项目依然是清一色的 Options API。
但用过一段时间后你会发现一个问题:当组件变复杂的时候,代码会"散"得到处都是。举个例子,一个购物车组件,它可能涉及商品列表、优惠计算、库存校验、提交订单这好几块业务。按 Options 的写法,这些业务的数据全混在data里,方法全混在methods里,watch 也堆在一起。你想改某个业务逻辑,就得在文件里来回跳:先滚到 data 看字段,再滚到 methods 找函数,再滚到 watch 看监听逻辑。一个组件四五百行的时候,这种"按选项切分"的组织方式会让人非常难受。
1.2 Composition API 到底解决了什么痛点
Composition API 是 Vue 3 推出的新写法,核心是提供了一个setup函数(还有更方便的<script setup>语法糖),让你不再按选项的类型去组织代码,而是按"业务逻辑"去组织代码。
它解决的第一个痛点是代码的内聚性。还是购物车那个例子,如果用 Composition API,你可以把商品列表相关的数据、计算属性、方法、监听逻辑放在一起,库存相关的放另一块,提交订单相关的放第三块。每一块都是完整自洽的,改某个功能基本不需要上下乱滚。
第二个痛点是逻辑复用。Vue 2 里复用逻辑主要靠 mixin,但 mixin 的缺点大家都知道:命名冲突、数据来源不清晰、多个 mixin 之间互相覆盖。Composition API 让你可以把一段逻辑直接封装成一个普通函数,在组件里按需调用,就像 React 的自定义 Hook 一样,干净、可追踪。
需要强调的是,Composition API 并不是要取代 Options API,官方也一直强调两者可以共存。一个组件里你完全可以打开setup的同时继续用data和methods,Vue 3 会帮你合并。它只是提供了一种更灵活的表达方式,让代码组织不再是"一刀切"。
2. 核心差异拆解:从组织代码的方式到心智模型
2.1 代码组织维度:按选项分类 vs 按逻辑聚合
Options API 的组织方式是"分类归档",Composition API 的组织方式是"按业务聚合"。我用一个生活化的例子打个比方。
想象你要修一个漏水的水管。Options 方式就像把所有工具按类型放在不同抽屉里:螺丝刀在一个抽屉,扳手在另一个抽屉,生料带在第三个抽屉。你要修水管,得来回从这个抽屉拿螺丝刀、从那个抽屉拿扳手。而 Composition 方式是每个业务场景放一个工具箱:修水管的工具箱里同时装着螺丝刀、扳手、生料带,修电器的工具箱又是另一套。代码组织也一样,Options 强制你把一个业务逻辑拆成多份扔进不同的选项区,Composition 则允许你把它们聚在一起。
这种差异在中大型组件里体现得特别明显。我以前接手过一个 Vue 2 的报表组件,三百多行的data里同时包含了筛选条件、表格数据、图表配置、导出状态,methods里十来个方法互相调用。想搞明白其中一个流程,得在几十行 data、几十行 methods、几个 watch 之间反复横跳。用 Composition API 重构之后,每个功能群都有自己独立的一个代码块,读代码的人可以顺着业务线索一口气看完,不用再"跨区搜索"。
2.2 响应式心智模型:this 代理 vs 显式声明
Options API 里,你在data中声明的字段会被 Vue 自动挂到组件实例上,然后在模板和methods里都是通过this.xxx去访问。这套机制对新手很友好,但也带来一个隐藏成本:你不太清楚数据到底是怎么变成响应式的,也常常会遇到this指向的问题,比如在回调函数里忘了提前const self = this。
Composition API 把这个过程变得显式了。你用的是ref和reactive来显式创建响应式数据,在<script setup>里,ref声明的变量在模板中会自动解包,你直接写{{ keyword }}就行,但在逻辑代码里必须写成keyword.value。这个.value是很多新手刚接触时最不习惯的地方,总觉得多此一举。但它的好处是:JavaScript 本身对基本类型是无法做响应式追踪的,Vue 2 是通过Object.defineProperty劫持属性实现的,而ref内部会给基本类型包一层对象,这层包裹让响应式追踪变得明确和可控。
reactive则适合对象类型,不需要.value,直接state.xxx访问,但注意它只能用于对象、数组这类引用类型,不能直接用于基本类型。而且reactive有个大坑:如果你整体重新赋值,或者解构了它,响应式连接就断了。这块我后面专门讲。
2.3 逻辑复用方式:Mixin 的坑 vs 组合式函数
Vue 2 里做逻辑复用最常用的是 mixin。比如你有两个组件都要做滚动加载,就把滚动监听的逻辑写进一个 mixin,然后mixins: [scrollMixin]引入。听起来挺方便,但实际用起来问题很多。
第一个问题是命名冲突。两个 mixin 都定义了initData方法,后者会把前者覆盖,而且 Vue 不会给你任何警告。第二个问题是来源不清晰。我在模板里看到loadMore这个方法,得去查它到底是组件自己定义的、mixin 里的,还是全局混入的,排错成本很高。第三个问题是作用域共享。mixin 里的数据和方法在多个组件里是各自独立的,但如果你不加约束,很容易写出互相依赖的隐性逻辑。
Composition API 提供了一种更优雅的复用方式——组合式函数,也就是你写一个普通的 JavaScript 函数,内部可以使用ref、computed、watch、生命周期钩子等所有 Vue 能力,然后把需要暴露的数据和方法 return 出去。组件里调用这个函数就像调用普通函数一样,每一份数据都是独立的,来源一目了然,还能传参。这才是 Composition API 最大的红利,比"换一种代码排版"意义大得多。
3. 实操对比:同一个搜索列表,两套写法
3.1 Options API 版本实现
光讲概念不够,我写一个非常常见的小功能来对比:带防抖的搜索框,输入关键词后请求接口,渲染列表,要有加载状态和错误提示。先看 Options 版本。
<template> <div> <input v-model="keyword" placeholder="请输入关键词" @input="onInput" /> <ul> <li v-for="item in list" :key="item.id">{{ item.name }}</li> </ul> <p v-if="loading">加载中…</p> <p v-if="error">{{ error }}</p> </div> </template> <script> import { fetchSearchResults } from '@/api/search' export default { name: 'SearchList', data() { return { keyword: '', list: [], loading: false, error: '', timer: null } }, computed: { hasResults() { return this.list.length > 0 } }, beforeUnmount() { clearTimeout(this.timer) }, methods: { onInput() { clearTimeout(this.timer) this.timer = setTimeout(() => { this.fetchResults() }, 300) }, async fetchResults() { if (!this.keyword) { this.list = [] return } this.loading = true this.error = '' try { const data = await fetchSearchResults(this.keyword) this.list = data } catch (e) { this.error = '请求失败,请重试' } finally { this.loading = false } } } } </script>这个组件不算复杂,但已经能看出来问题:keyword、list、loading、error这几个字段在data里,timer也在data里,逻辑方法都在methods里。如果以后要加一个"清空搜索历史"的功能,又得往 data 里加字段、往 methods 里加方法。这个组件变大的路径是可以预见的——最终会变成一堆字段和方法混在一起的大杂烩。
3.2 Composition API 版本实现(含 script setup)
同样的功能,用<script setup>写是这个样子。
<template> <div> <input v-model="keyword" placeholder="请输入关键词" @input="onInput" /> <ul> <li v-for="item in list" :key="item.id">{{ item.name }}</li> </ul> <p v-if="loading">加载中…</p> <p v-if="error">{{ error }}</p> </div> </template> <script setup> import { ref, computed, onBeforeUnmount } from 'vue' import { fetchSearchResults } from '@/api/search' const keyword = ref('') const list = ref([]) const loading = ref(false) const error = ref('') const hasResults = computed(() => list.value.length > 0) let timer = null async function fetchResults() { if (!keyword.value) { list.value = [] return } loading.value = true error.value = '' try { const data = await fetchSearchResults(keyword.value) list.value = data } catch (e) { error.value = '请求失败,请重试' } finally { loading.value = false } } function onInput() { clearTimeout(timer) timer = setTimeout(() => { fetchResults() }, 300) } onBeforeUnmount(() => { clearTimeout(timer) }) </script>表面上看起来代码量差不多,但注意几个细节。第一,没有了this,所有变量和函数都是普通的 JavaScript 变量和函数,类型推导会非常顺畅,IDE 里能直接跳转到定义。第二,响应式数据都是显式用ref创建,哪些是响应式的一眼就能看出来。第三,代码块的顺序完全由你自己控制——你可以先写关键词相关的逻辑,再写列表请求逻辑,不必被固定的"选项"顺序框住。
3.3 进一步:把逻辑抽成可复用的组合式函数
如果要复用到两个页面,Options 版本要么复制粘贴,要么上 mixin。Composition 版本可以直接把整段逻辑抽成一个useSearchList函数,跨组件复用。
// composables/useSearchList.js import { ref, onBeforeUnmount } from 'vue' import { fetchSearchResults } from '@/api/search' export function useSearchList(keyword = ref('')) { const list = ref([]) const loading = ref(false) const error = ref('') let timer = null async function fetchResults() { if (!keyword.value) { list.value = [] return } loading.value = true error.value = '' try { const data = await fetchSearchResults(keyword.value) list.value = data } catch (e) { error.value = '请求失败,请重试' } finally { loading.value = false } } function onInput() { clearTimeout(timer) timer = setTimeout(() => { fetchResults() }, 300) } onBeforeUnmount(() => { clearTimeout(timer) }) return { list, loading, error, onInput } }组件里就只剩几行:
<script setup> import { ref } from 'vue' import { useSearchList } from '@/composables/useSearchList' const keyword = ref('') const { list, loading, error, onInput } = useSearchList(keyword) </script>这就是我前面说的核心差异:逻辑复用不再是"配置式"的 mixin,而是"函数式"的组合。函数来源清晰、参数明确、返回值可见,想调试还是想修改都容易得多。
3.4 两份代码的维护体验盘点
从维护者的角度看,小项目里两份代码差别不大,甚至 Options 看起来更直观,因为新手不用理解ref和.value。但一旦组件功能增多,比如搜索逻辑里要加缓存、要加防抖参数、要加空状态控制,Composition 的优势会越来越明显——每个功能都通过变量和函数完整地呈现在当前代码块中,你可以按业务需要重新组织排列,而不是被data、methods、watch这几个固定的"抽屉"切割。
我自己的体会是,Vue 3 里如果用 Options API 而完全不用 setup 和组合式函数,其实是把 Vue 3 用成了"Vue 2.5"——能跑,但浪费了它最核心的能力。这就好比买了一台新电脑,却只拿来打字,完全不用它的多任务处理能力。当然,这并不意味着我们就要全面否定 Options,轻量组件用 Options 依旧很快。
4. 迁移与选型:新项目和老项目分别怎么决策
4.1 新项目:默认推荐 Composition API 的理由
如果你是从零开一个新项目,我强烈建议直接用 Composition API,而且用<script setup>语法糖。理由有三点。
第一,<script setup>是目前 Vue 官方推荐的默认写法,它在编译层面做了很多优化,代码更简洁,不用写export default和setup()样板,也不用手动return变量和函数。第二,逻辑复用能力是组合式函数的天然优势,新项目可以一开始就按功能域拆useXxx函数,让每个文件都变得小而专注。第三,TypeScript 的推导在<script setup>里是原生的,组件传参用defineProps、发事件用defineEmits,类型推断比 Options 的props选项和this代理强太多。给一个刚起步的项目打下一个可扩展的代码基础,比后面为了重构付出代价要划算。
4.2 老项目:什么时候保持 Options API,什么时候渐进迁移
已经存在的项目要不要立刻全量迁移?我的态度很明确:不要。除非项目不大、测试充分、时间充裕,否则"一刀切"重写是性价比最低的选择。你在 Vue 2 时代积累的 Options 组件,如果逻辑简单、改动频繁,强行迁移只会增加引入 bug 的风险。
那什么情况值得迁?我的标准是:组件逻辑确实复杂(超过 200 行且包含多个清晰业务域)、团队要长期维护、代码里已经出现了大量方法互相调用和难缠的 mixin。这类组件是重灾区,把它们迁到 Composition 会立刻看到收益。而一个十几行、纯展示的组件,你让它继续待在 Options 世界里毫无问题。
4.3 渐进迁移的实操步骤
渐进迁移有个好处:Vue 3 的 Options API 和 setup 是可以在同一个组件中共存的。你可以在一个组件里先用setup写新的逻辑,同时保留data和methods里的旧逻辑,Vue 会把两者合并。这就给了我们平滑过渡的空间。
我建议的步骤是:先给要迁移的组件补测试或者至少锁定行为,然后在组件里加入setup,把新增的逻辑写到里面,同时保持旧逻辑不动。跑通之后,再把容易迁移的data和methods逐步搬运到 setup 中,最后删掉所有 Options 部分,改成<script setup>。每次改动都小步提交,有问题立刻能回退。注意一点,不要在 setup 里通过this去访问旧的数据和方法,因为 setup 阶段里this是拿不到的,正确的做法是在 setup 返回的变量和 Options 数据之间做好对接。
5. 常见问题与排查技巧实录
5.1 响应式相关的坑
Composition API 最常见的坑都集中在响应式上。第一个是ref和reactive的区别没搞清。ref可以装任何类型的值,包括对象,而且它内部给出的.value是响应式的;reactive只能用于对象和数组,不能用于基本类型。很多新手拿reactive('hello')去写,结果发现页面不更新,因为reactive对原始值是无能为力的。
第二个坑是reactive的整体替换和解构丢失响应式。比如:
const state = reactive({ list: [], loading: false }) state = { list: newList, loading: false } // 报错,state 是 const即便你写成Object.assign(state, { list: newList })没问题,但如果你从state里解构const { list } = state,这个list就变成了普通变量,响应式连接彻底断了。所以在 Composition API 里,如果是简单的基本类型数据,直接用ref更省心;如果用reactive,操作对象里字段时用点语法访问,不要随意解构,也不要整体替换。
5.2 生命周期和 this 相关的坑
第二个高频问题是this。setup是在组件实例创建之前执行的,所以你在 setup 内部访问不到实例上的方法和数据,this是undefined。老代码里"创建完组件后要调用一下某个方法"的思路,在 Composition 里就要改成直接调用函数,或者在onMounted里调用。
生命周期钩子也有对应改名:beforeDestroy变成了beforeUnmount,destroyed变成了unmounted。其他钩子基本保留了原名字前面加个on,比如created对应的是在 setup 里直接写代码、mounted对应onMounted、updated对应onUpdated。
这里有个容易忽略的地方:created和beforeCreate这两个生命周期在 Composition 里没有对应的钩子,因为 setup 本身就发生在beforeCreate之前。如果原来组件的created里有一段初始化逻辑,迁移时直接把它写在 setup 的顶层就行。注意 setup 里不能直接用async,因为 setup 本身需要同步完成响应式数据和函数的注册,如果你写了 async setup,模板渲染会先于异步逻辑执行,容易出问题。需要异步初始化的话,建议把逻辑放在onMounted里或自己封装异步调用。
5.3 watch 和 computed 的坑
watch在 Composition 里是一个显式的函数,不再是 Options 里的一个选项。它的第一个参数可以传入一个 ref、一个响应式对象、一个 getter 函数或者一个数组。这里最容易踩坑的是监听reactive对象的属性,如果你直接:
const state = reactive({ keyword: '', list: [] }) watch(state.keyword, () => { ... })这里面state.keyword是一个字符串原始值,不是 ref,也不是 getter,Vue 会警告并且监听不到。正确做法是:
watch(() => state.keyword, () => { ... })用 getter 返回目标值。另外,如果你监听一个reactive对象,深层变化会默认触发,但如果你想精确知道哪个字段变了,还是建议用 getter 方式。
computed也有一个小坑:它的返回值是一个只读的 ref,需要依赖时用.value访问。在模板里它会自动解包,但在脚本里如果你忘记.value,你会拿到整个 computed 对象而不是计算后的值。很多新手的调试过程就是卡在"页面怎么不显示结果"——看起来完全遵循了教程,其实就是脚本里少写了一个.value。
5.4 组合式函数的编写规范
用好组合式函数有几个值得注意的细节。第一,useXxx的命名习惯不是硬性要求,但建议遵循,让读代码的人一眼看出这是一个组合式函数,而且其内部可以访问组件生命周期。第二,函数内部创建的 ref 默认是局部的,每次调用都会生成一份独立数据,这和多组件共享状态无关。如果你真的需要全局共享状态,可以把 ref 定义在模块顶层,再在组合式函数里返回它,就像 Vue 官方文档里提到的朴素全局状态方案。
第三,组合式函数可以接收参数,但要注意参数如果是普通变量,那么在函数内部它不会自动变成响应式。上面例子里的useSearchList(keyword)之所以能响应,是因为我传进去的是一个 ref,函数内部通过keyword.value访问,它本身就是响应式的。如果你传入的是字符串字面量,那就只有初始化时读一次。所以约定:需要响应式联动的参数,务必传 ref 或者用 getter 函数。这些规则虽然简单,但实际项目里不遵守的很容易写出看似可用、实则响应式断裂的代码。
6. 一点真实体会
6.1 我对两种 API 的态度
坦白讲,我在公司内部带团队做 Vue 3 项目时,默认就是<script setup>+ 组合式函数这套组合拳。原因不是赶时髦,而是维护效率切切实实提高了——以前改一个复杂的业务逻辑要在大组件里滚半天,现在顺着函数拆分的代码块几步就定位到了。但我也保留了不少 Options API 的组件,尤其是那种只是"把几个 prop 渲染一下"的轻量组件,用 Options 写起来更简短,没必要为了"统一规范"把它改成另一种写法。
风格之争里最没必要的就是"唯我独尊"的心态。Options API 不是劣等写法,Composition API 也不是银弹。真正的判断标准是:这段代码在复杂度和复用需求上,值不值得用更灵活的方式去写。
6.2 给正在学习的朋友一个建议
别只在文档里看语法,最有效的方式是找一个你平时经常维护的不算太复杂的组件,亲手把它从 Options 改写成 Composition API,再对比一下两份代码的阅读体验。改写过程中你大概率会踩到几个前面提到过的坑,那反而是一件好事——踩过一次坑,你对响应式原理的理解会比看十遍文档都扎实。
Vue 3 这套双轨 API 设计还有一个比较实际的好处:团队里不同水平的成员可以并行工作,老手用 Composition 写出可复用的组合式函数,新手在模板和样子简单的组件里先用 Options 把功能做出来,再逐步进阶。等到你对响应式 API 越来越有感觉,自然就会明白什么场景该用什么,而不是被某个固定规则绑住。