我最早接触Vue 3的组合式API(Composition API),是在一个维护了大半年的老后台项目上。那时候项目已经堆了几千行选项式代码,业务逻辑全都散在data、computed、watch、methods里,每改一个需求,至少得在文件里上下翻好几次,有时候改着改着就忘了自己刚才看的是哪个部分。后来项目决定往Vue 3迁移,我才真正把组合式API从“会用”变成了“理解它为什么这么设计”。
这篇文章不打算写成官方文档的复读版,也不做那种“ref和reactive的区别”一句话速查。我会从一个实际开发者的角度,把组合式API的设计动机、核心API的底层逻辑、代码组织方式、迁移实战、常见坑点完整走一遍,并且把大量真实项目中会遇到的问题一并列出来。内容比较长,适合正在学Vue 3的初学者,也适合已经开始用组合式API但想系统梳理一遍的开发者。看之前建议先装好Vue 3的开发环境,打开编辑器,边看边敲。
1. 先搞清楚:组合式API到底为了解决什么问题
1.1 选项式API的三个老大难问题
在聊组合式API之前,得先理解选项式API(Options API)的痛点,否则你很难理解Vue 3为什么要把整个组件组织逻辑推翻重来。
第一个痛点,是同一个业务逻辑的代码被彻底拆散。比如一个用户列表页面,需要加载数据、筛选、分页、导出,在选项式API里,这些逻辑会各自占一块地方:数据放在data,计算属性放在computed,触发放在methods,监听放在watch,初始化放在mounted。当这个页面超过500行以后,你想看"筛选功能"的完整链路,就得在data里找筛选条件字段,在computed里找筛选后结果,在watch里找筛选条件的监听,在methods里找筛选触发函数——一个功能的代码至少被拆到四个区域,维护时非常割裂。
第二个痛点,是逻辑复用困难。选项式API唯一的复用手段是mixin。mixin本身有几个硬伤:数据来源不透明,组件里用了mixin以后,根本分不清这个data里的字段到底是own的还是mixin注入的;多个mixin之间的命名冲突也很头疼;还有优先级问题——mixin和组件自身的同名属性谁的优先级高,这在项目大了以后会变成一个靠猜的问题。直接导致的结果就是,mixin只适合放非常简单的工具逻辑,稍微带点业务,复用起来就非常痛苦。
第三个痛点,是TypeScript支持不够友好。选项式API中的this是魔法字符串,组件里所有数据、方法、计算属性全部挂在this上,TypeScript推导起来需要一堆类型声明辅助,写起来非常费劲。项目规模一大,类型问题就成了隐形炸弹。
1.2 组合式API是怎么解题的
组合式API的核心思路很简单:把"按选项(option)组织代码"改成"按逻辑关注点(feature)组织代码"。
用一句话说清楚,就是一个功能的所有相关代码,可以放在一起,写成一个独立的函数或者一个集中的区域,然后被不同组件复用。
同时,组合式API没有this,所有响应式数据都是通过ref和reactive显式创建的变量。没有了this,代码就可以脱离组件上下文独立存在,这为逻辑抽离和类型推导打好了基础,也让TypeScript的体验提升了一个量级。
这里需要强调一点:组合式API不是要完全取代选项式API,它不是革命,而是演进。Vue 3里选项式API依然能用,而且很多中小项目用选项式API开发起来效率依然很高。组合式API的优势场景是复杂组件、需要逻辑复用的中大型项目,以及需要严格类型约束的TypeScript项目。你完全可以两种混着写,不会出问题。
2. 核心API逐个拆解,ref与reactive背后的响应式原理
2.1 为什么要区分ref和reactive
这是组合式API里问得最多的问题,也是面试高频题。我以前也天真地想过"为什么不用一个API两套方案",后来踩坑多了才明白,这个区分是响应式系统的底层机制决定的。
先看reactive。它接受一个对象,返回对象的响应式代理,底层用的是Proxy。Proxy可以拦截对象属性的读取、写入、删除、遍历等各种操作,所以对嵌套对象能实现深度的自动响应。但Proxy只能代理对象,不能代理原始类型(string、number、boolean、null、undefined、symbol、bigint)。
再看ref。为了处理单个原始值,Vue设计了一个包装对象,内部存一个.value属性,读取和修改都走.value,然后通过依赖收集和触发更新来实现响应式。所以ref本质上是一个盒子,把任意值(包括对象)装进{ value: ... }里。
使用建议是这样的:
- 单个基础类型值,用
ref。 - 表单对象、接口数据对象、组件整体state,用
reactive。 - 如果你无法确定用哪个,就用
ref。因为ref内部对对象值会自动用reactive处理,也就是说ref({ ... })和reactive({ ... })底层其实是同一套机制,ref只是多包了一层value。
这里有一个很多新手会踩的坑:reactive对象直接整个替换,会丢失响应式。因为响应式是通过Proxy代理实现的,你把state.xxx = newObj换成整个state = newObj,实际上是把变量指向了一个新的普通对象,Proxy失效。这种场景应该用ref(因为ref.value替换不会丢响应),或者用好Object.assign(state, newObj)。
2.2 ref的深层响应式与浅层响应式
ref对嵌套对象有递归包装的机制,访问任意深度的属性都是响应式的。但有时候我们不需要这样的深层响应,比如一个大列表里只有个别字段需要响应更新,深层代理会带来额外性能开销。这时候可以用shallowRef和shallowReactive这两个浅层版本。
shallowRef只代理.value这一层,不会递归处理.value内部的对象。shallowReactive同理,只跟踪对象第一层属性。使用场景很典型:比如一个监控大屏页面,每一秒整体替换一次数据对象,这时候用shallowRef就够了,深层的每个字段代理纯属浪费性能。
还有一个进阶用法,triggerRef。当你手动改变了shallowRef内部对象某个深层字段(这个操作不会被追踪),需要强制触发更新时,调用triggerRef(value)手动派发更新。这种API在实际业务里用得少,但面试时提出来,会显得你对响应式系统的理解不是停留在表面。
2.3 toRefs、toRef、解构与响应式丢失
这是实战里最容易被坑的地方。你写了这么一段代码:
const state = reactive({ count: 0, name: 'vue' }) const { count, name } = state然后你在模板里绑定count,点击按钮修改count,页面纹丝不动。原因就是:结构赋值把原始值取出来了,count只是那个时刻的快照,和响应式对象已经没有任何绑定关系。
解决办法是用toRefs。它会遍历reactive对象的每个属性,把每个属性都转成对应的ref,保证解构出来的变量依然是响应式的。
import { reactive, toRefs } from 'vue' const state = reactive({ count: 0, name: 'vue' }) const { count, name } = toRefs(state)还有一个toRef,只转换单个属性,适用于你只需要一个属性的场景,或者props里的某个字段需要独立传给组合函数时。比如:
const props = defineProps(['visible']) const visibleRef = toRef(props, 'visible')然后用visibleRef.value去访问,就不再容易丢失响应。
我在项目里总结的经验是:组件内部定义的state,尽量统一用reactive或ref管理,不要频繁解构;传递参数给子组件或组合函数时,优先传ref本身,而不是解构后的值。保持引用关系,比多写几行代码更重要。
3. 计算属性和监听器的正确姿势
3.1 computed的懒执行与缓存机制
computed的本质可以理解成一个有缓存功能的响应式计算器。它只在依赖变化时才重新计算,而且只要依赖不变化,多次访问都会直接返回缓存结果,不会重复执行getter函数。
对比一下computed和普通函数:
const fullName = computed(() => `${firstName.value} ${lastName.value}`) function getFullName() { return `${firstName.value} ${lastName.value}` }如果模板里同时用了fullName和getFullName()十次,前者只会执行一次getter,后者每次渲染都要执行十次。性能差异可能不大,但背后的意义是:computed是声明式的,你声明了依赖关系,框架负责追踪和更新;函数是命令式的,每次调用都需要现场计算。
这里要注意,computed默认是只读的。不要试图直接fullName.value = 'xxx',会报错。如果确实要修改,需要定义成可写的计算属性:
const fullName = computed({ get() { return `${firstName.value} ${lastName.value}` }, set(value) { const names = value.split(' ') firstName.value = names[0] lastName.value = names[1] } })3.2 watch、watchEffect,到底选哪个
watch和watchEffect的区别,一句话能说清:watchEffect是"只要用到啥就监听啥,一上来先跑一遍",watch是"明确指定看哪个,变化了才触发,默认不立即执行"。
watchEffect适合场景:你希望自动收集依赖,而不想手动写依赖列表。比如根据多个筛选条件请求列表,可以把所有筛选条件都放进一个watchEffect里,它自动追踪这些ref,任何一个变化都会重新执行请求函数。
watch适合场景:需要监听特定数据变化,然后做后续操作。它比watchEffect多了几个好处:可以访问新旧值;可以配置deep深度监听;可以配置immediate立即执行;可以声明多个来源。
还有一个容易被忽略的写法,监听一个getter函数而不只是响应式变量:
watch( () => [props.page, props.pageSize], ([newPage, newPageSize], [oldPage, oldPageSize]) => { // 分页参数变化后重新请求 } )这种写法在监听多个来源、或者只想监听复杂对象中某几个字段时非常实用。
3.3 监听器里的flush时机和清理副作用
默认情况下,watch和watchEffect的回调会在组件更新之前执行,也就是DOM还没更新。如果你需要在DOM更新后再执行回调(比如拿到最新的元素高度、宽度),就得配置flush: 'post'。对应的,还有flush: 'sync',表示数据变化后同步执行回调,这会让更新非常频繁,一般不建议使用。
另一个重要的点是清理副作用。在watchEffect里,如果请求操作是异步的,上一个请求还没返回,下一个请求就发出去了,就可能出现竞态问题——后发先至,数据被旧请求覆盖。解决办法是使用onCleanup,在副作用重新执行前,先取消上一次的异步操作:
watchEffect(async (onCleanup) => { const cancelToken = axios.CancelToken.source() onCleanup(() => cancelToken.cancel()) const res = await fetchList({ cancelToken: cancelToken.token }) list.value = res.data })watch也支持同样的清理逻辑,在第三个回调参数里接收onCleanup。这里是我实际项目里踩过很多坑的地方,刚开始不清理旧请求,列表数据总是一跳一跳的,排查了很久才发现是竞态。
4. 生命周期与setup的执行细节
4.1 setup在组件实例创建中的位置
setup是一个特殊的入口函数,它在组件实例创建之前执行,早于beforeCreate。在Vue 2时代,data和methods的初始化发生在组件实例创建过程中;在Vue 3组合式API里,setup返回的数据就直接作为组件渲染的数据源。
需要注意,setup执行时,组件实例还没有完全创建好,所以你不能访问this。这也是组合式API故意去掉this的原因——与其让你拿一个"半成品"this去踩坑,不如直接不提供。
4.2 组合式API的生命周期钩子对照
组合式API提供了一套onXxx函数,和选项式API的beforeXxx、xxxed对齐,对应关系如下:
| 选项式API | 组合式API |
|---|---|
| beforeCreate | setup本身 |
| created | setup本身 |
| beforeMount | onBeforeMount |
| mounted | onMounted |
| beforeUpdate | onBeforeUpdate |
| updated | onUpdated |
| beforeUnmount | onBeforeUnmount |
| unmounted | onUnmounted |
| errorCaptured | onErrorCaptured |
| activated | onActivated |
| deactivated | onDeactivated |
beforeCreate和created因为没有this,在组合式API里没有对应的on函数,它们做的事直接在setup里同步执行就行。之前有人问"我要在created里请求接口,怎么写",答案很简单:直接在setup里调用请求函数,效果一样。
还有一个onRenderTracked和onRenderTriggered,是调试响应式依赖的好帮手。开发模式下,onRenderTriggered会在组件重新渲染时告诉我们哪个状态变化触发了更新,对排查"为什么这个组件总是重渲染"非常有用。
4.3 需要特别留意的子组件暴露
组合式API里,父组件通过ref获取子组件实例时,默认拿不到子组件内部的任何方法或响应式变量。这是故意的——是为了封装,避免父组件随意操作子组件内部状态。
如果你确实需要暴露某些属性或方法,就在子组件里通过defineExpose明确声明:
// 子组件 const open = () => { /* ... */ } const close = () => { /* ... */ } defineExpose({ open, close })这样父组件拿着子组件的ref实例,就只能调用这两个方法,内部数据完全隔离。在封装通用组件、弹窗组件、表格组件时,这个API基本是必用的。
5. 组件通信:props、emit、v-model与provide/inject
5.1 props解构后还能响应式吗
组合式API里访问props很简单,const props = defineProps(['visible']),然后直接props.visible使用。但很多人在setup里直接解构props,这就会遇到和reactive对象解构一样的响应式丢失问题:
const { visible } = defineProps(['visible'])解构出来的visible变成一个普通变量,父组件更新props时,子组件不会感知到。
如果想解构又保持响应式,用toRefs或toRef:
const props = defineProps(['visible']) const { visible } = toRefs(props)这样visible变成了一个ref,模板里会自动解包,逻辑里用visible.value访问,父组件更新时响应正常。
5.2 emit的类型声明与回调触发
组合式API里触发事件需要显式声明emit:
const emit = defineEmits(['change', 'update:visible'])然后通过emit('change', payload)触发。TypeScript项目里,defineEmits支持更严格的类型约束,可以精确到参数类型:
const emit = defineEmits<{ (e: 'change', value: string): void (e: 'update:visible', visible: boolean): void }>()这让编译期就能发现问题,比如事件名拼错、参数类型不一致,都会直接报类型错误。
5.3 v-model在组合式API里的实现方式
v-model在Vue 3里的底层是modelValueprop和update:modelValue事件。自定义组件想要支持v-model,只需要定义这两个东西:
defineProps(['modelValue']) const emit = defineEmits(['update:modelValue']) function update(value) { emit('update:modelValue', value) }如果用到多个v-model,也是同理。比如:
<SearchPanel v-model:keyword="keyword" v-model:pageSize="pageSize" />子组件就接收keyword和pageSize两个prop,分别触发update:keyword和update:pageSize事件。这种多v-model的写法,在封装高级筛选组件时非常方便。
5.4 provide/inject解决深层组件通信
跨多级组件通信时,以前用props一层层传非常痛苦,Vue 3里用provide和inject舒服很多。组合式API下,provide和inject都可以直接在setup里调用:
// 祖先组件 import { provide, ref } from 'vue' const theme = ref('dark') provide('theme', theme) // 后代组件 import { inject } from 'vue' const theme = inject('theme')注意,inject接收的默认值如果依赖响应式数据,需要用工厂函数返回。更重要的是,provide传出去的值如果是ref,那后代组件修改这个值,会直接改掉祖先组件的状态。这在传状态的同时传一个更新函数,可以做成一个轻量级状态管理方案,适合中小型项目。
在我参与的一个中后台项目里,整套用户权限信息就是用provide注入的,子组件、孙子组件直接inject使用,省掉了大量层层透传的props。
6. 组合式函数:这才是逻辑复用的终极形态
6.1 从mixin到组合式函数,重构后有多爽
前面说了mixin的种种问题,组合式函数(Composables)就是来终结这些问题的。它本质上就是一个普通函数,内部用组合式API创建状态、定义方法、注册生命周期钩子,最后返回需要对外暴露的状态和方法。
以最常见的鼠标坐标跟踪为例,写一个useMouse:
// 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 } }任何组件里想用鼠标位置,就一行:
const { x, y } = useMouse()对比mixin,组合式函数有几个根本优势:数据来源清晰(返回值就是全部数据);没有命名冲突(每个变量都是局部变量,返回值名是调用方自己定的);可以嵌套组合(一个组合式函数内部可以调用另一个组合式函数);类型推导完全正常。
6.2 手写一个播放m3u8的组合式函数
实际业务里经常遇到视频播放,尤其是流媒体m3u8格式。以HLS协议为例,播放m3u8通常借助hls.js这样的库。可以把它封装成一个组合式函数,使用起来非常干净:
// composables/useHlsPlayer.js import { ref, onMounted, onUnmounted } from 'vue' export function useHlsPlayer(videoElement) { const playing = ref(false) const error = ref('') let hlsInstance = null async function load(url) { if (!videoElement.value) return if (hlsInstance) { hlsInstance.destroy() } // 原生支持 HLS 的浏览器(如 Safari),直接用 video 标签 if (videoElement.value.canPlayType('application/vnd.apple.mpegurl')) { videoElement.value.src = url return } // 其余浏览器使用 hls.js 播放 m3u8 const Hls = (await import('hls.js')).default if (Hls.isSupported()) { hlsInstance = new Hls({ enableWorker: true }) hlsInstance.loadSource(url) hlsInstance.attachMedia(videoElement.value) hlsInstance.on(Hls.Events.ERROR, (_, data) => { error.value = data.fatal ? '播放出错' : '' }) } } function play() { videoElement.value?.play() playing.value = true } function pause() { videoElement.value?.pause() playing.value = false } onUnmounted(() => { if (hlsInstance) hlsInstance.destroy() }) return { playing, error, load, play, pause } }这样组件里只需要传入video的ref即可,其他的安装、销毁、兼容性判断全部封装进组合式函数里,代码非常清晰。
6.3 请求封装:token处理与列表请求
前后端分离项目里,最常做的封装不是请求库,而是接口请求的组合式函数,把loading、data、error、刷新、分页全部管理起来。
// composables/useRequest.js import { ref, watch } from 'vue' export function useRequest(fetcher, options = {}) { const data = ref(null) const loading = ref(false) const error = ref(null) async function run(...args) { loading.value = true error.value = null try { data.value = await fetcher(...args) } catch (e) { error.value = e } finally { loading.value = false } } if (options.immediate) { run(...options.immediateParams) } return { data, loading, error, run } }请求拦截器里统一处理token:请求发出前,从某个地方取出token拼到Authorization头;响应返回401时,尝试用refreshToken刷新,失败则跳转登录页。这些逻辑全部可以收敛到一个地方,不会散落在各个组件里。组合式函数封装请求后,页面里只需要:
const { data, loading, error, run } = useRequest(fetchList, { immediate: true })接口来回传参、加载状态、错误处理,一行就管理完了。
7. 从选项式迁移到组合式,项目落地实战
7.1 渐进式迁移,别想着一步到位
迁移一个老项目最忌讳的就是"推倒重写"。我在实际项目中见过团队拍板"全部重写Vue 3",结果业务复杂、周期长,最后项目延期,前端代码也乱成一锅粥。渐进式迁移才是稳妥的路子。
第一步,先把项目从Vue 2迁移到Vue 3(或者直接用Vue 3新建项目),这时候还可以继续用选项式API,保证业务功能不受影响。第二步,优先在新增页面、新组件里用组合式API,养成写组合式函数的习惯。第三步,遇到需要修改逻辑的旧组件,顺手改造,但不要为了改造而改造——如果组件本身就100行,选项式完全够用,没必要硬改。
7.2 项目结构和命名规范参考
组合式API没有强制目录结构,但我建议把组合式函数统一放到src/composables目录,保存状态管理相关的内容又不一样。下面是一个我实践中不错的目录方案:
src/ ├── composables/ │ ├── useRequest.js # 通用请求 │ ├── useTableList.js # 列表分页 │ ├── useHlsPlayer.js # 视频播放 │ └── usePermission.js # 权限判断 ├── components/ ├── views/ └── utils/组合式函数的命名统一用use开头,一眼就能识别。如果一个组合式函数内部逻辑超过200行,建议再拆成更小的函数,而不是一个函数搞定所有事。比如useTableList可以继续拆成usePagination和useFetchList两个内部函数。
7.3 路由参数、环境配置这类常见场景怎么写
组合式API下拿路由参数很简单,但有几个坑要注意。Vue Router 4里,useRoute和useRouter都是组合式API提供的函数:
import { useRoute, useRouter } from 'vue-router' const route = useRoute() const router = useRouter() // 获取当前路由参数 const id = route.params.id // 编程式导航 router.push({ name: 'user-detail', params: { id: 1 } })监听路由参数变化,用watch:
import { watch } from 'vue' watch(() => route.params.id, (newId, oldId) => { // 切换详情页时重新加载 })环境配置这块,Vue 3配合Vite的话,通过import.meta.env.VITE_XXX读取,建议封装成一个组合式函数,统一暴露当前环境、接口地址等:
// composables/useEnv.js export function useEnv() { const isProd = import.meta.env.PROD const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || '/api' return { isProd, apiBaseUrl } }其实可以看到,组合式API对整个开发思路的影响是贯穿的,不只是写ref和reactive这么简单,更像是一套"逻辑内聚"的组织哲学在起作用。
8. 组合式API常见坑与问题排查速查
这一节把我自己在项目中踩过的问题,以及群里大量同行反馈过的高频问题整理成一个速查表,建议收藏。
| 问题 | 现象 | 原因 | 解决办法 |
|---|---|---|---|
| 对象赋值后页面不变 | 修改了reactive对象的属性,UI没更新 | 把整个对象重新赋值,导致Proxy丢失 | 用Object.assign合并,或改用ref |
| 解构后失去响应式 | 从reactive或props解构出变量后,UI不更新 | 基础类型被复制,和响应式对象断开了引用 | 用toRefs或toRef |
| watch不触发 | 监听reactive对象里的数组,改变元素后没反应 | 数组索引赋值或length修改不是响应式的(Vue 3的Proxy对索引赋值其实能触发,但某些方法如直接arr[0] = xxx在特定场景仍可能不更新) | 用splice、push等数组方法,或者整体重新赋值 |
| watchEffect频繁执行 | 一些请求重复触发 | 依赖了模板中用到的响应式状态,或者依赖范围过大 | 用watch明确指定依赖,或用flush: 'post'控制时机 |
| setup中拿不到this | 想在setup里访问全局实例 | 组合式API故意移除this | 用getCurrentInstance()或依赖注入 |
| provide/inject类型不明 | 注入后是undefined | 祖先组件没有调用provide,或key不一致 | 确保Key值统一,最好抽到常量文件 |
| 列表渲染时ref失效 | 用ref获取v-for子组件实例拿到null | v-for里ref不自动收集 | 用函数ref或模板引用数组 |
| 异步组件加载失败 | 动态import报错 | 路径错误或者网络问题 | 检查路径,配置ErrorComponent |
8.1 关于"赋值页面不变"的深入排查
这个问题的出现频率真的很高。除了前面说的整个reactive对象直接替换之外,还有几种隐藏情况。
一种是嵌套层级过深。如果reactive对象里嵌套了一个Map或Set,Vue 3的响应式系统是能够追踪的,但如果你替换了整个集合对象(比如state.map = new Map()),在某些情况下更新触发是够的。建议对集合类型的更新,尽量使用原对象的方法(set、delete、add),而不是整体替换。
另一种情况是数据通过Object.assign合并后出现响应式丢失。Object.assign本质上把目标对象的所有属性覆盖成新值,这个操作在Proxy下是可以被追踪的,但前提是目标对象本身是reactive或ref的value。如果你的目标对象不是响应式对象,那自然没有任何响应。
8.2 调试工具与模板编译的小诀窍
Vue 3开发建议装上Vue Devtools插件,它可以直观看到组件树、props、组合式API里定义的所有ref/reactive的数据,以及性能时间线。排查组合式API渲染问题时,重点看插件里"Composition API"的状态面板,比在模板里加console.log高效得多。
如果遇到"模板里能读变量,但事件监听里读到的是旧值",大概率是闭包问题。比如在setTimeout回调里访问某个ref,它可能已经变了,但你访问的是旧闭包。解决办法是重新读一次ref.value,或者用watch来同步最新值。
9. 组合式API场景扩展:复杂中后台项目如何组织
9.1 大量中后台页面都可以抽成什么组合式函数
中后台项目的页面高度相似:列表、搜索、分页、弹窗、表单、联动。这些UI模式非常适合抽成组合式函数。比如列表页,可以抽一个useTableList,把搜索条件、分页参数、列表数据、loading、刷新、重置全部集中管理:
// composables/useTableList.js import { ref, reactive } from 'vue' export function useTableList(fetchApi, options = {}) { const list = ref([]) const total = ref(0) const loading = ref(false) const query = reactive(options.defaultQuery || {}) const page = ref(1) const pageSize = ref(options.defaultPageSize || 20) async function fetchList() { loading.value = true try { const res = await fetchApi({ ...query, page: page.value, pageSize: pageSize.value }) list.value = res.list total.value = res.total } finally { loading.value = false } } function handleSearch() { page.value = 1 fetchList() } function handlePageChange(current, size) { page.value = current pageSize.value = size fetchList() } function handleReset() { Object.keys(query).forEach((key) => { query[key] = '' }) page.value = 1 fetchList() } return { list, total, loading, query, page, pageSize, fetchList, handleSearch, handlePageChange, handleReset } }页面里使用:
const { list, total, loading, query, page, pageSize, fetchList, handleSearch, handlePageChange, handleReset } = useTableList(getUserList)再配合搜索表单、表格、分页组件,一个列表页的代码能减少三分之一。而且这些逻辑可以完全复制到另一个相似页面,只换接口和查询条件即可。
9.2 表格导出Excel这类跨页面的协作逻辑
中后台里经常遇到"当前页表格导出,但跨多个表格导出"的场景,比如多个表格合并导出一个Excel。这种跨模块的业务逻辑,组合式API也能组织得很清楚。先用一个组合式函数封装导出功能:
// composables/useExportExcel.js import { ref } from 'vue' export function useExportExcel() { const exporting = ref(false) async function exportTables(tables, options = {}) { exporting.value = true try { // 动态引入 Excel 导出库 const XLSX = await import('xlsx') const workbook = XLSX.utils.book_new() tables.forEach(({ sheetName, data }) => { const worksheet = XLSX.utils.json_to_sheet(data) XLSX.utils.book_append_sheet(workbook, worksheet, sheetName) }) XLSX.writeFile(workbook, options.fileName || 'export.xlsx') } finally { exporting.value = false } } return { exporting, exportTables } }多个页面的表格数据通过组合式函数统一收集,合并成一个工作簿导出,整个过程不会污染全局状态,每个页面只知道自己拿到什么数据、传给导出函数就行。
10. 再聊几个容易忽略的细节与经验总结
10.1 关于组合式API与性能优化
组合式API本身不直接带来性能提升,但它更容易写出高性能的代码,原因是逻辑内聚后,你可以更清晰地控制响应式依赖的范围。比如一个小组件只关心一个ref,Vue 3的依赖追踪可以让这个组件在别的状态变化时完全跳过更新,这就是Vue 3比Vue 2性能好很多的底层原因——多组件的依赖追踪粒度更细。
实际优化中,有几个组合式API相关点要特别注意:
- 尽量避免在
watchEffect里进行大量同步计算,依赖频繁变化时会造成不必要的性能开销。 - 大列表用
shallowRef或shallowReactive,配合不可变数据更新方式(整体替换),减少深层代理开销。 computed里不要写有副作用的代码,计算属性应该保持纯粹,否则可能在渲染过程中触发意外更新,开发模式还会警告。- 异步组件的
defineAsyncComponent结合组合式API使用,可以把一个页面拆成多个独立模块按需加载,首屏速度提升明显。
10.2 中后台开源项目参考
如果想看组合式API在大项目里的完整应用,建议去看一些开源项目,比如 Vue Vben Admin 是基于Vue 3 + TypeScript + Vite的经典中后台解决方案,它内部对组合式API的使用非常系统,把权限、类型、路由、状态管理都做成了组合式函数。还有Nuxt 3,作为Vue 3的框架级方案,它的数据请求useFetch、useAsyncData都是组合式API的直接体现。
这些项目不是单纯让你拿来抄,而是学习思路:怎么对业务场景做抽象,怎么把逻辑和UI分离,怎么处理全局状态和局部状态的关系。
10.3 组合式API和选项式API混用时的注意点
虽然Vue 3允许两种API混用,但尽量在一个组件内保持风格统一,不要一半是setup、一半是options。特别是不要在某些需要this访问的场景里强行混用,否则代码可读性会很差。如果团队整体用选项式API开发,那就统一用选项式;如果用组合式,就统一组合式。混着写不是不行,但维护成本会变高。
还有一点,如果组件里有多个逻辑关注点,比如"统计+筛选+导出",选项式还能基本应付,但组合式明显更优。反过来,如果组件本身很简单,比如只展示一个静态卡片,用选项式反而更紧凑。要按实际情况选。
10.4 对新手入门组合式API的建议
新手最容易犯的错是"什么都塞进setup",把setup写成了巨型函数,几百行代码堆在一起。组合式API的优势在于拆分,不是把所有代码都写在setup里。正确做法是:setup里只保留组件的组装逻辑——定义props/emits、调用几个组合式函数、返回模板需要的数据。其他复杂逻辑都应该抽到组合式函数中去。
如果你刚开始学,我的建议是从"ref和reactive的响应式机制"入手,先彻底理解这两个API,然后练习写简单的组合式函数(比如倒计时、鼠标跟踪),再把请求、分页这类实际业务场景封装成组合式函数,慢慢就会形成"逻辑角度"思考的习惯。
我在实际开发中最大的体会是,组合式API学起来不难,但真正掌握是从"思维方式转变"开始的。你不再是站在"组件选项"的角度去堆代码,而是站在"业务逻辑"的角度去组织代码。一旦跨过这个门槛,再看复杂项目的前端代码,会清爽很多。
如果有条件,强烈建议把你自己正在维护的某一个模块,用组合式API重写一遍试试。不用管结果好不好,先体验一遍从"分散"到"内聚"的整个过程,你对这套API的理解会彻底不一样。