侧边栏壁纸
博主头像
Setyg的博客

当秋风染红落叶的时候,就是新生~

  • 累计撰写 86 篇文章
  • 累计创建 66 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录
Vue

Vue 选项式 API(Options API)与组合式 API(Composition API)的区别与写法对照

Setyg
2026-09-21 / 0 评论 / 0 点赞 / 4 阅读 / 0 字

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> 顶层直接调用,不能等某个条件成立再调。

五、组合式的常见坑

  1. 脚本里忘写 .value:count++ 不会报错,但改的是 ref 对象本身,视图不更新。模板里顶层 ref 自动解包,容易形成「模板不用写、脚本忘了写」的错觉。
  2. 解构 reactive 会丢响应性:const { count } = state 拿到的是普通值,要用 toRefs(state);从 composable 返回时统一返回 ref 就不会有这个问题。
  3. 整体替换 reactive 变量:断开引用链,改用 ref 包对象后替换 .value。
  4. props 解构的版本差异:3.5 之前解构后不响应,见上文第三节第 5 小节。
  5. 侦听 reactive 的属性时直接传值:watch(form.age, cb) 无效,要写 watch(() => form.age, cb)。
  6. 想把 this 找回来:getCurrentInstance() 能拿到实例,但它不是官方推荐的用法,别拿它当 this 用。
  7. 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 未挂载
beforeMountonBeforeMount挂载开始前
mountedonMountedDOM 挂载完成,可访问模板引用
beforeUpdateonBeforeUpdate数据变化、DOM 更新前
updatedonUpdatedDOM 更新后(不要在这里改状态,会死循环)
beforeUnmountonBeforeUnmount卸载前,DOM 仍可用,适合清理副作用
unmountedonUnmounted卸载后
errorCapturedonErrorCaptured捕获后代组件抛出的错误
activated / deactivatedonActivated / onDeactivated被 <KeepAlive> 缓存组件的激活 / 失活
renderTracked / renderTriggeredonRenderTracked / onRenderTriggered开发环境调试渲染依赖
serverPrefetchonServerPrefetchSSR 服务端预取数据

七、怎么选

  • 新项目:直接用 <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.nn.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(() => {})
接收 propsprops: { title: String } + this.titleconst props = defineProps(...) + props.title
触发事件this.$emit('change', v)const emit = defineEmits(['change']) + emit('change', v)
模板引用this.$refs.inputconst input = ref(null)(3.5+ useTemplateRef('input'))
跨层传值provide() {} / inject: []provide() / inject()
DOM 更新后回调this.$nextTick(cb)nextTick(cb)
逻辑复用mixins: [mouseMixin]const { x, y } = useMouse()
0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin

评论区