news 2026/10/15 8:48:34

Vue 3 组合式 API 实践指南:ref、reactive、组件通信与性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue 3 组合式 API 实践指南:ref、reactive、组件通信与性能优化

这是我个人长期记录的 Vue 学习笔记系列,今天写到了第五篇。先说清楚这篇笔记的内容定位:如果你已经学过 Vue 的基础用法——模板、指令、组件、生命周期,现在想真正把 Vue 3 用明白,想搞懂组合式 API 里ref、reactive、watch这些 API 到底怎么选,组件通信什么时候该用emit、什么时候该上provide/inject,项目跑起来后页面卡顿、内存涨上去该怎么排查——那么这篇笔记基本就是给你准备的。

笔记不会像官方文档那样逐个 API 罗列,而是把我自己在几个真实项目里反复试错后沉淀下来的判断方式和操作套路写下来,避免所有内容都写成"文档翻译"。看的时候请记住一个前提:技术选型没有绝对最优,只有结合场景的取舍。这篇笔记记录的就是这种取舍过程。

1. 从选项式到组合式:为什么我决定彻底切换

1.1 组合式 API 到底把什么问题解决了

早年在用 Vue 2 写中大型页面时,最难受的并不是语法复杂,而是逻辑碎片化。一个功能模块通常会涉及data里的几个字段、computed里的一个派生值、mounted里的初始化请求、methods里的三个操作函数。如果只是一个小页面还能忍,当页面里面有四五个业务模块时,options结构就把同一份业务逻辑强行拆到了不同的块里,查看一条完整链路要在代码里上下翻跳,维护成本高得离谱。

组合式 API 的核心思路就是允许你按照业务维度去组织代码。把某一块业务相关的响应式状态、计算属性、监听器、方法都放进一个独立的可复用函数里,比如useUserInfo、useTableList。这样组件本体就只做组合和编排,真正的业务逻辑都封装到了各自的 hooks 中。

我实际做过一个对比测试:同样写一个带筛选条件的列表页,Vue 2 选项式版本大概需要在script里写 280 行,而 Vue 3 组合式 API 版本把筛选逻辑、分页逻辑、数据请求拆成三个 hooks 之后,setup里面只剩下几十行调用代码。虽然总行数差距不大,但调试和扩展的体验完全不一样。比如后来要在一个新页面复用同样的筛选逻辑,选项式只能复制粘贴再改一堆接口名,而组合式版本直接引入对应 hook 即可,甚至不用看内部实现。

1.2 迁移时最容易踩的坑

从 Vue 2 切换到 Vue 3,第一直觉是跟着文档改语法,但实际上最需要调整的是心智模型。

第一个坑是this的消失。组件内部不再有this.xxx,所有响应式变量都通过ref和reactive显式声明。很多人一开始会在setup里写const count = ref(0),然后模板中直接使用count,没问题。但在一不小心在setTimeout回调里写count.value++时会下意识继续写this.count.value++,然后报错。这个需要一两周才能完全适应。

第二个坑是生命周期的表达方式变化。beforeDestroy变成了onBeforeUnmount,destroyed变成了onUnmounted,而且新增了onActivated、onDeactivated用于keep-alive场景。如果你在 Vue 2 里习惯用watch监听$route,在 Vue 3 里需要改用useRoute()+watch的组合,因为选项式里的watch: { $route }在组合式 API 中不再生效。

第三个坑比较隐蔽:reactive不能随意解构。解构出来的字段会失去响应式追踪,日常开发中我建议除了ref之外,能用reactive就把整个对象传给一个computed或watch。真要解构时要么用toRefs,要么直接改用多个ref。我见过好几个同事在这个问题上卡了一下午,最后怎么改都觉得数据不同步,其实就是因为解构破坏了代理链。

2. 响应式原理与开发实践

2.1 Proxy 的陷阱:解构、浅层响应式问题

Vue 3 的响应式系统基于Proxy,和 Vue 2 的Object.defineProperty相比有着本质差异。最明显的好处是新增属性、删除属性都不需要再用Vue.set或Vue.delete,直接操作对象就能触发更新。但 Proxy 也带来了一些容易忽略的硬限制。

第一个限制是目标对象必须是引用类型,所以基础数据类型必须用ref包裹。ref在内部其实创建了一个含value属性的对象,再用reactive将其变成响应式。理解了这一点你就明白了为什么模板里访问ref自动解包,而reactive的嵌套对象不会自动解包。

第二个限制是reactive创建的代理对象不能直接跨组件共享某个属性值,因为属性在解构后只是普通值。解决方法是使用toRefs,或者干脆用一个自定义 hook 返回多个ref。我在实际开发中更倾向于返回ref的方式,因为这样状态来源更扁平,也不会碰到解构丢失响应式的坑。

第三个限制是关于浅层响应式。reactive默认是深度的,嵌套对象也会被代理。但如果你有个非常大的列表嵌套了一层不需要深度响应的对象,比如接口返回了一批包含大量只读配置项的数组,直接用reactive会浪费不少性能。这时候可以用shallowRef、shallowReactive来做浅响应式,只对最外层变更触发更新。我一般只在做数据处理耗时较重的图表组件时才会这么优化,毕竟浅层响应式会牺牲掉嵌套对象变更的自动追踪能力。

2.2 watch、computed、watchEffect 的选型经验

这个三兄弟经常被混用,区分清楚了能省大量 debug 时间。

先说computed。它的核心是基于已有状态派生新状态,并且有缓存。只要依赖的响应式变量没有变化,多次访问返回的都是同一个值。我习惯在模板里尽量避免写复杂表达式,比如filteredList、totalPrice这类都交给computed,这样逻辑可测试也清晰。

然后是watch。它强调的是副作用触发,比如“当用户ID变化时重新拉取详情”。watch适合依赖某个源头状态、驱动一些外部行为(请求、日志、操作 dom)的场景。需要注意的是默认情况下watch是懒执行的,只有目标变化时才会触发。

watchEffect则是立刻执行一次,然后在依赖变化时自动再次执行。我第一次用的时候就觉得它很像场景更简单的computed+watch的结合体。但它也有个坑:你不能精确指定要监听哪个数据,只能自动追踪回调里用到的所有响应式依赖。所以如果你需要在回调里调用某个接口,且该接口依赖多个状态,用watchEffect会很省事;但如果你只希望某个特定条件变化时才触发,就得用watch。

我这里给出一份实际的选型参考:

场景推荐方案原因
从列表推导筛选结果computed有缓存,性能好
监听 id 变化并请求详情watch精确控制监听源
监听多个条件并组合成一条请求watchEffect自动追踪多个依赖
做防抖搜索watch + 手动定时器需要控制触发时机
初始化时需要同步执行逻辑watchEffect立即执行一次

我在项目里经常看到用watch监听一个对象,然后写深拷贝去比较新旧值,其实直接换成watch的deep选项就能解决,但要注意deep会遍历对象内部,大对象上性能存在隐患,能不用就不用。

3. 组件通信的几种姿势与真实场景

3.1 父子通信:props、emit 和 v-model 的正确用法

组件通信是老生常谈,误区也多。基础方式是props向下传数据,emit向上发事件。但很多人忽略了一个细节:props应该是只读的,不要试图在子组件中修改。曾经为了省事,我在子组件里直接props.visible = false,结果 Vue 运行时会给你一条警告,而状态变更可能已经乱了套。正确的做法是子组件通过emit('update:visible', false)通知父组件去改数据。

v-model的本质就是发送更新事件。在 Vue 3 中,v-model可以绑定多个属性,比如v-model:title和v-model:visible同时存在于一个组件上。这和 Vue 2 的.sync修饰符很像,但语法更统一。我建议所有需要双向同步的组件参数都使用v-model,不要自己发明onChange事件来传值,因为模板上直接写v-model的语义是最清晰的。

3.2 状态管理?还是 provide/inject?

如果组件层级非常深,比如爷爷组件里有个状态需要传给孙组件,用props一层层透传不仅代码冗长,而且效率低。此时可以使用provide+inject。这个组合的特点是:祖先组件通过provide提供数据,后代组件通过inject直接接收,中间层完全不用管。

但要注意,provide默认不是响应式的。如果只传入一个普通对象,子组件拿到的数据不会响应其变化。要让它响应,需要传入ref或reactive对象。我在封装全局插件时,比如通用的用户信息、主题配置,倾向于创建一个共享的ref,然后provide这个ref的value,在消费侧做成computed来使用。

什么时候才需要引入Pinia或Vuex这种重量级状态管理库?我的判断标准只有一个:是否有多个非父子关系的组件需要协同读写同一份状态。比如登录状态、购物车、全局配置。如果只是父子传递,用provide/inject就够了;如果逻辑复杂且跨页面,就上状态管理库。

我整理了一个简单的选型表格:

通信需求推荐方案说明
父子属性传递props只读,单一方向
子组件修改父组件状态emit / v-model事件更新
跨多层组件传递provide/inject减少透传
任意组件共享状态Pinia模块化、调试工具完善
全局数据但仅读场景根组件 provide + inject简单轻量

3.3 组件通信中常见的隐形问题

实际开发中,我在通信问题上遇到过不少隐形 bug,挑三个最有代表性的说。

第一个是事件命名大小写。Vue 3 中如果使用模板监听自定义事件,事件名转换规则会和 Vue 2 不一样。比如你在组件里emit('someEvent'),父组件模板上写@some-event是能匹配的,但如果写@someEvent,在 DOM 模板里会被强制转为小写,这就可能监听不到。我建议所有自定义事件统一用 kebab-case 命名,省得踩这个坑。

第二个是props的默认值结构。如果使用setup语法糖,defineProps的默认值需要放在withDefaults中。很多人沿用旧用法,在props解构时试图赋默认值,结果响应式丢失,非常诡异。这个属于语法层面硬规范,没有技巧,必须记清楚。

第三个是重复渲染导致的 watch 频繁触发。当父组件传下来的props是对象,而子组件内部watch这个对象时,如果对象在一次渲染中被父组件重新创建(哪怕内容相等),子组件的watch也会因为引用地址变化而触发。要避免这种无谓请求,一种方法是watch的flush配合deep,但更推荐在源头保证引用稳定,比如在父组件使用computed缓存结果,而不是每次render都新建对象。

4. Vue 3 性能优化笔记

4.1 按需引入与 tree-shaking

Vue 3 在体积控制上比 Vue 2 好很多,一个原因是核心代码按需暴露,另一个原因是基于 ES Module 的tree-shaking让不用的代码在构建时被移除。如果你还在使用全局引入的方式:

import { createApp } from 'vue' import App from './App.vue' createApp(App).mount('#app')

你已经实现了基础的按需使用。但是真正需要留意的是第三方组件库。以某组件库为例,按需引入时需要使用插件的unplugin-vue-components自动解析模板中的组件,并只打包用到的部分。手动写全量引入:

import { Button, Input } from 'your-lib'

也是按需的一种方式,但模板中每增加一个组件,你都要回来改 import,体验差。自动按需引入是更现代的做法,配置方法通常是这样:

// vite.config.js import Components from 'unplugin-vue-components/vite' import { MyResolver } from 'your-lib-resolver' export default { plugins: [ Components({ resolvers: [MyResolver()] }) ] }

这样模板中使用button,构建时只会把对应的组件和样式打包进去。我这几个项目实测下来,全量引入的 JS 单页体积能减少 30%~40%。体积优化对首屏加载时间影响明显,特别是移动端场景。

4.2 虚拟列表与大列表优化

渲染大量数据是 Vue 最常见的性能瓶颈。假设一个页面要展示 10000 行数据,全部用v-for渲染 DOM,页面会瞬间卡死。虚拟列表的思路是只渲染可视区域内的条目,通过滚动计算起始索引和结束索引,动态渲染对应切片。

自己实现虚拟列表不算复杂,但细节很多。核心计算逻辑分为三步:

第一步,获取容器的可视高度viewHeight和滚动偏移量scrollTop。

第二步,根据每项预估高度rowHeight算出起始索引:

const startIndex = Math.floor(scrollTop / rowHeight) const endIndex = Math.min( startIndex + Math.ceil(viewHeight / rowHeight), totalCount )

第三步,渲染[startIndex - buffer, endIndex + buffer]范围内的数据,其中buffer是为了滚动过快时防止白屏而预留的额外渲染项数。

真正实现起来有两件麻烦事。第一件是动态行高。如果每条数据高度不固定,需要逐项测量高度并缓存累计高度表,复杂度会明显上升。我建议优先考虑让每项高度固定,或者使用 padding 占位而不是绝对定位实现。第二件是滚动过程中频繁更新 DOM 会引发大量渲染帧,必要时需要引入requestAnimationFrame做节流,避免同步计算消耗主线程太多时间。

如果只是需要快速方案,也可以使用社区成熟的虚拟滚动库,但不建议无脑引第三方库,因为很多虚拟列表组件的 API 和自己的 UI 框架绑得太紧,反而难定制。我的经验是:行高固定、单列表格类场景,自己实现最灵活;不规则卡片瀑布流这类,直接找现成方案。

4.3 keep-alive 的缓存细节

在管理后台里,最常见的优化就是keep-alive。它能保存组件实例和 DOM 状态,让页面切换后保留之前的滚动位置、表单输入,不会再重新请求接口。但在 Vue 3 中,keep-alive的使用位置有讲究。

如果你把keep-alive包在router-view外面:

<router-view v-slot="{ Component }"> <keep-alive> <component :is="Component" /> </keep-alive> </router-view>

那么所有符合条件的路由组件都会被缓存。但实际场景中你可能只希望某些详情页、列表页被缓存,而表单页每次进入都重置。这就需要使用include或exclude属性,根据组件名称来匹配。

一个经常踩的坑是组件名称不见了。在 Vue 3 的<script setup>语法中,组件默认无法从模板上下文获取自身名称,这会导致keep-alive的include匹配失效。解决方式是额外使用defineOptions({ name: 'xxx' })或者传统选项式写法。这也解释了为什么很多从 Vue 2 升级过来的项目,keep-alive突然不生效,排查了一圈发现是名称匹配问题。

缓存也不是万能的。当你有跨天跨页面的数据时效需求时,缓存旧的页面状态反而会造成误导。我一般在配置缓存时,会额外记录一个缓存时间戳,并在页面重新激活时判断是否需要强制刷新数据,而不是无脑信任缓存。

5. 常见问题与坑位整理

5.1 列表 key 的复杂场景

key是前端框架中最常用又最容易被敷衍的指令。很多人习惯直接写:key="index",这个在静态列表里没有大问题,但在有v-if/v-else、排序、插入删除操作的动态列表中,用index作为 key 会导致状态误绑定。

实际发生过这样的 bug:一个可拖拽排序的表格,某一行的输入框内容在拖拽后全部错位。原因就是 key 用了数组下标,拖拽导致元素位置变化,Vue 复用旧节点时把输入框内部的value状态和旧的 key 绑定了,数据却不是那一条的。

所以 key 的第一原则是:使用业务唯一标识,比如商品 ID、订单号。如果接口没有提供唯一 ID,也要用id + 类型拼一个稳定字符串出来。除非该列表绝对静态且没有内部状态,否则不要用 index。

5.2 异步组件与 Suspense 的取舍

按需加载不仅是第三方库,应用自身的路由组件也可以通过异步组件实现。Vue 3 中直接使用defineAsyncComponent:

import { defineAsyncComponent } from 'vue' const AsyncComp = defineAsyncComponent(() => import('./Heavy.vue'))

配合路由懒加载,可以把每个页面的代码拆成单独 chunk,首屏只加载当前路由的资源,体积和加载速度都有明显收益。

Suspense是配合异步组件处理加载状态的实验性能力,不过 API 稳定性一直一般。我在实际项目中更倾向于在异步组件外层手动写一个v-if="isReady",或者利用loading配置项指定占位内容:

const AsyncComp = defineAsyncComponent({ loader: () => import('./Heavy.vue'), loadingComponent: LoadingSpin, delay: 200 })

用第三方库或者自定义 Loading 组件来兜底,避免依赖实验特性导致升级崩溃。

5.3 代码复用的进化:从 mixin 到 composable

Vue 2 时代做逻辑复用最常用的方式就是mixin。但mixin有很明显的可维护性问题:命名冲突、来源不透明、隐式依赖。比如两个 mixin 同时定义了data里的state字段,最终合并结果可能出乎意料。排查问题时也很痛苦,你不知道某个方法来自哪个 mixin。

组合式 API 彻底改变了复用模式,composable(自定义 hooks)本质就是普通函数,内部用ref/computed/watch组合出需要的状态和逻辑,然后在组件setup中调用。它完全控制了代码注入点,不存在命名冲突问题,因为每次调用都会返回新的一组局部变量。

这是我在项目中封装一个非常基础的useRequest函数示例:

import { ref } from 'vue' export function useRequest(fetcher) { const data = ref(null) const loading = ref(false) const error = ref(null) async function run(...args) { loading.value = true error.value = null try { const result = await fetcher(...args) data.value = result } catch (err) { error.value = err } finally { loading.value = false } return data.value } return { data, loading, error, run } }

有了这种基础封装,每个列表页的请求逻辑都能统一成同一个模式:调用useRequest,然后把loading绑定到按钮,data绑定到列表。复用时只需要传入不同的fetcher函数,代码结构完全一致。这套模式我已经沿用了很久,比mixin干净太多。

5.4 记忆深刻的一次响应式失效排查

最后记录一个我见过的特别隐蔽的问题。组件里用了:

const data = reactive({ list: [] }) const { list } = data // 解构 list.push('new item')

页面不更新。原因就是上面讲过的reactive解构丢响应式。后来我把结构改成:

const data = reactive({ list: [] }) const list = computed(() => data.list)

或者干脆:

const list = ref([])

三种方案都能解决,但从代码清晰度来看,ref是最直观的。这类问题最大的风险不是不会改,而是项目里到处都有这种解构代码时,逐个排查的工作量会让人崩溃。所以你从第一天写代码时就应该确定一个团队约定:凡是使用reactive的地方,就不要再解构使用;要解构就明确使用toRefs。

笔记写到这里,其实很多点都是在具体调试中一次次碰壁后记下来的。Vue 3 的内容远不止这么多,但第五篇笔记记录的是约束最多、也最影响日常开发效率的部分。我个人的习惯是每遇到一次难查的问题,就把它压缩成一条带具体代码的笔记片段,后续在团队 review 时拿出来当作反面教材,比讲十遍官方文档都有效。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 8:48:34

Sysmac Studio中文手册实战:从手册查询到运动控制调试

简介&#xff1a;Sysmac Studio 中文手册是一份面向电气工程师、设计安装维护人员及系统管理者的 PLC 自动化软件操作指南&#xff0c;重点覆盖 NJ/NX/NY 系列控制器的编程、配置与调试流程。包内为单个 PDF 文件&#xff0c;压缩后约 31MB&#xff0c;便于离线查阅&#xff0c…

作者头像 李华
网站建设 2026/10/15 8:47:51

SpringBoot+Vue构建老年陪诊系统:架构设计与无障碍实践

1. 项目背景与核心价值陪诊陪护服务在老龄化社会背景下正成为刚需。根据公开数据显示&#xff0c;我国60岁以上老年人口占比已超过18%&#xff0c;其中约15%的老年人存在日常就医陪护需求。传统线下陪诊服务存在信息不对称、预约效率低、服务质量难保障等问题。这个基于SpringB…

作者头像 李华
网站建设 2026/10/15 8:47:21

动态索引动作空间:重构浏览器自动化的核心范式

1. 项目概述&#xff1a;这不是一个“自动化脚本”&#xff0c;而是一次对浏览器交互本质的重新定义你有没有试过在订票网站上反复点击、等待、填表、跳转&#xff0c;最后卡在支付页刷新三次才成功&#xff1f;我做过上百个机票预订自动化项目&#xff0c;从最原始的Selenium硬…

作者头像 李华
网站建设 2026/10/15 8:46:50

SpringBoot宠物服务管理系统架构设计与实现

1. 项目概述&#xff1a;宠物服务管理系统的技术实现养宠物在现代都市生活中已成为越来越普遍的现象&#xff0c;随之而来的是宠物主对专业养护指导和服务管理的强烈需求。这个基于SpringBoot的宠物指南服务平台管理系统&#xff0c;正是为解决这一痛点而设计的技术解决方案。作…

作者头像 李华
网站建设 2026/10/15 8:46:33

SQL窗口函数sum() over实战:分组汇总与逐行累计的核心差异

SQL里的sum() over(partition by ... order by ...)&#xff0c;我见过太多人栽在它上面了。前两天还有人在我这边交流&#xff0c;说想算一个"按区域累计的销售额"&#xff0c;结果写出来的SQL出来的数字和自己手算的完全对不上。一看他的写法&#xff1a;sum(amoun…

作者头像 李华