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变成了onBeforeUnmountdestroyed变成了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则是立刻执行一次然后在依赖变化时自动再次执行。我第一次用的时候就觉得它很像场景更简单的computedwatch的结合体。但它也有个坑你不能精确指定要监听哪个数据只能自动追踪回调里用到的所有响应式依赖。所以如果你需要在回调里调用某个接口且该接口依赖多个状态用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一层层透传不仅代码冗长而且效率低。此时可以使用provideinject。这个组合的特点是祖先组件通过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-shakingVue 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 :isComponent / /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是前端框架中最常用又最容易被敷衍的指令。很多人习惯直接写:keyindex这个在静态列表里没有大问题但在有v-if/v-else、排序、插入删除操作的动态列表中用index作为 key 会导致状态误绑定。实际发生过这样的 bug一个可拖拽排序的表格某一行的输入框内容在拖拽后全部错位。原因就是 key 用了数组下标拖拽导致元素位置变化Vue 复用旧节点时把输入框内部的value状态和旧的 key 绑定了数据却不是那一条的。所以 key 的第一原则是使用业务唯一标识比如商品 ID、订单号。如果接口没有提供唯一 ID也要用id 类型拼一个稳定字符串出来。除非该列表绝对静态且没有内部状态否则不要用 index。5.2 异步组件与 Suspense 的取舍按需加载不仅是第三方库应用自身的路由组件也可以通过异步组件实现。Vue 3 中直接使用defineAsyncComponentimport { defineAsyncComponent } from vue const AsyncComp defineAsyncComponent(() import(./Heavy.vue))配合路由懒加载可以把每个页面的代码拆成单独 chunk首屏只加载当前路由的资源体积和加载速度都有明显收益。Suspense是配合异步组件处理加载状态的实验性能力不过 API 稳定性一直一般。我在实际项目中更倾向于在异步组件外层手动写一个v-ifisReady或者利用loading配置项指定占位内容const AsyncComp defineAsyncComponent({ loader: () import(./Heavy.vue), loadingComponent: LoadingSpin, delay: 200 })用第三方库或者自定义 Loading 组件来兜底避免依赖实验特性导致升级崩溃。5.3 代码复用的进化从 mixin 到 composableVue 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 时拿出来当作反面教材比讲十遍官方文档都有效。