
我刚接手一个Vue3后台项目的时候第一个反应是“这框架真快”。结果一跑起来菜单切换卡顿、大表格渲染掉帧、列表输入搜索像踩了棉花。Vue3 的性能优化从来不是靠某个配置项一键开启的它是一整套从编译、响应式到渲染层的刻意设计。我后来花了两个星期把项目从头到尾捋了一遍从 compile 优化、响应式边界、组件拆分到资源加载全链路调优这里把核心思路和能直接落地的做法都写出来。1. Vue3 性能优化的第一层编译时到底帮我们省了什么很多新手会把 Vue3 的性能优势简单归结为“Proxy 比 Object.defineProperty 快”实际上编译器的优化才是普通项目收益最大的地方。Vue3 的模板在被编译成 render 函数时会自动做很多标记和提升工作这些优化是白捡的前提是你要理解它做了什么避免亲手破坏掉。1.1 script setup 和响应式转换并不是魔法Vue3 中我默认推荐script setup写法不只是因为代码更简洁而是它天然让编译器更容易分析变量作用域。编译器能确定哪些变量是局部变量、哪些是组件属性从而在生成 render 函数时做更精准的依赖收集和更新判断。响应式系统本身也一样ref和reactive在初始化时会把嵌套对象递归转换为响应式对象。如果数据体积不大这一点成本可以忽略但如果是从后端一次性拉回来的几百条甚至上千条数据并且你根本不会逐层修改它那么递归代理的开销以及后续每次访问代理属性的开销就会累积。我见过一个很典型的案例项目里把一整份字典表塞进了reactive里面有两万多个 JSON 节点。页面初始化的时候硬生生卡了 300ms而且每次字典数据的值变化时即使只改了一个字段整个组件树里所有依赖这个字典的 computed 都会重新计算。后来改成shallowRef持有原始对象配合markRaw避免递归代理初始化掉到了几十毫秒。1.2 静态节点提升、patchFlags、事件缓存是怎么起作用的模板编译后的代码里会包含不少“暗号”。比如静态节点会被提升到render函数外面这样每次重新渲染的时候同一个静态节点对象就直接复用不需要重新创建虚拟 DOM。可能有人觉得“不就是多个对象嘛能差多少”但在一个列表项里如果模板的结构很复杂几百行列表数据就意味着几百个静态节点的创建被省掉了差的就是一个数量级。再看patchFlags。编译器会检测到动态绑定的部分标记TEXT_CLASS、PROPS等标志。在执行 patch 的时候Vue3 可以跳过整棵静态子树只对标记过的地方做 diff。这比 Vue2 的“全量 diff”要精准很多。如果你在模板里写了一个v-if同时只有一个class是动态的那么 Vue3 在运行时只比较 class 变化不需要重新创建整个元素。事件缓存也很关键类似cacheHandlers。开发中经常写clickhandleClick编译器会把事件函数缓存起来避免每次渲染都创建新的函数引用。很多习惯 Vue2 的开发者不知道这一点以为只要把函数提取到setup里就没事了实际上模板里内联事件也基本不会因为“函数引用变了”而触发子组件更新因为编译器帮你缓存了。1.3 编译优化是白拿的但前提是别写反模式编译优化虽然自动但有些写法会让优化失效。最典型的反模式是直接在模板里写复杂的表达式比如{{ (a.b || {}).c ? formatSomething(a.b.c) : }}。这样模板会生成很深的计算逻辑每次渲染都会执行且这些表达式不会被编译器提升优化。另一个反模式是滥用组件嵌套。一个列表项如果拆成很多层小组件而且每层都用了响应式对象属性传递那么更新时可能触发的不只是当前组件还会波及其子组件。不是说不能拆组件而是拆组件时要保证 props 的粒度合理尽量让动态变化的 props 只在真正需要更新的那层组件里发生变化。还有一个很隐蔽的点模板中直接调用方法。div{{ formatTime(now) }}/div这种写法每次组件更新都会重复执行方法即使now根本没变。正确做法是把它变成computed或者确保只在依赖变化时执行。编译器的优化解决不了“模板太聪明”的问题代码的瘦身还得靠开发者的自觉。2. 响应式系统的“过度响应”是大多数人没意识到的性能黑洞Vue3 的响应式实现确实很优雅但这不意味着你可以无脑把所有数据都变成响应式。性能问题往往不是“响应式不够快”而是“响应了不该响应的东西”。我调优时最常做的事就是给响应式数据“做减法”。2.1 shallowRef、shallowReactive、markRaw 适用场景shallowRef和shallowReactive的核心特点是只跟踪最外层值的变化不深层次递归代理内部属性。这个特点特别适合三类场景。第一类是从接口拿到、前端只做整体替换的数据。比如一个列表页的结果集用户可能只会重新请求并整体赋值。假如你写const list refany[]([])然后list.value response.dataVue3 会对数组里的每一个元素做响应式代理。如果你用shallowRef则只有list这个引用变化时才会触发更新数组内部的元素保持原样。省掉的不仅是递归代理的时间也省去了很多不必要的依赖追踪。第二类是第三方库实例、高德地图实例、echarts 实例这类“不需要响应式”的对象。把它们放进reactive会导致无意义的属性和事件追踪直接在初始化时用markRaw标记一下之后即使对象被塞进响应式容器里也不会被代理。第三类是静态配置、大字典、枚举映射。这些数据通常初始化后不再变化用普通变量或者markRaw包裹的状态管理里保存就够没必要让 Vue 去监听。2.2 不要什么都放进 reactive只放需要响应的部分我在很多代码里看到过这种写法const state reactive({ userInfo: null, permissionList: [], dictMap: {}, list: [], page: 1, total: 0, loading: false })看起来挺规整但问题在于permissionList和dictMap往往是从后端一次性拿到的静态数据页面只读不写。list是接口数据但数据自身的深层属性也不需要响应式。最终只有page、total、loading这几个字段在用响应式的更新能力。优化建议是拆分const page ref(1) const total ref(0) const loading ref(false) const dictMap markRaw(await getDictMap()) const permissionList markRaw(await getPermissionList()) const list shallowRef(await getList())这样写的好处是依赖这个数据的组件只会在对应的值真正变化时重新渲染不会因为别的字段变化而被迫更新。响应式系统的遍历范围也大幅缩小访问速度会更快。2.3 大数组、深嵌套对象的响应式陷阱大数组是性能问题的高发区。首先reactive对数组的深代理会导致每个元素的每个属性都变成 getter/setter如果数组里面有对象嵌套开销会成倍增长。其次当数组发生变化时Vue3 的递归触发更新会让所有依赖数组长度的 computed 和 watch 重新运行。我处理过一个真实案例一个表格页绑定了 3000 条数据每条数据有 60 多个字段。最初代码把整张表的数据放在reactive里输入框搜索时每次都重新过滤出几百条数据并赋值。页面肉眼可见地掉帧卡好几秒。后来我把搜索逻辑改成计算属性推导并用shallowRef保存原始列表搜索时不去改原数组而是返回一个新的过滤结果给shallowRef。由于搜索依赖的是keyword和原始列表只有这两者变化才会触发列表更新渲染性能明显提升。深嵌套对象的响应式陷阱还体现在“你想改一层结果连坐一片”的问题上。比如const form reactive({ step1: { name: , age: 0 }, step2: { address: , phone: } })每次只修改form.step1.name从理论上看 Vue3 可以做到精确更新。但如果一个组件同时依赖了form.step1和form.step2哪怕这层联动没有实际必要也会全部触发。建议是在分步表单场景里把不同步骤拆成独立的响应式对象而不是全部挂在一个大对象上。3. 真正让大页面卡顿的往往是渲染层这些“小事”编译优化和响应式省下了计算时间但用户感知到的卡顿基本都出现在渲染层。一个小细节就可能是性能瓶颈。这一节我们专门聊列表渲染、静态内容锁定以及超长列表的取舍。3.1 列表渲染与 key 的正确姿势v-for的key本质上是给虚拟 DOM 的 diff 提供一个身份标识。很多人的习惯是:keyindex这在列表数据“只追加不重排”的场景下没有大问题但一旦涉及删除、插入、筛选重排就会让 Vue 不知道该复用哪些节点只能做更多 DOM 重建工作。更稳妥的方式是用数据的唯一 id。如果后端返回的数据没有 id可以用内容字段拼接生成一个稳定 key或者在前端维护一个 id 映射。不要为了省事用 index否则后续做拖拽排序、表格行复选、动画过渡时会出现各种奇怪的渲染复用问题。另外v-for和v-if不要在同一个元素上使用。Vue3 中v-if的优先级高于v-for但这样做会让每次渲染都先遍历列表再判断条件性能差而且语义不清晰。正确做法是用computed先过滤出最终要渲染的列表再直接遍历。3.2 v-memo 和 v-once给静态内容上一把锁v-once可以标记“只渲染一次”的内容之后组件更新时直接跳过。适合用在渲染成本很高、但内容不会变的静态区块比如页面顶部大 Banner、固定的说明文案、传进来的 props 初始化之后不会再变的子组件。v-memo是 Vue3.2 引入的它比v-once更灵活。v-memo接收一个依赖数组只有当依赖变化时才重新渲染这一部分内容。我用得最多的场景是列表项里有一大段由多个 props 综合计算出来的区域正常情况下只要父组件状态变化整个列表项都会重渲染加上v-memo后可以把依赖锁定到真正影响这个区域的几个字段上。举个例子一个待办列表项只有checked和title会影响它的显示但父组件里有filter状态一改就触发所有列表项重渲染。你可以这样写div v-foritem in list :keyitem.id v-memo[item.checked, item.title] input typecheckbox v-modelitem.checked / span{{ item.title }}/span /div这样当filter变化时列表项不会全部重渲染只有checked或title改变的那个项才会更新。注意v-memo不是万能药如果依赖数组写得过于宽泛就失去意义如果依赖没有覆盖模板里的所有响应式数据也可能导致内容更新不及时。3.3 虚拟滚动与分片渲染在真实项目里的取舍当列表数据达到数千甚至上万条时直接渲染所有 DOM 会让浏览器崩溃。在这个临界点之前虚拟滚动是首选的优化方式。虚拟滚动的原理是只渲染可视区域附近的内容通过监听滚动位置动态计算应该显示的列表项。我自己的经验是数据量在 2000 条以下时优先考虑分页、搜索和正常渲染因为简单、可靠、滚动流畅数据量超过 2000 条同时用户需求又是连续滚动浏览那再用虚拟滚动。市面上有现成的库也可以自己封装一个简易版。核心思路就是外层容器固定高度内部有一个“总高度”占位层保证滚动条长度正确。监听滚动事件计算startIndex和endIndex。渲染endIndex - startIndex bufferSize条数据。通过transform: translateY(offset)把可见区内容定位到正确位置。虚拟滚动的坑在于每一项的高度必须稳定或者你能精确预判。遇到多行文本长度不一的场景就得在测量后动态调整缓存位置实现成本会高不少。如果是一些卡片流、瀑布流这类高度变化大的内容我更建议用分片渲染也就是把一个长列表切割成多帧渲染虽然滚动时可能看到部分占位但至少页面不会卡死。4. 异步组件、路由懒加载和资源拆分把首屏时间压下来性能优化不只是运行时加载时的体感也非常重要。Vue3 项目构建后的主包如果超过 1MB不管框架多快首屏都会白等很久。代码分割和异步加载是必须做的基础优化。4.1 defineAsyncComponent 与路由级代码分割组件层面的异步加载用defineAsyncComponent非常方便import { defineAsyncComponent } from vue const AsyncChild defineAsyncComponent(() import(./Child.vue))这样 Child 组件会被单独分割到一个 chunk只有真正渲染到它的时候才去加载。同样的思路可以用在路由上const routes [ { path: /dashboard, component: () import(/views/Dashboard.vue) } ]Vite 会自动根据动态 import 分包每个路由页面独立成一个 chunk。需要注意的是异步组件在加载过程中会有闪烁空白可以配合loadingComponent和delay参数使用在加载超过 200ms 后显示一个骨架屏组件避免太快的闪烁。const AsyncChild defineAsyncComponent({ loader: () import(./Child.vue), loadingComponent: LoadingComp, delay: 200 })4.2 组件按需加载和自动导入省去手动 import 的隐患如果项目里引用了 UI 组件库一定不要整包引入。以 Element Plus 为例很多人习惯在入口文件里一次性use(ElementPlus)结果打包出来好几 MB。正确做法是通过 unplugin-vue-components 实现组件自动按需导入它会在编译阶段分析模板中用到的组件只把用到的组件代码打进包里。这个插件还有一个很香的功能自动导入 Vue API。比如在模板中使用了ref、computed不需要手动import { ref } from vue配置好后编译阶段会帮我们补上。这样不仅省代码还能避免因为手写按需导入遗漏导致的包体积变大问题。注意自动导入只对组件库、指定目录下的组件和 Vue 核心 API 生效。对于自己项目里的公共组件如果它们彼此依赖很重我并不支持把所有页面组件都无脑做成异步加载因为分包太碎反而会增加 HTTP 请求数量。合理粒度是路由级别拆包组件库按需引入业务公共组件按需加载低频弹窗类组件使用defineAsyncComponent。4.3 图片懒加载、骨架屏和预加载配合的体验优化首屏性能不只看代码大小图片也是大头。如果一个页面上半屏挂了 10 张大图每张都几 MB加载时间必然爆炸。图片懒加载是必须做的可以用 IntersectionObserver 来实现也可以用第三方指令库。核心逻辑是只有图片进入视口附近才开始加载真实 src默认给一张低清晰度占位图。骨架屏能大幅提升用户对“加载速度”的主观感受。异步组件加载时显示一个灰色结构块路由切换时显示页面骨架配合过渡动画不会让人感觉页面卡死。预加载则是“看起来快”的另一个玩法当用户鼠标移到某个 tab 上等待点击前可以预先加载对应的 chunk。注意预加载不要过度否则会浪费带宽一般用于用户最可能访问的高频页面。5. 运行时高频场景的性能排查与实测心得前面说的都是优化方法但真正开发中找性能瓶颈还是离不开工具和排查链路。这一节聊我日常是怎么定位问题的以及在事件、计算属性、缓存方面的一些经验。5.1 用 devtools 定位重复渲染的链路Vue3 devtools 的 Performance 面板可以记录组件渲染耗时。我一般会先打开它操作出现卡顿的功能然后回去看火焰图。注意看哪些组件的高亮区域非常宽而且时间占比高说明它在反复重渲染。最常见的问题出在“父组件更新所有子孙组件都跟着跑”。即使子组件的 props 没变只要父组件的 render 函数执行了子组件默认会重新渲染。要优化这个首先确认子组件是否被包裹在memo或defineOptions中开启inheritAttrs等配置其次要注意给子组件传递的 props 如果是一个每次渲染都重新创建的对象或函数那么子组件的重渲染基本上不可避免。比如父组件里这样写Child :config{ type: a, size: b } click() {} /父组件每次重新渲染都会生成一个新对象和箭头函数。哪怕子组件内部什么都不变也会因为引用不同而触发更新。优化方案是抽成常量或者 computed事件函数用setup里定义的方法而不是每次渲染时临时创建。5.2 事件处理、防抖节流与 requestIdleCallback 的配合事件处理是另一个容易被忽视的性能点。input 实时搜索时如果每次输入都立刻发起请求或者做复杂过滤体验必然差。防抖和节流需要区分输入搜索用防抖等用户停下来 300ms 再执行滚动加载更多用节流保证固定时间间隔只执行一次。我在项目里一般封装一个useDebounceRef来直接给输入值做防抖响应import { ref, watch } from vue export function useDebounceRef(value: string, delay 300) { const debounced ref(value) let timer: ReturnTypetypeof setTimeout watch(value, (newVal) { clearTimeout(timer) timer setTimeout(() { debounced.value newVal }, delay) }) return debounced }模板里绑定原始输入值给computed或watch用的是debounced这样所有依赖搜索结果的渲染逻辑都不会因为每次按键而重启。requestIdleCallback适合处理低优先级、耗时较长的任务比如图表数据的预处理、日志上报。它会在浏览器空闲时执行避免阻塞主线程。不过兼容性需要 polyfill并且不要在它里面做 DOM 操作因为触发时机不稳定。5.3 缓存计算属性、避免渲染期间创建对象与方法computed的缓存是基于依赖值比较的依赖没变就直接拿上次的结果。这看起来是常识但很多人习惯在模板里写复杂函数调用或者用watch去深拷贝一份数据导致计算量重复。正确的姿势是任何“从现有数据推导出新值”的逻辑都可以优先考虑computed并且让computed只依赖真正变化的最小粒度数据。还有一个小细节在 render 过程中不要直接创建对象和方法尤其不要放在模板的表达式中。每次渲染都会重新执行模板表达式创建一个新对象然后交给子组件或 DOM。这个行为会干扰v-memo、memo等优化手段的效果也不利于 Vue 的静态节点复用。举个我调优过的例子一个进度条组件需要显示当前进度和剩余时间。原来每次父组件的timer更新都会传一个新对象进度信息下去。改成把进度信息变成computed返回稳定值只有当秒数变化时才会生成新对象子组件重渲染次数立刻少了一个量级。优化做完以后我从 devtools 里看到页面整体重渲染频率降低了不少交互输入也跟手了很多。Vue3 的性能优化并不是一个高深的玄学它更多是让你理解框架的设计意图然后顺着这个意图写代码。我现在的习惯是新建一个组件前先问一句“这里真的需要响应式吗”写完模板后看一眼有没有多余的复杂表达式接入接口数据时先标记一下哪些是静态数据。这些习惯积累下来项目基本不会在性能上给你“眼前一亮”的惊喜但至少不会在关键时刻掉链子。最后再补一句如果你手头正好有个 Vite Vue3 项目打开浏览器 Performance 面板找到那个卡顿的功能按上面说的把响应式数据瘦个身大概率就能看到非常明显的效果。