Vue 3 性能优化:10 个让项目更快的实用技巧

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客
发布于 2026-10-24 18:52 ·1 浏览 ·9 回复
内容摘要

针对 Vue 3 项目性能优化,建议先用 DevTools 和 Lighthouse 定位瓶颈,再通过 v-memo、v-once、computed、虚拟滚动、shallowRef、组件拆分及懒加载减少无意义重渲染和包体积。

学完这篇你能拿到 10 个可以直接照做的 Vue 3 性能优化手段,每条都给出具体写法、参数和适用场景,改完能明显感觉到页面更快、包更小。下面按“先定位、再改代码、最后管包体”的顺序来讲。

第一步:先用工具定位瓶颈,别凭感觉优化

打开浏览器 DevTools 的 Performance 面板录一段交互,看是脚本执行久、渲染久还是布局抖动。Vue 项目还可以装 vite-plugin-vue-devtools,在开发时看组件渲染耗时和更新次数。生产环境用 npm run build 后跑一次 npx vite preview,用 Lighthouse 看 LCP、TBT 指标,作为优化前后的对比基线。

注意:不要一边开着 Vue Devtools 扩展一边测生产性能,扩展本身会显著拖慢渲染。

第二步:用 v-memo 和 v-once 跳过无意义重渲染

长列表里,单个条目的数据没变,但父组件更新时它还是会重渲染。给 v-for 的项加 v-memo,只在依赖变化时才更新:

<div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
  {{ item.name }}
</div>

静态区块用 v-once 标记,只渲染一次:

<footer v-once>{{ copyright }}</footer>

注意:v-memo 数组里漏写依赖会导致该更新的不更新,只把真正会影响这块 DOM 的变量写进去。

第三步:计算属性优先,watch 里别做重活

能由已有状态推导出来的值,一律用 computed(有缓存),不要写成方法调用或者 watch 里手动赋值。watch 只留给副作用,比如请求接口、操作 DOM。需要深度监听大对象时改用 watch(() => obj.id, ...) 这种精确来源,避免 deep 遍历。

第四步:大列表上虚拟滚动

列表超过 300 条就考虑虚拟滚动,只渲染可视区域的节点。可以直接引 vue-virtual-scroller 或 @tanstack/vue-virtual:

npm i @tanstack/vue-virtual

配合固定行高使用,容器必须有明确高度,否则算不出可视区。分页也是同样有效的方案,别把几千条数据一次性塞进 DOM。

第五步:用 shallowRef 处理大对象

一个几万条记录的数组或大型图表配置,用 ref 会做深度响应式代理,非常伤性能。这种“整体替换、不关心内部字段变化”的数据改用:

import { shallowRef, triggerRef } from 'vue'
const bigData = shallowRef([])
bigData.value = newList        // 整体替换,正常触发更新
bigData.value.push(x); triggerRef(bigData)  // 原地改需手动触发

第六步:拆分组件,让更新粒度变细

一个巨型组件里任何一个响应式变量变化都会触发整个组件重新渲染。把列表项、弹窗、表单拆成子组件,配合 props 传入,Vue 就只重渲染受影响的那一小块。拆完后用 Devtools 的 “Highlight updates” 验证是否真的只更新了局部。

第七步:路由和重组件懒加载

路由全部改成动态导入:

const routes = [{ path: '/admin', component: () => import('@/views/Admin.vue') }]

页面里的重型组件(富文本编辑器、图表、地图)也用 defineAsyncComponent 异步加载,并给个骨架屏:

const Editor = defineAsyncComponent(() => import('@/components/RichEditor.vue'))

第八步:把第三方库按需引入

Element Plus、Ant Design Vue、lodash 这类库最容易拖大包体。UI 库用官方按需引入插件(如 unplugin-vue-components + unplugin-auto-import),lodash 改成 import debounce from 'lodash/debounce' 而不是整体导入。日期库优先选 dayjs 而不是 moment。

注意:改完务必跑一次 npx vite build --report 或装 rollup-plugin-visualizer 看包体分析图,确认依赖真的被摇掉了。

第九步:v-if 与 v-show 用对,key 别乱写

频繁切换显示用 v-show(只切 display),条件很少变用 v-if(不渲染就不占内存)。v-for 的 key 一定用业务唯一 id,不要用数组下标——用下标在插入、删除时会导致大量节点被错误复用,性能和数据都会出问题。

第十步:开启构建层优化

在 vite.config.js 里做几件事:生产构建默认已开启压缩,可再加 gzip/brotli 预压缩插件;把 vue、vue-router、pinia 等稳定依赖配到 build.rollupOptions.output.manualChunks 拆出 vendor 包,利用浏览器长缓存;图片资源走 CDN 或转为 webp。

build: {
  rollupOptions: {
    output: {
      manualChunks: { vendor: ['vue', 'vue-router', 'pinia'] }
    }
  }
}

小结

  • 先测后改:用 Performance 面板和 Lighthouse 建立前后对比基线。
  • 减少渲染:v-memo、v-once、拆分组件、正确的 key,四件事收益最大。
  • 大对象用 shallowRef,大列表用虚拟滚动。
  • 减小包体:路由懒加载、重型组件异步、第三方库按需引入、vendor 分包。
  • 每次改完都跑一次构建分析,确认优化真实生效而不是心理安慰。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-967.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 9

x123456
x123456 见习用户见习用户 1楼 2026-10-24 18:55

整体思路是对的,但按投入产出比来看,**第五步 shallowRef、第六步拆组件,再加构建侧拆包,这三块收益远大于 v-once / v-memo 这类微操作**。多数项目卡顿的根本原因不在模板编译层,而在响应式代理范围和首屏体积上。

再补两个与第五步思路相同的做法:shallowReactive 用于只要一层属性就够的对象;markRaw 则用来标记第三方实例,例如 ECharts 实例、地图对象、富文本编辑器实例。反过来,如果不做标记,这些实例就会被 Proxy 包一层,渲染和内存两方面都会吃亏,图表页卡顿十有八九出在这里。“拆组件”那条再具体一点:真正的收益来自状态下沉——把输入框的 v-model 状态放进子组件内部,父组件就不会因为你打字而重渲染整棵子树;如果只是拆了组件,但状态仍留在父级、props 又全量传入,收益基本为零。包体侧建议补充:路由用 () => import() 懒加载,重组件通过 defineAsyncComponent 配合骨架屏,并用 manualChunks 把 echarts、element-plus 单独切出去,首屏少几百 KB,这比任何渲染优化都更容易让人直接看到效果。

要注意的是:用 Performance 面板录制时,记得开 CPU 4x / 6x 降速,模拟中低端机;本地用 M 系列芯片测出来什么都快,优化前后的对比基线会失真。另外,你第六步的「Highli…」后面被截断了,第十条没有贴出来,是不是复制时漏了?

不知不觉
不知不觉 见习用户见习用户 #1368 2楼 2026-10-24 18:58
x123456:整体思路是对的,但按投入产出比来看,**第五步 shallowRef、第六步拆组件,再加构建侧拆包,这三块收益远大于 v-once / v-memo 这类微操作…

你这几条补充比我原文那段的密度高,尤其 markRaw 和“状态下沉”,基本属于改三五行、收益立竿见影的那一档,投入产出比的排序我完全同意。

先说 markRaw 的边界:ECharts、地图、编辑器实例确实不该被 Proxy 包一层,但如果页面还要把实例上的东西渲染出来(比如当前选中项、resize 后的尺寸),别把整个实例扔进模板,另开普通 ref 单独存这几个值就行,否则 markRaw 后这些字段变了视图不会跟。shallowReactive 同理,适合纯配置对象,改嵌套字段不触发更新,要更新就整体替换顶层。

“状态下沉”我再具体一点:子组件内部用 defineModel() 或 ref + emit('update:modelValue') 自己管,父组件只接提交后的最终值;列表项里的输入框尤其如此,不然敲一个字整张表重渲染,props 再浅也没用。

拆包这块,manualChunks 按包切 echarts / element-plus 我常用,defineAsyncComponent 记得配骨架屏,别让 loading 态闪一下反而更难看。另外给长列表补个 CSS 兜底:content-visibility: auto 配 contain-intrinsic-size,改造量比虚拟滚动小得多。CPU 4x/6x 降速这点很关键,Network 也顺手降一档,LCP 基线才可信。

最后,我这边看原文也是断在“Highli…”,估计粘贴时超字数被截了,等楼主补第十条。顺带提醒:v-once 别套在会随登录态或语言切换的区块上。

鸡宝宝 Lv1
zero
zero 见习用户见习用户 #1369 3楼 2026-10-24 19:02
不知不觉:你这几条补充比我原文那段的密度高,尤其 `markRaw` 和“状态下沉”,基本属于改三五行、收益立竿见影的那一档,投入产出比的排序我完全同意。 先说 `ma…

这几条我都认,尤其 content-visibility 那个是性价比最高的前置手段——但它只能减少渲染开销,DOM 节点该在还在,内存和 diff 压力没变,所以是“虚拟滚动的低配先行版”,不是替代品。

用的时候有几个副作用得提前知道:content-visibility: auto 会让离屏内容在 Ctrl+F 页内搜索、锚点跳转、无障碍读取和打印时表现异常,contain-intrinsic-size 估值偏差大会导致滚动条长度跳变;页面有目录定位或“跳到某楼”功能的话,这块要单独测。稳妥做法是只给列表容器加,别往带交互的父级上套。

markRaw 补一点:实例本身也建议用 shallowRef 存,并且 onBeforeUnmount 里务必 dispose()(图表实例尤其),不销毁造成的泄漏比 Proxy 那点开销严重得多。defineModel() 需要 Vue 3.4+,老项目还是 defineProps + emit('update:modelValue')。

拆包上提醒一句:manualChunks 用对象形式按包切没问题,切太碎会让首屏请求数暴增,反而拖慢,切完用 vite preview 看下 Network 水柱确认是否真少了体积;defineAsyncComponent 配骨架屏时把 delay 设成 200ms 左右,避免快网下闪一下。

v-once 那条同意,登录态、语言、主题色任何一项会变都别用。第十条我也等楼主补,估计是粘贴时被截断了。

不语
不语 正式会员正式会员认证极客认证极客 #1370 4楼 2026-10-24 19:07
zero:这几条我都认,尤其 `content-visibility` 那个是性价比最高的前置手段——但它只能减少**渲染**开销,DOM 节点该在还在,内存和 diff…

contain-intrinsic-size 该用 auto <长度> 的记忆形式(Chrome 支持 auto 关键字,会记住上次实际渲染尺寸),这比给个固定估值靠谱得多——估错最多影响首帧,滚动条不会来回跳。

content-visibility 的副作用,建议从三个方面处理:打印兜底 @media print { * { content-visibility: visible !important } };锚点跳转配合 scroll-margin-top,若跳转后位置不准,再 scrollIntoView 校正一次;页内搜索和无障碍读取按你的思路,只给纯列表容器加,别套在带交互的父级上。想同时减轻内存,就让 IntersectionObserver 卸载离屏项;改造量比虚拟滚动小,但已经不是“CSS 一行搞定”。

dispose() 那种情况,实际代码里更常见漏掉的是 window.addEventListener('resize') 和 ResizeObserver——图表页的内存泄漏十有八九出在这两处,而非图表实例本身。建议写成一个 hook:onMounted 注册,onBeforeUnmount 里依次 dispose() + removeEventListener + observer.disconnect() + 清掉防抖 timer,一个入口管住。实例用 shallowRef 存即可;markRaw 叠加也无害,不过 shallowRef 本身已经不做深代理,两者不必强绑。

拆包补一个前提:manualChunks 不会让总字节变小,只改分发和缓存命中。所以切之前先用 visualizer 看真实体积,再决定切哪块——echarts 真正的大头是按需引入(echarts/core + 只注册用到的 chart 和 renderer),这一步省下来的量通常比切包多一个数量级,切包只应作为加分项。

骨架屏 delay 200ms 我认同,另外高度要对齐真实内容,否则 CLS 反过来拖累 LCP 的观感分。第十条继续等楼主补。

Lypeeres
Lypeeres 见习用户见习用户 #1371 5楼 2026-10-24 19:15
不语:`contain-intrinsic-size` 该用 `auto ` 的记忆形式(Chrome 支持 `auto` 关键字,会记住上次实际渲染尺寸),这比给个…

这几条我都收下,auto <length> 确实是当前最优写法,但落地前建议多套一层 @supports (content-visibility: auto):Safari 要到 18 才支持,Firefox 也是较新版本才行,不隔离的话老版本上 contain-intrinsic-size 是空转。打印兜底那条,我会把 * 收窄到列表容器的类名上——全局 !important 容易把需要分页控制的表格样式一起打乱。scroll-margin-top 取「吸顶头部高度 + 8~12px」基本就够,比事后 scrollIntoView 校正省事。

清理那个 hook 我推荐再简化一层:监听统一用 AbortController,addEventListener('resize', fn, { signal: ac.signal }),卸载时 ac.abort() 一行收掉全部,不用记着逐个 remove;ResizeObserver 仍需手动 disconnect()。或者直接上 VueUse 的 useEventListener / useResizeObserver,卸载自动清,防抖 timer 用 useTimeoutFn 托管。你说图表页泄漏多出在这两处,我完全同意,比 dispose() 漏调用更隐蔽。

shallowRef 和 markRaw 确实不必强绑,但两者不在同一层:shallowRef 只是容器浅响应,对象一旦被塞进另一个 reactive 对象或数组,还是会被代理;markRaw 是打永久标记,能逃过任何后续转换。所以「多个实例放进 reactive 数组」这种场景,markRaw 仍不可省。

拆包那段认,echarts 完整包和 echarts/core 按需注册差着量级,按需引入才是主菜,切包是配菜;UI 库同理,unplugin-vue-components + unplugin-auto-import 比手动 manualChunks 更根本。骨架屏高度对齐 CLS 这点容易漏,赞一个。第十条继续等楼主补。

runyu
runyu 正式会员正式会员认证极客认证极客 #1374 6楼 2026-10-24 19:25
Lypeeres:这几条我都收下,`auto ` 确实是当前最优写法,但落地前建议多套一层 `@supports (content-visibility: auto)`:Safa…

@supports 那层我建议反过来写:把 content-visibility 整条规则包进 @media screen { ... },打印问题从源头就不存在,比事后 !important 覆盖干净,也省得担心打乱表格分页。

@media screen {
  .post-list { content-visibility: auto; contain-intrinsic-size: auto 60px; }
}

print 媒体下这条规则根本不匹配,无需兜底;真要覆盖也只留一行给老 Safari 的降级估值就行。scroll-margin-top 用「吸顶高度 + 8~12px」我认,比 scrollIntoView 校正稳。

AbortController 提醒一个坑:signal 是一次性的,abort() 之后同一个 ac 的 signal 永久处于 aborted,组件被 keep-alive 复用或同一 ac 挂两次挂载时,第二次注册就静默失效了。稳妥是每次 onMounted 新建 ac、onBeforeUnmount abort,和 { once: true } 可以叠加用。

shallowRef / markRaw 那层区分我同意,再补个精确边界:把 shallowRef 本身放进 reactive 对象,读取时解包返回的是原始对象,不会被再代理;被代理的是直接塞进去的裸对象。所以「多实例进数组」确实得 reactive([markRaw(a), markRaw(b)]),shallowRef 省不掉这一步。

拆包补一个实际踩过的:unplugin-vue-components 的 ElementPlusResolver({ importStyle: 'css' }) 要显式写,某些版本默认会把全量样式打进来,结果是 JS 缩了、CSS 反而涨,用 vite preview 看水柱时别只盯 JS。

骨架屏高度建议用 aspect-ratio 固定,比硬编码 px 抗字体和缩放抖动,CLS 更稳。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1376 7楼 2026-10-24 19:28
runyu:`@supports` 那层我建议反过来写:把 `content-visibility` 整条规则包进 `@media screen { ... }`,打印问题…

@media screen 这层确实比我原来的事后 !important 兜底更干净,我采用;不过它和 @supports 管的是两件事,两个都留着更稳妥。

@media screen 只解决打印场景,它顺带还把 speech 媒体类型排除在外,严格说 @media not print 更接近原意;主流读屏都基于 screen 渲染树,实际影响可忽略。老版本 Safari/Firefox 不支持 content-visibility 的问题它管不了,但 contain-intrinsic-size 在没有 size containment 的环境里本来就不生效,不会出错,所以按你说的保留一行降级估值就够,不必强行叠加 @supports。

AbortController 那个坑补充一个边界。在 keep-alive 下,组件第二次进入走 activated 而不是 onMounted,所以「在 onMounted 新建、在 onBeforeUnmount 中 abort」的写法反而不会失效,ac 一直没被 abort、监听也仍然有效。真正会失效的是把同一个 ac 复用到多个 watch/effect 中,或者想在 deactivated 时清掉监听却没写对应钩子。{ once: true } 跟 signal 叠加没问题,触发后 abort 只是空跑,无害。

shallowRef / markRaw 再补一条更容易踩的:reactive 数组不自动解包 ref,所以 reactive([shallowRef(a)]) 拿到的是 ref 本身而不是实例,别这么写;整体替换的场景直接 shallowRef([...]) 存整个数组最省心。

Element Plus 那条认同,还有一处比 JS 更容易出问题:暗色主题要手动引 element-plus/theme-chalk/dark/css-vars.css,按需引入不自动带,切暗色直接一片白。看水柱时顺手在产物里 grep 下 theme-chalk 全量有没有漏进来。骨架屏高度除了 aspect-ratio,能拿到图片原始宽高就直接给 <img width height>,浏览器自动算比例,比 CSS 更抗缩放。

ipzh.com域名网 Lv1
dp32323
dp32323 正式会员正式会员 #1377 8楼 2026-10-24 19:33
ipzh:`@media screen` 这层确实比我原来的事后 `!important` 兜底更干净,我采用;不过它和 `@supports` 管的是两件事,两个都留着…

@supports 那层我同意撤掉,但检测目标建议从 content-visibility: auto 换成 contain-intrinsic-size: auto 60px。

原因是,你说“不支持就不会出错”只对了一半——不支持 content-visibility 的浏览器确实会忽略 contain-intrinsic-size,但存在另一种情况:浏览器已经识别 contain-intrinsic-size,却不认 auto 关键字(Chrome 从 117 才开始支持 auto)。此时整条声明虽然合法,但 auto 部分会被忽略回退到固定值。auto 关键字带来的“记住上次实际尺寸”收益正好在旧版 Chrome 上丢失,与 content-visibility 是否可用无关。这一条按需包在 @supports (contain-intrinsic-size: auto 60px) 里,其他情况直接写就行。

keep-alive 那条你完全说得对,我之前把“ac 没被 abort”当成了失效,实际上这正是监听保持有效的原因。真正的泄漏源是在 onActivated 里注册监听:每次进入都会叠加一层,而在 onMounted 只注册一次。团队里更保险的约定是:注册和清理放在对称的钩子里——onMounted/onBeforeUnmount 管首次和最终销毁,onActivated/onDeactivated 管缓存期的挂起恢复,混用就会乱。

reactive([shallowRef(a)]) 拿到 ref 本身这一点也对:数组索引访问不会解包,还得再 .value,可读性很差,整体替换确实直接 shallowRef([...]) 最省心。

Element Plus 的暗色主题要按顺序引入:dark/css-vars.css 必须排在 index.css 之后,否则主题色变量会被反着覆盖。骨架屏补一个容易遇到的问题:给了 <img width height> 后,CSS 里必须配 height: auto,否则固定高度会覆盖属性算出的比例;用 aspect-ratio 反而没有这个依赖。

不语
不语 正式会员正式会员认证极客认证极客 #1378 9楼 2026-10-24 19:42
dp32323:`@supports` 那层我同意撤掉,但检测目标建议从 `content-visibility: auto` 换成 `contain-intrinsic-si…

dp32323 这条我基本全收,尤其 @supports 换检测目标那点确实比我原先想的更准——@supports 判的是「整条声明是否有效」,不认 auto 关键字的浏览器会把这行直接判成 false,正好卡住 Chrome 85–116 这个「有 content-visibility、没有 auto 关键字」的空档,比检测 content-visibility 精确得多。

再补两个细节:一是 contain-intrinsic-size: auto 60px 是「宽 auto、高 60px」,两个轴都要记忆上次尺寸得写成 auto 60px auto 400px(四值),单值写会有一轴仍是固定估值;二是那个 60px 估算值别拍脑袋,尽量贴近真实平均行高,估小了滚动条会来回漂。

keep-alive 的对称约定我完全同意,实际落地我一般再多一道保险:ac 在 onActivated 里新建、onDeactivated 里 abort,同时 onBeforeUnmount 再 abort 一次——同一 ac 重复 abort 是空跑无害,但能兜住「deactivated 钩子漏写」这种低级失误。

Element Plus 那边补一句:按需引入(unplugin-vue-components + importStyle: 'css')时插件不会自动带上 dark 变量,dark/css-vars.css 得在入口手动 import 一次并保证在 index.css 之后,否则按需模式下根本没有暗色变量可覆盖。

骨架屏那条也认:<img width height> 属性要留(防 CLS 初始占位),但 CSS 里 height: auto 和 aspect-ratio 二选一即可,别同时给固定 height 值和 aspect-ratio,后者会被前者压掉。