Vue 3 同时支持两套组件写法:沿用 Vue 2 的选项式 API(Options API)和新的组合式 API(Composition API)。两者不是新旧替代关系,而是组织代码的两种思路——一个按「选项类型」切分,一个按「功能」切分。本文用同一组功能的双版本代码做对照,把两者的差异、常用写法以及各自容易踩的坑过一遍。
一、先看同一个组件的两版代码
一个计数器:显示计数、可自增、有个计算属性、挂载后打日志。
选项式 API(Options API)
<script>
export default {
data() {
return {
count: 0,
step: 1
}
},
computed: {
double() {
return this.count * this.step
}
},
methods: {
add() {
this.count += this.step
}
},
mounted() {
console.log('mounted, count =', this.count)
}
}
</script>
<template>
<button @click="add">{{ count }} / {{ double }}</button>
</template>组合式 API(Composition API,<script setup>)
<script setup>
import { ref, computed, onMounted } from 'vue'
const count = ref(0)
const step = ref(1)
const double = computed(() => count.value * step.value)
function add() {
count.value += step.value
}
onMounted(() => {
console.log('mounted, count =', count.value)
})
</script>
<template>
<button @click="add">{{ count }} / {{ double }}</button>
</template>两个变化最直观:没有 this,改成直接引用局部变量;响应式要显式声明(ref()),脚本里读写带 .value。注意模板里写的是 {{ count }} 而不是 {{ count.value }}——模板中顶层的 ref 会自动解包。
二、核心差异对照表
| 维度 | 选项式 API(Options API) | 组合式 API(Composition API) |
|---|---|---|
| 代码组织 | 按「选项类型」切分:data、methods、computed、watch 各占一块,同一个功能的代码被拆到四处 |
按「功能」组织:一个功能的 state、计算、方法、生命周期写在一起,必要时整体抽成 composable |
| 组件实例 | 用 this 访问所有选项;this 类型靠选项推断 |
没有 this,靠词法作用域直接引用局部变量;TypeScript 推导完整 |
| 响应式 | data() 返回的对象自动响应式,模板和 this 里都不用 .value |
显式声明 ref() / reactive(),脚本里要 .value,模板顶层自动解包 |
| 逻辑复用 | mixin / extends:来源隐式,命名冲突静默覆盖 |
composable 函数:来源显式,可传参、可重命名、天然 tree-shaking |
| 生命周期 | 同名选项方法,如 mounted() {} |
onMounted(() => {}) 在 setup 中注册 |
| 组合能力 | 较弱:所有东西混在 this 上,跨组件复用只能写 mixin |
较强:可以像普通函数一样嵌套、组合、条件调用 |
| 适用场景 | 逻辑简单的小组件、存量项目、团队暂不熟悉组合式 | 逻辑复杂的大组件、需要封装复用、TypeScript 项目 |
三、逐项写法对照
1. 响应式状态
选项式里 data 返回的对象自动被处理成响应式,但有一个硬性要求:
export default {
// ✅ data 必须是函数:每个组件实例拿到独立的一份数据
data() {
return { count: 0 }
}
// ❌ 写成对象会让所有实例共享同一份数据
// data: { count: 0 }
}组合式里要自己选 ref 还是 reactive:
import { ref, reactive } from 'vue'
// 基本类型用 ref(数字、字符串、布尔、数组也可以)
const count = ref(0)
count.value++ // 脚本里必须 .value
// 对象用 reactive,属性访问不用 .value
const form = reactive({ name: '', age: 0 })
form.name = 'tom' // ✅
form = reactive({ name: 'x' }) // ❌ let 声明的变量整体替换会断掉响应性
// 需要整体替换的对象,用 ref 包一层
const form2 = ref({ name: '', age: 0 })
form2.value = { name: 'x', age: 1 } // ✅ 替换的是 .value,引用链没断reactive 的两个限制要记住:只对对象/数组/Map/Set 有效,不能用于基本类型;整体替换会丢失响应性,因为它返回的是 Proxy 引用,你重新赋值只是换了变量指向,模板还连着旧对象。
2. 计算属性
// 选项式:只读
export default {
computed: {
double() {
return this.count * 2
}
}
}
// 选项式:可写
export default {
computed: {
fullName: {
get() { return this.first + ' ' + this.last },
set(val) {
const [f, l] = val.split(' ')
this.first = f
this.last = l
}
}
}
}// 组合式:只读
import { computed } from 'vue'
const double = computed(() => count.value * 2)
// 组合式:可写
const fullName = computed({
get: () => first.value + ' ' + last.value,
set(val) {
const [f, l] = val.split(' ')
first.value = f
last.value = l
}
})两边语义一致:有缓存、依赖不变就不重新计算、不要在 getter 里做副作用(改状态、发请求),那属于 watch 的活。
3. 侦听器
// 选项式
export default {
watch: {
// 简写,只关心变化后
keyword(newVal, oldVal) {
this.search(newVal)
},
// 支持点路径字符串
'form.age'(newVal) {
console.log('age 变了', newVal)
},
// 完整写法,配置 immediate / deep
list: {
handler(newVal) { console.log(newVal) },
immediate: true,
deep: true
}
}
}// 组合式
import { watch, watchEffect, ref, reactive } from 'vue'
const keyword = ref('')
const form = reactive({ age: 0 })
const list = ref([])
// 侦听 ref:直接传
watch(keyword, (newVal, oldVal) => { search(newVal) })
// 侦听 reactive 的某个属性:必须用 getter 包一层
watch(() => form.age, (newVal) => { console.log('age 变了', newVal) })
// watch(form.age, ...) ❌ 传进去的是普通值,不是响应式来源,不会触发
// 侦听整个 reactive 对象:隐式 deep,且 newVal === oldVal
watch(form, () => { console.log('form 变了') })
// 多来源
watch([keyword, () => form.age], ([nk, na], [ok, oa]) => { /* ... */ })
// 配置项
watch(keyword, handler, { immediate: true, deep: true, flush: 'post' })
// 不清来源,自动收集依赖,立即执行一次
watchEffect(() => {
console.log('依赖变化了:', keyword.value, form.age)
})两个容易踩的点:
- 选项式的
watch默认不是深度侦听,侦听对象属性变化要显式写deep: true;而组合式里watch(reactiveObj, ...)是隐式深度,想省性能就得换成 getter 精确侦听某个属性。 - 侦听 reactive 对象的回调拿不到旧值(
newVal === oldVal),因为同一个 Proxy 已被就地修改。需要旧值就侦听 getter 或快照一份。
清理副作用用 onCleanup,比手动记状态干净:
watch(keyword, (kw, _, onCleanup) => {
const timer = setTimeout(() => fetchResult(kw), 300)
onCleanup(() => clearTimeout(timer)) // 下次触发或组件卸载时调用
})4. 生命周期
// 选项式:写成同名方法
export default {
beforeCreate() {},
created() {},
mounted() {},
beforeUnmount() {},
unmounted() {}
}// 组合式:从 vue 导入,在 setup 中注册
import {
onBeforeMount, onMounted,
onBeforeUpdate, onUpdated,
onBeforeUnmount, onUnmounted,
onActivated, onDeactivated,
onErrorCaptured, nextTick
} from 'vue'
onMounted(() => { /* DOM 已就绪 */ })
onBeforeUnmount(() => { /* 此时 DOM 还在,适合清理定时器、解绑监听 */ })组合式没有 onCreated / onBeforeCreate——setup 本身就在这个时机执行,之前的初始化代码直接写在 setup 顶层即可(此时 props 已可用,DOM 还没有)。另外 this.$nextTick() 换成 import { nextTick } from 'vue'。
5. props 与 emit
// 选项式
export default {
props: {
title: { type: String, required: true },
count: { type: Number, default: 0 }
},
emits: ['change', 'update'],
methods: {
handle() {
this.$emit('change', this.count + 1)
}
}
}<!-- 组合式:defineProps / defineEmits 是编译宏,不需要 import -->
<script setup>
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 }
})
const emit = defineEmits(['change', 'update'])
function handle() {
emit('change', props.count + 1)
}
</script>TypeScript 项目可以直接用类型声明,比运行时对象更紧凑:
<script setup lang="ts">
// 类型声明式(3.0+)
const props = defineProps<{ title: string; count?: number }>()
const emit = defineEmits<{ (e: 'change', value: number): void }>()
// 3.3+ 更简洁的具名元组写法
const emit2 = defineEmits<{ change: [value: number] }>()
// 类型声明下没有运行时默认值,用 withDefaults 补
const props2 = withDefaults(defineProps<{ count?: number }>(), { count: 0 })
</script>props 解构的版本差异是高频疑问点:
<script setup>
const props = defineProps({ count: Number })
// Vue 3.5+ :解构出来的变量仍然是响应式的
const { count } = defineProps({ count: Number })
// Vue 3.4 及以前:解构会丢失响应性,props.count 变化后 count 不更新
// 正确做法是保留 props.count,或者用 toRef
import { toRef } from 'vue'
const count1 = toRef(props, 'count') // ✅ 保持连接
</script>6. 模板引用(获取 DOM / 子组件实例)
<!-- 选项式 -->
<template>
<input ref="input" />
</template>
<script>
export default {
mounted() {
this.$refs.input.focus()
}
}
</script><!-- 组合式:声明一个同名 ref -->
<template>
<input ref="input" />
</template>
<script setup>
import { ref, useTemplateRef, onMounted } from 'vue'
// 变量名必须和模板里的 ref 值一致;初始值必须是 null
const input = ref(null)
onMounted(() => input.value.focus())
// Vue 3.5+ 推荐写法:显式传模板里的 ref 名,不再强依赖变量名
const input2 = useTemplateRef('input')
</script>7. 跨层传值 provide / inject
// 选项式
export default {
// ⚠️ 这样 provide 的是取值那一刻的快照,之后 this.theme 变了,后代拿不到新值
provide() {
return { theme: this.theme }
},
inject: ['theme']
}
// 需要响应式就包一层 computed
import { computed } from 'vue'
export default {
data() { return { theme: 'dark' } },
provide() {
return { theme: computed(() => this.theme) }
}
}// 组合式
import { provide, inject, ref, readonly } from 'vue'
// 祖先组件
const theme = ref('dark')
provide('theme', theme) // 直接传 ref,天然响应式
// provide('theme', readonly(theme)) // 不想让后代改,包一层 readonly
// 后代组件
const theme = inject('theme', ref('light')) // 第二个参数是找不到时的默认值大型项目或要发布成库时,注入的 key 建议用 Symbol('theme') 代替字符串,避免命名撞车。
四、逻辑复用:mixin 对比 composable
这是两套 API 差异最大的地方,也是官方推荐组合式的主要原因。
mixin 的写法与问题
// mixins/useMouse.js
export const mouseMixin = {
data() {
return { x: 0, y: 0 }
},
mounted() {
window.addEventListener('mousemove', this.update)
},
beforeUnmount() {
window.removeEventListener('mousemove', this.update)
},
methods: {
update(e) {
this.x = e.pageX
this.y = e.pageY
}
}
}
// 使用
export default {
mixins: [mouseMixin],
mounted() {
console.log(this.x) // x 从哪来的?光看这段代码看不出来
}
}- 来源不清晰:
this.x、this.update是组件自己定义的还是 mixin 塞进来的,只能靠翻文件。 - 命名冲突静默覆盖:组件自身的选项优先级高于 mixin,多个 mixin 之间按数组顺序后者覆盖前者,
data是浅合并——一旦重名,不报错、不警告,只是行为悄悄变了。 - 无法传参:mixin 是静态合并,想让它接受不同的配置参数,只能再包一层工厂函数,很别扭。
- 类型推导差:TS 对混入进来的
this成员基本推不出来。
composable 的写法
// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(e) {
x.value = e.pageX
y.value = e.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}<script setup>
import { useMouse } from '@/composables/useMouse'
// 来源就在这一行,一眼可见;还能改名避免冲突
const { x, y } = useMouse()
const { x: chartX, y: chartY } = useMouse()
</script>composable 相比 mixin 的收益:
- 来源显式:解构那一行就写明了每个变量从哪来。
- 可重命名、可多次调用:同一个 composable 在一个组件里用两次互不干扰,mixin 做不到。
- 可传参:
useFetch(url, { immediate: true }),就是一个普通函数。 - 可组合:composable 内部可以调用别的 composable,像搭积木一样嵌套。
- tree-shaking 友好:没用到的 composable 会被打包工具摇掉,mixin 只要注册就会进包。
- 类型完整:返回值类型由函数签名直接给出,不需要额外声明。
composable 的一个硬性约束:里面的 onMounted、provide、inject 等依赖组件实例的 API,必须在 setup 的执行期间同步调用。放进 setTimeout、Promise.then 或事件回调里再调用,会拿不到组件实例并报 onMounted is called when there is no active component instance。所以 composable 要在 <script setup> 顶层直接调用,不能等某个条件成立再调。
五、组合式的常见坑
- 脚本里忘写
.value:count++不会报错,但改的是 ref 对象本身,视图不更新。模板里顶层 ref 自动解包,容易形成「模板不用写、脚本忘了写」的错觉。 - 解构
reactive会丢响应性:const { count } = state拿到的是普通值,要用toRefs(state);从 composable 返回时统一返回ref就不会有这个问题。 - 整体替换
reactive变量:断开引用链,改用ref包对象后替换.value。 - props 解构的版本差异:3.5 之前解构后不响应,见上文第三节第 5 小节。
- 侦听 reactive 的属性时直接传值:
watch(form.age, cb)无效,要写watch(() => form.age, cb)。 - 想把
this找回来:getCurrentInstance()能拿到实例,但它不是官方推荐的用法,别拿它当this用。 - ref 嵌在 reactive 里会自动解包:
reactive({ n: ref(0) })之后state.n直接就是值,不用.value;但放在数组或 Map 里不会解包。
选项式这边也有自己的问题,同样值得知道:data 必须写成函数(写成对象会导致所有实例共享数据)、mixin 的命名冲突、按选项类型切分导致大组件里同一功能的代码分散在四处、TS 对 this 的推导依赖选项推断、复杂组件里容易失真。
六、生命周期对应速查表
| 选项式 API(Options API) | 组合式 API(Composition API) | 时机 |
|---|---|---|
beforeCreate | —(直接写在 setup 顶层) | 实例创建前 |
created | —(直接写在 setup 顶层) | 实例创建后,props 可用,DOM 未挂载 |
beforeMount | onBeforeMount | 挂载开始前 |
mounted | onMounted | DOM 挂载完成,可访问模板引用 |
beforeUpdate | onBeforeUpdate | 数据变化、DOM 更新前 |
updated | onUpdated | DOM 更新后(不要在这里改状态,会死循环) |
beforeUnmount | onBeforeUnmount | 卸载前,DOM 仍可用,适合清理副作用 |
unmounted | onUnmounted | 卸载后 |
errorCaptured | onErrorCaptured | 捕获后代组件抛出的错误 |
activated / deactivated | onActivated / onDeactivated | 被 <KeepAlive> 缓存组件的激活 / 失活 |
renderTracked / renderTriggered | onRenderTracked / onRenderTriggered | 开发环境调试渲染依赖 |
serverPrefetch | onServerPrefetch | SSR 服务端预取数据 |
七、怎么选
- 新项目:直接用
<script setup>。官方文档默认示例已是这一套,TS 体验、逻辑复用、打包体积都更好。 - 存量项目:选项式在 Vue 3 里是完整支持的一等公民,不是废弃 API。不必为了「新」而整体重写,新组件用组合式、老组件保持不动是常见做法。
- 简单小组件:选项式其实更省字数,几行就能说清;逻辑一复杂(一个功能牵涉多个状态 + 侦听 + 生命周期)时组合式的优势才开始显现。
- 不要在一个组件里混用两套:技术上
setup()选项可以和data/methods共存,但同一个东西两种写法混在一起会很难读。<script setup>旁边再挂一个普通<script>是另一回事,那是为了写name、inheritAttrs这类选项(3.3+ 也可以用defineOptions宏写在<script setup>里)。
八、速查卡
| 需求 | 选项式 API(Options API) | 组合式 API(Composition API) |
|---|---|---|
| 响应式数据 | data() { return { n: 1 } } | const n = ref(1) |
| 读取 | this.n | n.value(模板里写 n) |
| 对象状态 | data() 返回对象 | const s = reactive({ n: 1 }) |
| 方法 | methods: { fn() {} } | function fn() {} |
| 计算属性 | computed: { c() {} } | const c = computed(() => ...) |
| 侦听器 | watch: { n(nv, ov) {} } | watch(n, (nv, ov) => {}) |
| 立即 / 深度侦听 | watch: { n: { handler, immediate: true, deep: true } } | watch(n, cb, { immediate: true, deep: true }) |
| 挂载完成 | mounted() {} | onMounted(() => {}) |
| 卸载前清理 | beforeUnmount() {} | onBeforeUnmount(() => {}) |
| 接收 props | props: { title: String } + this.title | const props = defineProps(...) + props.title |
| 触发事件 | this.$emit('change', v) | const emit = defineEmits(['change']) + emit('change', v) |
| 模板引用 | this.$refs.input | const input = ref(null)(3.5+ useTemplateRef('input')) |
| 跨层传值 | provide() {} / inject: [] | provide() / inject() |
| DOM 更新后回调 | this.$nextTick(cb) | nextTick(cb) |
| 逻辑复用 | mixins: [mouseMixin] | const { x, y } = useMouse() |
评论区