Composition API 深度解析 - 重新理解 Vue 的组件化编程


写在前面

很多人学 Composition API 是从"怎么用 ref"开始的,但这样学到的只是语法。本篇想从更根本的地方出发:Vue 2 的 Options API 在大型项目里遇到了什么问题?Composition API 是如何从设计层面解决这些问题的?响应式系统底层是怎么工作的?为什么 ref 要加 .value?组合函数和 mixin 本质上的差距在哪里?和 Vite 的关系又是什么?

本文主要参考 Vue 3 官方文档 - 组合式 API 常见问答响应式基础组合式函数


目录

  • [1. Options API 的局限:不是"旧",而是"不够灵活"](#1. Options API 的局限:不是"旧",而是"不够灵活")
  • [2. Composition API 的设计目标:三个核心解决的问题](#2. Composition API 的设计目标:三个核心解决的问题)
  • [3. Vue 响应式系统:Proxy 的工作原理](#3. Vue 响应式系统:Proxy 的工作原理)
  • [4. ref():为什么需要 .value?](#4. ref():为什么需要 .value?)
  • [5. reactive():代理对象的能力与局限](#5. reactive():代理对象的能力与局限)
  • [6. ref vs reactive:如何做选择?](#6. ref vs reactive:如何做选择?)
  • [7. computed 与 watch:依赖追踪的两种用法](#7. computed 与 watch:依赖追踪的两种用法)
  • [8. 生命周期钩子:执行时机全解析](#8. 生命周期钩子:执行时机全解析)
  • [9. 组合函数(Composables):逻辑复用的正确姿势](#9. 组合函数(Composables):逻辑复用的正确姿势)
  • [10. <script setup>:编译层面的优化](#10. <script setup>:编译层面的优化)
  • [11. TypeScript 集成:类型推导的天然优势](#11. TypeScript 集成:类型推导的天然优势)
  • [12. 与 Vite 的关系:构建工具如何影响 API 使用体验](#12. 与 Vite 的关系:构建工具如何影响 API 使用体验)
  • [13. 与 React Hooks 的对比:看起来像,但本质不同](#13. 与 React Hooks 的对比:看起来像,但本质不同)
  • [14. 常见坑与最佳实践](#14. 常见坑与最佳实践)
  • [15. 何时保留 Options API?](#15. 何时保留 Options API?)
  • 小结

1. Options API 的局限:不是"旧",而是"不够灵活"

Vue 2 的 Options API 是一套非常"有规律"的 API 设计:data 放数据,methods 放方法,computed 放计算属性,watch 放监听器,每个东西都有固定的位置。对于中小型项目,这种规律性带来了很好的一致性------你知道在哪找什么,写代码时"少思考"。

问题出在中大型组件上

以我们项目里的用户管理页面为例,它大概有这些功能:查询用户列表、新增用户、编辑用户、删除用户、批量操作、分页、状态筛选。把这些功能用 Options API 写出来,组件会是这样的:

复制代码
data() {
  return {
    // 列表相关
    userList: [],
    loading: false,
    total: 0,
    currentPage: 1,
    pageSize: 20,
    
    // 查询表单相关
    queryForm: { userName: '', state: null },
    
    // 弹窗相关
    dialogVisible: false,
    dialogType: 'add',
    editForm: { userName: '', email: '', role: '' },
    
    // 批量操作相关
    selectedIds: [],
  }
}

methods: {
  // 列表相关
  getUserList() { ... }
  handlePageChange() { ... }
  handleSearch() { ... }
  
  // 弹窗相关
  handleAdd() { ... }
  handleEdit(row) { ... }
  handleDelete(id) { ... }
  handleSubmit() { ... }
  
  // 批量操作相关
  handleBatchDelete() { ... }
  handleSelectionChange(rows) { ... }
}

computed: {
  filteredList() { ... }  // 列表相关
  isAllSelected() { ... } // 批量操作相关
}

watch: {
  queryForm: { handler() { ... }, deep: true }  // 查询相关
}

这个组件文件可能长达 500 行。当你需要修改"分页"这个功能时,你要在 data、methods、computed、watch 四处跳转,反复上下翻滚。当你想把"查询功能"抽取出来复用时,你要从四个选项里分别摘出属于它的那部分------这个过程容易出错。

Vue 官方把这个问题叫做**"逻辑关注点分散"**,形象的比喻是:同一种颜色的代码(属于同一个功能)被强制分散到组件文件的各个角落。

复制代码
// Options API 中,"查询功能"的代码分布(用字母表示位置):
data:     queryForm, loading          ← A 区
methods:  getUserList, handleSearch   ← B 区(与 A 区相距很远)
watch:    queryForm                   ← C 区(与 B 区又相距很远)

Composition API 的出发点就是:允许开发者按功能而不是按选项类型组织代码


2. Composition API 的设计目标:三个核心解决的问题

根据 Vue 官方文档,Composition API 主要解决了三个问题:

2.1 更好的逻辑复用

Options API 时代的逻辑复用靠 mixin,但 mixin 有几个根本性缺陷(后面专门对比)。Composition API 通过**组合函数(Composables)**提供了清晰、可测试、无副作用的逻辑复用机制。

这个优势催生了 VueUse 这样的生态项目------一个不断成长的组合式函数集合,提供了上百个开箱即用的功能(防抖、节流、网络状态、剪贴板、本地存储......)。这种生态在 Options API 时代几乎不可能以这种形式存在。

2.2 更灵活的代码组织

Composition API 不规定你把代码放在哪里,你可以完全按照业务功能来组织:

javascript 复制代码
// setup 里,"查询功能"的所有代码聚合在一起
const queryForm = reactive({ userName: '', state: null })
const loading = ref(false)
const getUserList = async () => { ... }
watch(queryForm, getUserList, { deep: true })

// "弹窗功能"紧随其后,完全独立
const dialogVisible = ref(false)
const editForm = reactive({ ... })
const handleSubmit = async () => { ... }

这种组织方式使得抽取复用变得极其简单:把某个功能相关的代码块整体移出去就完成了,不需要从四个选项中分别摘取片段。

2.3 更好的 TypeScript 类型推导

Options API 是 2013 年设计的,那时 TypeScript 还没有流行。它的设计依赖 this 上下文,而 this 的类型推断在 TypeScript 里极其复杂。Vue 核心团队不得不写了大量"类型体操"来让 Options API 有基本的类型支持,但即使这样,在 mixin 和依赖注入场景下类型推断仍然不理想。

Composition API 主要使用普通变量和函数,这些本来就是类型友好的。用 TypeScript 写的 Composition API 代码和普通 JS 代码差异很小,类型推断几乎是自动的。

2.4 更小的生产包体积

这点经常被忽略。<script setup> + Composition API 比等价的 Options API 代码打包后更小,原因是:

  • <script setup> 中的模板和 script 在同一作用域,本地变量名可以被压缩工具缩短 (如 userLista
  • Options API 依赖 this.xxx 访问属性,属性名(如 this.userList不能被压缩,因为属性是字符串键名

这个差距在大型项目中可以达到几十 KB。


3. Vue 响应式系统:Proxy 的工作原理

在深入 refreactive 之前,需要先理解 Vue 3 响应式系统的基础:JavaScript Proxy

Vue 2 使用 Object.defineProperty 来拦截属性访问,这个方案有两个重要局限:

  1. 无法检测对象新增属性 (所以 Vue 2 需要 Vue.set(obj, key, value)
  2. 无法检测数组索引length 的变化(所以 Vue 2 要包装数组方法)

Vue 3 换用 Proxy,它可以拦截对整个对象的所有操作,包括属性新增、删除、数组索引修改等。

响应式系统的核心工作流程:

复制代码
数据读取(get):
  访问 state.count
    → Proxy getter 触发
    → 依赖追踪:把"当前正在运行的 effect(副作用)"记录为 count 的依赖
    → 返回值

数据修改(set):
  state.count = 1
    → Proxy setter 触发
    → 依赖触发:通知所有依赖了 count 的 effect 重新执行
    → 更新值,触发组件重新渲染

这就是 Vue 响应式的本质:读时收集依赖,写时通知更新(发布-订阅模式)。组件的渲染函数本身就是一个 effect,当它读取了某个响应式数据,就自动订阅了该数据的变化。

用伪代码表示 ref 的内部实现:

javascript 复制代码
// ref 的简化实现(概念模型)
class RefImpl {
  constructor(value) {
    this._value = value
  }
  
  get value() {
    track(this, 'value')  // 收集依赖:谁在读我?
    return this._value
  }
  
  set value(newValue) {
    this._value = newValue
    trigger(this, 'value')  // 触发更新:通知所有依赖我的地方
  }
}

function ref(value) {
  return new RefImpl(value)
}

这也是为什么 修改数据必须通过响应式 API,而不能直接赋值给一个普通变量:只有经过 Proxy 或 getter/setter 拦截的修改,Vue 才知道需要更新视图。


4. ref():为什么需要 .value?

这是学 Composition API 最常见的疑问。理解了上一节,答案就清楚了。

根本原因:JavaScript 的基本类型(string、number、boolean)是值传递的,不是引用传递的。

javascript 复制代码
// 普通 JS 赋值
let count = 0
let alias = count  // alias 只是拷贝了值,和 count 没有任何关联

count = 1
console.log(alias)  // 还是 0,修改 count 不影响 alias

如果 Vue 的响应式系统直接接受基本类型值,它无法在值被修改时得到通知------因为修改 count 本身并不会触发任何 getter/setter,Vue 没有任何机会插手。

ref(0) 的解决方案:把值包在一个对象里,然后对这个对象用 Proxy 拦截:

javascript 复制代码
// ref(0) 等价于做了这件事:
const count = {
  _value: 0,
  get value() {
    track()     // 依赖收集
    return this._value
  },
  set value(newVal) {
    this._value = newVal
    trigger()   // 触发更新
  }
}

现在 count 是一个对象,而对象是引用类型,可以被传递、被 Proxy 拦截。.value 就是包装对象上的属性访问,触发 getter/setter,让响应式机制得以运转。

模板里为什么不需要 .value

这是编译器做的自动解包(auto-unwrap)。当你在 <template> 里写 {``{ count }},Vue 模板编译器知道 count 是一个 Ref 对象,会自动访问 count.value

vue 复制代码
<!-- 你写的 -->
<template>{{ count }}</template>

<!-- 编译器生成的(概念上等价于) -->
<template>{{ count.value }}</template>

这个自动解包只在模板的顶层属性 上生效。如果 ref 嵌套在对象里,就需要手动 .value

javascript 复制代码
const count = ref(0)
const obj = { count }  // ref 嵌套在普通对象里

// ❌ 模板里不会自动解包嵌套的 ref
// {{ obj.count }}  → 显示 { value: 0 }(不是你想要的)

// ✅ 手动访问
// {{ obj.count.value }}

// ✅ 或者解构到顶层
const { count: nestedCount } = obj
// {{ nestedCount }}  → 正常显示 0

Vue 官方文档在 3.x 中明确推荐:ref() 作为声明响应式状态的主要 API


5. reactive():代理对象的能力与局限

reactive() 直接对整个对象做 Proxy,访问属性时不需要 .value

javascript 复制代码
const user = reactive({
  name: '张三',
  age: 18,
  address: {
    city: '北京'
  }
})

// 深层响应式,嵌套对象也被代理
user.name = '李四'       // 触发更新 ✓
user.address.city = '上海'  // 深层修改,也触发更新 ✓

但 Vue 官方文档明确列出了 reactive() 的三个局限性,这也是为什么更推荐 ref()

5.1 只能处理对象类型

javascript 复制代码
// ❌ 不能传基本类型
const count = reactive(0)    // 警告:不起作用
const name = reactive('张三') // 警告:不起作用

// ✅ reactive 只接受对象、数组、Map、Set
const state = reactive({ count: 0 })
const list = reactive([1, 2, 3])

5.2 不能替换整个对象

javascript 复制代码
let state = reactive({ count: 0 })

// ❌ 这样写会让响应式连接断掉
state = reactive({ count: 1 })  // 新的对象,但原来绑定到模板的是旧引用

// ✅ 用 Object.assign 原地修改
Object.assign(state, { count: 1 })

这个问题在 API 请求后"整体替换数据"的场景里很常见:

javascript 复制代码
// ❌ 常见错误:请求完数据后直接替换
const user = reactive({})
const fetchUser = async () => {
  const res = await api.getUser()
  user = reactive(res.data)  // 响应式丢失!
}

// ✅ 应该原地修改
const fetchUser = async () => {
  const res = await api.getUser()
  Object.assign(user, res.data)  // 保持同一引用,响应式保留
}

5.3 解构会丢失响应式

这是 reactive() 最常踩的坑:

javascript 复制代码
const state = reactive({ count: 0, name: '张三' })

// ❌ 解构出来的 count 是普通数字,失去响应性
const { count } = state
count++  // 不触发视图更新!

// 传给函数时也一样
someFunction(state.count)  // 传的是值 0,不是响应式引用

解决方案一:用 toRefs() 转换

javascript 复制代码
import { toRefs } from 'vue'

const state = reactive({ count: 0, name: '张三' })
const { count, name } = toRefs(state)  // 每个属性都变成了 ref
count.value++  // ✓ 触发更新

解决方案二:统一改用 ref(),从根本上避免这个问题。


6. ref vs reactive:如何做选择?

Vue 官方文档给出了明确建议:推荐使用 ref() 作为声明响应式状态的主要 API

这不是说 reactive() 不好,而是从工程实践角度考量:

维度 ref() reactive()
支持类型 所有类型(基本类型+对象) 仅对象类型
模板使用 自动解包,无需 .value 直接访问,无需 .value
Script 使用 需要 .value 不需要 .value
解构 安全,解构出来还是 ref 危险,解构后失去响应式
整体替换 x.value = newVal,安全 不能整体替换,需要 Object.assign
TypeScript 类型明确,Ref<T> 类型推断有时不完整

统一用 ref() 的团队规范

javascript 复制代码
// 基本类型
const count = ref(0)
const name = ref('')
const loading = ref(false)

// 对象:也用 ref 包装
const user = ref({ name: '', age: 0 })
const queryForm = ref({ keyword: '', status: null })

// 数组
const list = ref([])

// 修改对象属性(通过 .value 访问对象,再修改属性)
user.value.name = '张三'
queryForm.value.keyword = '搜索词'

// 整体替换(安全)
list.value = newList

这样做的好处:无论什么类型的数据,访问模式都是一致的(script 里加 .value,模板里不加),不用记"这个是 ref 还是 reactive,我需不需要加 .value"。


7. computed 与 watch:依赖追踪的两种用法

7.1 computed:有缓存的派生状态

computed 本质是:基于响应式数据计算得出,并且有缓存的值

javascript 复制代码
const list = ref([
  { name: '张三', active: true },
  { name: '李四', active: false },
  { name: '王五', active: true },
])

// computed 会缓存:只有 list.value 变化时才重新计算
const activeList = computed(() => list.value.filter(item => item.active))
const activeCount = computed(() => activeList.value.length)

缓存机制的意义:模板里多次访问 activeList,不会多次执行过滤逻辑,只在依赖的 list 变化时重新计算一次。

写入型 computed(较少用,但要知道):

javascript 复制代码
const fullName = computed({
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  set(value) {
    const parts = value.split(' ')
    firstName.value = parts[0]
    lastName.value = parts[1]
  }
})

// 现在可以双向绑定:
fullName.value = '张 三'  // 自动拆分到 firstName 和 lastName

7.2 watch:响应式数据变化时执行副作用

javascript 复制代码
const searchKeyword = ref('')
const searchResults = ref([])

// 监听 searchKeyword,关键字变化时重新搜索
watch(searchKeyword, async (newValue, oldValue) => {
  if (newValue.trim()) {
    searchResults.value = await api.search(newValue)
  } else {
    searchResults.value = []
  }
})

watch 的几个重要选项:

javascript 复制代码
// 深度监听(监听对象内部的变化)
watch(queryForm, (newForm) => {
  fetchList()
}, {
  deep: true,       // 深度监听,对象内任意属性变化都触发
  immediate: true,  // 立即执行一次(相当于 created + watch 的组合)
})

// 监听多个来源(数组形式)
watch([page, pageSize], ([newPage, newPageSize]) => {
  fetchList()
})

7.3 watchEffect:自动收集依赖的 watch

watchEffect 会自动追踪函数内访问的所有响应式数据,不需要手动指定要监听什么:

javascript 复制代码
// 会自动监听 searchKeyword 和 filters 的变化
watchEffect(async () => {
  // 函数执行时访问了哪些响应式数据,就自动监听那些数据
  const results = await api.search({
    keyword: searchKeyword.value,  // 自动追踪
    ...filters.value               // 自动追踪
  })
  searchResults.value = results
})

watch vs watchEffect 的选择

场景 推荐
需要访问变化前后的值(oldValue) watch
需要精确控制监听哪些数据 watch
监听逻辑依赖多个响应式数据,懒得手动声明依赖 watchEffect
需要立即执行,且不需要 oldValue watchEffect(默认立即执行)

8. 生命周期钩子:执行时机全解析

Composition API 的生命周期钩子名称都以 on 开头,是函数调用而不是选项声明:

组件销毁
组件被创建
setup 开始执行
onBeforeMount
DOM 挂载
onMounted ← 最常用
数据变化?
onBeforeUpdate
DOM 更新
onUpdated
onBeforeUnmount
onUnmounted

Options API Composition API 执行时机
beforeCreate --- setup() 本身的执行时机与 beforeCreate 等价
created --- setup() 内部可以直接写初始化逻辑
beforeMount onBeforeMount DOM 挂载之前,此时虚拟 DOM 已生成
mounted onMounted DOM 挂载完成,可以访问 DOM 元素
beforeUpdate onBeforeUpdate 响应式数据变化,DOM 更新之前
updated onUpdated DOM 更新完成
beforeUnmount onBeforeUnmount 组件卸载前,清理副作用的时机
unmounted onUnmounted 组件卸载完成

最常用的钩子和典型用法

javascript 复制代码
import { onMounted, onBeforeUnmount } from 'vue'

// ✅ onMounted:初始化数据请求、操作 DOM
onMounted(async () => {
  await fetchList()       // 请求数据
  initChart()            // 初始化图表(需要 DOM 就绪)
})

// ✅ onBeforeUnmount:清理副作用,防止内存泄漏
const timer = setInterval(() => {
  refreshData()
}, 5000)

onBeforeUnmount(() => {
  clearInterval(timer)   // 组件销毁前清除定时器
})

// ✅ 可以多次调用同一个钩子(按注册顺序执行)
onMounted(() => console.log('钩子1'))
onMounted(() => console.log('钩子2'))
// 输出:钩子1, 钩子2

为什么 setup 替代了 beforeCreatecreated

setupbeforeCreate 之前执行,但 Vue 的官方文档说它在功能上覆盖了这两个钩子。因为 setup 执行时,响应式数据已经可以初始化(直接用 ref/reactive 声明),不再需要 created 这个"数据初始化完成后执行"的钩子了。


9. 组合函数(Composables):逻辑复用的正确姿势

这是 Composition API 最核心的价值,值得深入讲。

9.1 什么是组合函数?

Vue 官方文档的定义:"组合式函数"(Composables) 是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑的函数。

关键词是"有状态"。普通工具函数(如日期格式化、字符串处理)封装的是无状态逻辑------输入确定输出就确定。组合函数封装的是随时间变化的状态以及与这些状态相关的操作和生命周期。

一个最简洁的例子(官方鼠标追踪示例):

javascript 复制代码
// src/composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  // 生命周期绑定到调用组件上
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }  // 始终返回 ref,方便解构时保持响应式
}
vue 复制代码
<!-- 任意组件中使用 -->
<script setup>
import { useMouse } from '@/composables/useMouse'

const { x, y } = useMouse()  // 清晰!变量来源一目了然
</script>

<template>鼠标位置:{{ x }}, {{ y }}</template>

9.2 项目中的实战组合函数

在我们的后台系统里,几乎每个列表页都有查询、分页、加载状态三件套。用组合函数抽取:

javascript 复制代码
// src/composables/useTable.js
import { ref, reactive, onMounted } from 'vue'

export function useTable(fetchFn, options = {}) {
  const { defaultPageSize = 20, immediate = true } = options

  const list = ref([])
  const loading = ref(false)
  const total = ref(0)
  const pagination = reactive({
    page: 1,
    pageSize: defaultPageSize,
  })

  const fetchList = async (params = {}) => {
    loading.value = true
    try {
      const res = await fetchFn({
        page: pagination.page,
        pageSize: pagination.pageSize,
        ...params,
      })
      list.value = res.list
      total.value = res.total
    } finally {
      loading.value = false
    }
  }

  const handlePageChange = (page) => {
    pagination.page = page
    fetchList()
  }

  const handlePageSizeChange = (size) => {
    pagination.page = 1
    pagination.pageSize = size
    fetchList()
  }

  if (immediate) {
    onMounted(fetchList)
  }

  return {
    list,
    loading,
    total,
    pagination,
    fetchList,
    handlePageChange,
    handlePageSizeChange,
  }
}

使用时:

vue 复制代码
<script setup>
import { useTable } from '@/composables/useTable'
import api from '@/api'

// 用户列表
const {
  list: userList,
  loading,
  total,
  pagination,
  fetchList,
  handlePageChange,
} = useTable(api.getUserList)

// 角色列表(可以在同一组件里同时使用两次,完全独立)
const { list: roleList } = useTable(api.getRoleList)
</script>

这个 useTable 的代码如果用 mixin 来实现,会遇到什么问题?当你在同一个组件里调用两次 useTable(一个用户列表、一个角色列表),两个 mixin 的 listloading 就会命名冲突,整个方案崩溃。而组合函数通过解构重命名优雅地解决了这个问题。

9.3 组合函数与 mixin 的本质差距

Vue 官方文档总结了 mixin 的三个根本缺陷,用对比来说明:

缺陷一:数据来源不清晰

javascript 复制代码
// mixin 方式
export default {
  mixins: [userMixin, tableMixin, permissionMixin],
  mounted() {
    // this.list 来自哪个 mixin?this.loading?this.permissions?
    // 你需要去每个 mixin 文件里查
    this.getUserList()
  }
}

// 组合函数方式
const { list: userList, getUserList } = useUserList()   // 来源清晰
const { loading, total } = useTable(api.getUserList)   // 来源清晰
const { permissions } = usePermission()                // 来源清晰

缺陷二:命名冲突

javascript 复制代码
// 两个 mixin 都定义了 data.list,后者会无声覆盖前者
export default {
  mixins: [
    { data() { return { list: [] } } },  // userMixin
    { data() { return { list: [] } } },  // orderMixin → 覆盖了 userMixin 的 list!
  ]
}

// 组合函数通过重命名轻松解决
const { list: userList } = useUserList()
const { list: orderList } = useOrderList()

缺陷三:隐式的跨 mixin 交流

javascript 复制代码
// mixin A 依赖 mixin B 定义的 data.userId,但这个依赖是隐式的
// 删除 mixin B 或改了 userId 的名字,会产生难以追踪的 bug
const mixinA = {
  methods: {
    fetchDetail() {
      api.getDetail(this.userId)  // userId 从哪来的?魔法注入!
    }
  }
}

// 组合函数的依赖是显式的,通过参数传递
function useDetail(userId) {  // 明确的输入
  const detail = ref(null)
  watchEffect(() => {
    api.getDetail(userId.value).then(res => detail.value = res)
  })
  return { detail }
}

9.4 组合函数的命名和返回值约定

Vue 官方约定:

  1. 名称以 use 开头 ,驼峰命名:useMouseuseUserListusePermission
  2. 始终返回包含 ref 的普通对象(而不是返回 reactive 对象),这样解构后 ref 的响应性能保持
javascript 复制代码
// ✅ 正确:返回包含 ref 的普通对象,解构后仍是响应式的
export function useFoo() {
  const x = ref(0)
  const y = ref(0)
  return { x, y }  // 解构后 x, y 还是 ref,响应式保留
}

// ❌ 错误:返回 reactive 包装的对象,解构后失去响应式
export function useFoo() {
  const state = reactive({ x: 0, y: 0 })
  return state  // 解构后 x, y 变成普通数字,响应式丢失
}
  1. 只在 <script setup>setup() 的同步上下文中调用组合函数(不能在条件语句、普通函数或异步回调里调用)
javascript 复制代码
// ❌ 不能在 if 里调用
if (condition) {
  const { x } = useMouse()  // 错误!
}

// ❌ 不能在普通函数里调用
function handleClick() {
  const { x } = useMouse()  // 错误!
}

// ✅ 只在 setup 的顶层同步调用
const { x, y } = useMouse()  // 正确

这个限制的原因:Vue 需要在 setup 执行时知道当前是哪个组件实例,才能把生命周期钩子、computed、watch 绑定到正确的实例上。在异步或条件调用中,Vue 无法保证组件实例上下文的正确性。


10. <script setup>:编译层面的优化

<script setup> 是 Vue 3.2 引入的语法糖,是目前官方最推荐的单文件组件书写方式

10.1 三种写法对比

vue 复制代码
<!-- 方式一:Options API(Vue 2 风格) -->
<script>
export default {
  data() {
    return { count: 0 }
  },
  methods: {
    increment() { this.count++ }
  }
}
</script>

<!-- 方式二:setup() 函数(Composition API 过渡写法) -->
<script>
import { ref } from 'vue'
export default {
  setup() {
    const count = ref(0)
    const increment = () => count.value++
    return { count, increment }  // 必须手动 return,繁琐
  }
}
</script>

<!-- 方式三:<script setup>(推荐) -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
const increment = () => count.value++
// 无需 return,顶层变量和函数自动暴露给模板
</script>

10.2 <script setup> 的编译行为

<script setup> 的本质是编译时语法糖 ,编译器在构建时把它转换成 setup() 函数调用。Vue 官方文档说明了它的性能优势:

<script setup> 形式书写的组件模板被编译为了一个内联函数,和 <script setup> 中的代码位于同一作用域。不像选项式 API 需要依赖 this 上下文对象访问属性,被编译的模板可以直接访问 <script setup> 中定义的变量,无需从实例中代理。这对代码压缩更友好,因为本地变量的名字可以被压缩,但对象的属性名则不能。

10.3 <script setup> 中的特殊 API

vue 复制代码
<script setup>
import { ref, computed, defineProps, defineEmits, defineExpose } from 'vue'

// defineProps:声明接收的 props(替代 Options API 的 props 选项)
const props = defineProps({
  userId: {
    type: Number,
    required: true
  },
  title: {
    type: String,
    default: '默认标题'
  }
})

// TypeScript 风格的 props 声明(更推荐)
// const props = defineProps<{ userId: number; title?: string }>()

// defineEmits:声明可以发出的事件(替代 Options API 的 emits 选项)
const emit = defineEmits(['update', 'close'])
const handleUpdate = () => {
  emit('update', { id: props.userId, data: formData.value })
}

// defineExpose:显式声明对外暴露的属性(默认 <script setup> 的内容是私有的)
const formRef = ref(null)
const validate = () => formRef.value?.validate()
defineExpose({ validate })  // 父组件通过 ref 可以调用这个 validate 方法
</script>

11. TypeScript 集成:类型推导的天然优势

Composition API 与 TypeScript 的集成是它最被专业团队看重的特性之一。

typescript 复制代码
// 有了 TypeScript,ref 会自动推断类型
const count = ref(0)        // 类型:Ref<number>
const name = ref('')        // 类型:Ref<string>
const list = ref<User[]>([]) // 显式指定泛型:Ref<User[]>

// computed 类型自动推断
const totalCount = computed(() => list.value.length)
// 类型自动推断为 ComputedRef<number>

// 接口定义
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

// props 的 TypeScript 声明(<script setup> 专用)
const props = defineProps<{
  user: User
  visible: boolean
  onClose?: () => void
}>()

// withDefaults:为 TS 风格的 props 设置默认值
const props = withDefaults(defineProps<{
  title: string
  size?: 'small' | 'medium' | 'large'
}>(), {
  size: 'medium'
})

相比之下,Vue 2 的 Options API 要做到同等程度的类型推断,需要 vue-class-component + vue-property-decorator + 大量类型声明样板代码,且在 mixin 场景下仍然有类型推断空白。


12. 与 Vite 的关系:构建工具如何影响 API 使用体验

Composition API 是 Vue 框架的功能,Vite 是构建工具,两者处于不同的层面。但它们之间的协作关系非常紧密,理解这个关系有助于你写出更好的代码。

12.1 Vite 如何处理 .vue 文件和 <script setup>

Vite 本身不懂 .vue 文件,是 @vitejs/plugin-vue 插件负责处理单文件组件。这个插件的工作流程:

复制代码
.vue 文件(含 <script setup>)
          ↓ @vitejs/plugin-vue 插件解析
  ┌─────────────────────────────────────────┐
  │  <script setup> → 转换为 setup() 函数   │
  │  <template>     → 编译为渲染函数        │
  │  <style>        → 提取为 CSS 模块       │
  └─────────────────────────────────────────┘
          ↓ Vite 按需编译
  标准的 ESM 模块(浏览器可以直接执行)

<script setup> 中的模板编译是静态分析友好 的:编译器知道 countuserList 等变量是从 setup 作用域来的,可以直接生成最优的访问代码,而不需要通过 this 代理。

12.2 Vite 的 HMR 与 Composition API

Vite 的 HMR(热模块替换)与 Vue 的响应式系统有深度配合:

当你修改一个 .vue 文件:

  1. @vitejs/plugin-vue 检测到变化,分析改动的是 <template><script setup> 还是 <style>
  2. 如果只改了 <template>,只更新渲染函数,组件的响应式状态(ref/reactive 的值)完全保留
  3. 如果改了 <script setup>,需要重新执行 setup(),但 Vue HMR 会尝试保留尽可能多的状态

这种细粒度的 HMR 更新,正是建立在 Composition API 把状态和逻辑清晰分离的基础上。如果用 Options API,某些情况下 HMR 会更难做到精准。

12.3 Tree Shaking:Composition API 的天然优势

Vue 3 的所有 Composition API 函数(refreactivecomputedwatchonMounted......)都是具名 ES 模块导出

javascript 复制代码
// 你使用什么,就 import 什么
import { ref, computed, onMounted } from 'vue'

Vite 底层的 Rolldown/Rollup 可以进行 Tree Shaking ------分析代码,把没有用到的函数从最终 bundle 里去掉。如果你的项目完全用 Composition API,onUpdatedonBeforeMount 等你没用到的钩子不会出现在打包产物里。

相比之下,Options API 下 Vue 运行时需要支持所有选项,即使你用不到,相关代码也在包里。Vue 官方文档提到,如果整个项目都用 Composition API,可以通过编译时标记去掉 Options API 的支持代码,减少几 KB 体积。

12.4 unplugin-auto-import:Vite 插件与 Composition API 的结合

在 Vite 配置里使用 unplugin-auto-import,可以让 refcomputedonMounted 等 Vue API 自动导入,无需每个文件都写一行 import

javascript 复制代码
// vite.config.js
import AutoImport from 'unplugin-auto-import/vite'

export default defineConfig({
  plugins: [
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
    })
  ]
})

之后:

vue 复制代码
<script setup>
// 不需要这行了:
// import { ref, computed, onMounted } from 'vue'

const count = ref(0)       // 直接用
const doubled = computed(() => count.value * 2)
onMounted(() => console.log('ready'))
</script>

这个功能专门为 Composition API 的函数式风格设计,无法用于 Options API(Options API 是声明性对象,不是函数调用)。这体现了 Vite 插件生态和 Composition API 在工程化层面的深度协作。


13. 与 React Hooks 的对比:看起来像,但本质不同

Composition API 的灵感来源之一是 React Hooks,两者都是"基于函数的逻辑组合"。但 Vue 官方文档专门讨论了它们之间的根本差异

React Hooks 的设计问题:

  1. 每次渲染都重新调用 :React 的 hooks(useStateuseEffect 等)在每次组件渲染时都会重新执行。这产生了"闭包变量过期"问题,useCallbackuseMemo 的依赖数组必须手动维护,稍有不慎就会产生 bug。

  2. 不能在条件中使用 :React hooks 必须在组件顶层以相同顺序调用(不能写在 if 里),因为 React 用调用顺序来区分不同的 hook 状态。这是一个开发者必须死记的规则。

  3. 过度依赖记忆化 :频繁需要 useMemouseCallback 来避免不必要的重渲染,这增加了认知负担。

Vue Composition API 的设计:

  1. setup() 只调用一次 :Vue 的 setup() 在组件实例创建时只运行一次。之后的视图更新由响应式系统自动处理,不需要重新执行 setup()。没有"过期闭包"问题,没有依赖数组,没有 useMemo 的必要(computed 自动缓存)。

  2. 可以有条件地调用组合函数 :因为不依赖调用顺序,你可以在 if 语句里调用组合函数(注意:是组合函数,不是 lifecycle hooks)。

  3. 依赖自动追踪 :Vue 的响应式系统自动追踪所有依赖,不需要手动声明 deps 数组。watchEffect(() => { ... }) 会自动知道它依赖了什么。

javascript 复制代码
// React 的 useEffect,必须手动声明依赖
useEffect(() => {
  fetch(`/api/users/${userId}`)  // 用了 userId
}, [userId])  // 必须手动写 [userId],漏写会有 bug

// Vue 的 watchEffect,自动追踪依赖
watchEffect(() => {
  fetch(`/api/users/${userId.value}`)  // 自动知道依赖了 userId
  // 不需要声明依赖数组,自动追踪
})

14. 常见坑与最佳实践

14.1 reactive 整体替换问题

javascript 复制代码
// ❌ 常见错误
const form = reactive({ name: '', email: '' })
const handleReset = () => {
  form = reactive({ name: '', email: '' })  // 引用断开,响应式失效!
}

// ✅ 正确:原地重置
const handleReset = () => {
  Object.assign(form, { name: '', email: '' })
}

// ✅ 更好:用 ref,可以整体替换
const form = ref({ name: '', email: '' })
const handleReset = () => {
  form.value = { name: '', email: '' }  // 安全替换
}

14.2 在异步函数中等待后调用生命周期钩子

javascript 复制代码
// ❌ await 之后,组件实例上下文可能已经丢失
setup(async () => {
  await someAsyncOperation()
  onMounted(() => { ... })  // ⚠️ 在某些情况下不生效
})

// ✅ 生命周期钩子在 setup 同步部分注册
setup(() => {
  onMounted(async () => {
    // 在这里做异步操作,不要反过来
    await someAsyncOperation()
  })
})

14.3 传递响应式数据时保持响应性

javascript 复制代码
// ❌ 传 .value 给函数,传递的是当前的值,失去响应性
const count = ref(0)
watchCount(count.value)  // 函数只知道"现在的值是 0",不知道变化

// ✅ 传 ref 本身(或 getter 函数)
watchCount(count)          // 函数可以访问 count.value 并追踪变化
watchCount(() => count.value)  // getter 函数方式

// 组合函数最佳实践:用 toValue() 规范化输入
import { toValue } from 'vue'
function useFeature(maybeRef) {
  const value = toValue(maybeRef)  // 自动处理 ref、getter 和普通值
  // ...
}

14.4 watch 监听对象内部变化

javascript 复制代码
const user = ref({ name: '张三', age: 18 })

// ❌ 默认只监听 ref 本身的变化(整体替换),不监听内部属性
watch(user, () => { /* 只有 user.value = {} 时才触发 */ })

// ✅ 监听内部属性变化,需要 deep: true
watch(user, () => { /* user.value.name 变化也触发 */ }, { deep: true })

// ✅ 或者只监听某个特定属性(用 getter)
watch(() => user.value.name, (newName) => {
  console.log('name changed to', newName)
})

14.5 组合函数里的副作用清理

javascript 复制代码
// ✅ 在组合函数内部管理副作用的生命周期
export function useWindowResize() {
  const width = ref(window.innerWidth)

  const handleResize = () => {
    width.value = window.innerWidth
  }

  onMounted(() => {
    window.addEventListener('resize', handleResize)
  })

  onUnmounted(() => {
    // 必须清理!否则组件销毁后 handleResize 还在执行,引用已销毁的组件状态
    window.removeEventListener('resize', handleResize)
  })

  return { width }
}

15. 何时保留 Options API?

Vue 官方明确表态:不会废弃 Options API,它依然是 Vue 的一部分

以下场景建议保留或优先使用 Options API:

场景 建议 原因
组件逻辑简单(< 60 行) Options API 结构直观,不需要组合函数的灵活性
团队以 Vue 2 背景为主 保持 Options API 减少迁移成本和认知负担
老项目增量维护 保持原有风格 混用两种 API 增加维护复杂度
教学或演示 Options API 更直观,适合入门讲解
需要在多个逻辑关注点间复用、有 TypeScript 需求 Composition API 组合函数优势明显
新建大型后台系统(多人协作) Composition API + <script setup> 长期可维护性更好

我们的项目(后台管理系统,多人协作)更适合 Composition API + <script setup>,但迁移要循序渐进,不要为了"用新 API"而强制改写正常运行的老代码


小结

读完这篇,希望你对 Composition API 的理解从"换个写法"升级到"理解设计意图":

  1. Composition API 解决的核心问题是 Options API 在大型组件里的"逻辑关注点分散"------相关代码被强制拆分到不同选项中,难以维护和复用。

  2. 响应式系统基于 JavaScript Proxy ,读时收集依赖,写时触发更新。ref 用包装对象解决了基本类型无法被 Proxy 拦截的问题,.value 是这个设计的必然产物。

  3. reactive() 有三个明确的局限 (只能处理对象、不能整体替换、解构丢失响应式),官方建议优先使用 ref()

  4. 组合函数(Composables)是 Composition API 最重要的应用模式,它比 mixin 有清晰的来源、无命名冲突、显式的依赖三大核心优势。

  5. <script setup> 是编译层面的优化,不只是语法糖------同作用域的模板和变量使得代码压缩更彻底,生产包体积更小。

  6. 与 Vite 的关系@vitejs/plugin-vue 负责编译 .vue 文件,Vite 的 HMR 对 Composition API 有专门优化,Tree Shaking 天然支持按需引入 Vue API,unplugin-auto-import 进一步简化开发体验。两者是工具链与框架特性层面的深度协作。

  7. 与 React Hooks 的本质差异 :Vue 的 setup() 只运行一次,响应式系统自动追踪依赖,没有"过期闭包"问题,不需要依赖数组和 useMemo

相关推荐
Cxiaomu2 小时前
React Native 双端一体工程,如何实现分端运行与分端打包?
javascript·react native·react.js
踩着两条虫2 小时前
从一行代码到一个生态:VTJ.PRO的创作之路
前端·低代码·ai编程
幼儿园技术家3 小时前
嵌套 H5 的跨端通信:iOS / Android / 小程序 / 浏览器
前端·js or ts
冰暮流星3 小时前
javascript之dom访问属性
开发语言·javascript·dubbo
一只小阿乐3 小时前
TypeScript中的React开发
前端·javascript·typescript·react
用户9714171814273 小时前
vite项目开发环境启动白屏
前端
Highcharts.js3 小时前
Highcharts客户端导出使用文档说明|图表导出模块讲解
前端·javascript·pdf·highcharts·图表导出
上山打牛3 小时前
cornerstone3D 通过二进制渲染影像
前端
华仔啊3 小时前
GitHub 25k Star!这款开源录屏工具,免费无水印可商用,彻底告别付费
javascript