Vue 3 页面性能优化最容易走进两个误区:一上来就改代码,或者只盯着打包体积。真正影响用户体验的,可能是首屏图片、串行接口、主线程长任务、列表重复更新,也可能是发布后旧页面加载不到新 chunk。
本文以一个“商品检索与报表”页面为例,建立从测量、定位、修改到回归的完整流程。示例基于 Vue 3、Vue Router 和 Vite;代码需要按项目版本调整。
图:先测量与定位,再删除不必要工作,最后用真实用户数据和 CI 预算守住收益;点击查看原图。
一、先定义问题,而不是先写优化方案
假设页面包含:
- 顶部筛选区和用户权限;
- 约 5,000 条可筛选记录;
- 一个低频打开的图表面板;
- 首屏商品图和多个后端接口;
- SPA 路由,由 Vite 构建并部署到 CDN。
用户反馈是“第一次打开慢,输入筛选条件也会卡”。这实际上是两个问题:
- 加载性能:多久看见主要内容,页面何时可以交互;
- 更新性能:点击、输入和切换筛选条件后,下一帧多久出现。
先写验收表。下面数字是案例目标,不是所有项目的行业标准:
| 指标 | 示例基线 | 本轮目标 | 数据来源 |
|---|---|---|---|
| LCP | 4.1 秒 | 小于 2.5 秒 | 真实用户 P75 + 实验室复测 |
| INP | 310 毫秒 | 小于 200 毫秒 | 真实用户 P75 |
| CLS | 0.18 | 小于 0.1 | 真实用户 P75 |
| 首路由 JS(gzip) | 420 KB | 小于 260 KB | 生产构建产物 |
| 筛选一次的主线程长任务 | 180 毫秒 | 不出现超过 50 毫秒的单任务 | Performance 录制 |
| 5,000 行页面 DOM 节点 | 25,000+ | 只渲染可视窗口附近节点 | Elements + Performance |
Web Vitals 的“良好”边界用于用户体验分层,项目预算还应结合设备、网络、业务价值和当前基线制定。对生产数据通常看第 75 百分位,并区分移动端、桌面端、地区和页面类型。
二、建立可重复的测量环境
2.1 不要用开发模式评估首屏
开发模式包含 HMR、调试信息和不同的模块加载方式。首屏与产物体积应使用生产构建:
1 | npm ci |
记录 Node.js、包管理器、锁文件、浏览器版本和提交 SHA。优化前后使用相同设备、网络限速、CPU 限速、账号权限和数据集。
建议至少测四组:
| 场景 | 缓存 | 网络与 CPU | 目的 |
|---|---|---|---|
| 首次访问 | 清空 | 中端移动设备模拟、弱网 | 暴露下载、解析和首屏问题 |
| 二次访问 | 保留 | 同上 | 验证缓存策略 |
| 大数据筛选 | 保留 | CPU 限速 | 暴露长任务与重复渲染 |
| 真实设备 | 自然 | 真实 Wi-Fi/蜂窝网络 | 防止只优化模拟环境 |
每个实验室场景至少执行 3~5 次,保存中位数和波动范围。只比较一次 Lighthouse 分数,很容易把网络噪声当成优化收益。
2.2 用三类证据定位
- Network:HTML、CSS、JS、图片和接口是否形成串行瀑布;
- Performance:LCP 元素是什么,长任务花在脚本执行、样式、布局还是绘制;
- Vue DevTools:哪些组件重复更新,单次渲染成本多大。
临时启用 Vue 性能标记,便于在浏览器 Performance 时间线中定位组件:
1 | const app = createApp(App) |
性能录制要包含一个完整动作,例如“加载页面—输入关键词—切换筛选—打开图表”。只录空闲页面,无法解释交互卡顿。
三、第一步:先消除入口中的非必要代码
3.1 路由组件使用动态导入
不要在路由表顶部静态导入所有页面:
1 | import type { RouteRecordRaw } from 'vue-router' |
动态导入会成为构建拆分点。验证时不要只看“chunk 数量增加”,还要看首路由 JS 是否下降、额外请求是否并行、用户跳转时是否出现明显等待。
拆得过细会增加请求调度和运行时开销。通常先按路由拆,再根据录制结果处理路由内的大型低频功能。
3.2 低频重组件异步加载
图表、富文本编辑器和导出面板不应因为“以后可能点开”就进入首屏:
1 | <script setup lang="ts"> |
必须同时设计加载、失败、重试和超时状态。弱网下只有一个永远旋转的 loading,并不算完成异步化。
3.3 避免让 tree-shaking 失效
检查入口中这些常见问题:
- 从聚合文件导入一个函数,却把带副作用的整个目录带入;
- 全量注册实际只使用少数组件的 UI 库或图标库;
- 把大型 JSON、语言包和编辑器插件静态导进入口;
- 同一依赖因版本不一致被打入多份;
- 为一个简单能力引入体积远大于业务代码的库。
优先从明确的子路径导入,并确认第三方包提供 ESM 构建:
1 | // 含糊的聚合入口,可能加载更多模块 |
不要为了少几 KB 复制第三方库实现。评估体积、维护成本、浏览器兼容和安全更新后再决定。
四、第二步:压平网络瀑布
4.1 没有依赖关系的请求并行
下面写法会让权限请求等待用户请求完成:
1 | const profile = await fetchProfile() |
如果两者没有依赖,应并行启动:
1 | const [profile, permissions] = await Promise.all([ |
并行不是越多越好。请求共享限流、数据库连接或依赖前一个响应时,仍需按真实关系编排。目标是删除无意义等待,而不是瞬间压垮后端。
4.2 为搜索请求取消过期工作
快速输入可能产生多个并发请求,旧响应还可能覆盖新结果:
1 | import { ref, watch } from 'vue' |
生产代码还要区分主动取消和真实错误,增加输入防抖、空值处理和可见错误状态。后端仍需支持幂等、限流和超时,前端取消不能替代服务端保护。
4.3 首屏图片和非首屏图片采用不同策略
LCP 图片不要 loading="lazy";应给出明确尺寸,防止加载后推开内容:
1 | <img |
非首屏图片再使用 loading="lazy"。还要检查 CDN 缓存、图片编码、响应尺寸和 alt 文本,不能只把原始 4K 图片换个 WebP 后缀。
4.4 缓存策略要与发布方式配套
Vite 产物通常带内容哈希,可以长期缓存:
1 | /assets/*.js Cache-Control: public, max-age=31536000, immutable |
HTML 需要及时重新验证,哈希资源则应保留足够长时间。如果发布时立即删除旧 chunk,仍停留在旧 HTML 的用户可能遇到动态导入失败。更稳妥的做法是保留旧资源一段时间,并监控 vite:preloadError。
需要自动刷新时,必须限制同一版本只刷新一次,避免缓存或网络故障导致无限循环:
1 | window.addEventListener('vite:preloadError', (event) => { |
__APP_VERSION__ 可在构建时注入。刷新前应上报错误,发布平台应继续保留旧静态资源;刷新只是兜底,不是资产生命周期策略。
五、第三步:减少不必要的组件更新
5.1 让子组件 props 保持稳定
下面的每一行都接收 activeId,选中项改变时所有行都会收到变化:
1 | <ProductRow |
在父层先计算布尔值,大多数行的 active 不会变化:
1 | <ProductRow |
同时避免每次渲染都在模板中创建新对象或新数组作为 prop。稳定引用能减少下游计算和更新,但不要为了引用稳定引入可变全局状态。
5.2 computed 不要无条件返回新对象
Vue 3.4 及以上能在 computed 值不变时减少 effect 触发,但每次返回新对象会让引用始终不同:
1 | // 每次计算都创建新对象 |
如果调用方只需要一个布尔值,就直接返回稳定原始值:
1 | const hasResult = computed(() => rows.value.length > 0) |
不要一看到 computed 就手工缓存旧对象。先用性能录制证明它位于热点路径,并保证依赖在每次计算中完整收集。
5.3 大型只读数据使用 shallowRef
Vue 响应式默认是深层的。对于从服务端获取、后续按不可变方式替换的大数组,可以只跟踪根引用:
1 | import { shallowRef } from 'vue' |
下面的深层修改不会触发更新,也违背了约定:
1 | // 不要这样修改 shallowRef 内部数据 |
shallowRef 只适合大型、可按不可变方式处理的数据。普通表单和需要细粒度编辑的对象继续使用常规响应式 API 更清晰。
5.4 谨慎使用 v-once 和 v-memo
完全不会变化的子树可使用 v-once。大列表中,确认依赖非常明确时可以使用 v-memo:
1 | <ProductRow |
如果忘记把影响 UI 的值加入依赖数组,页面会显示过期内容。先稳定 props、减少数据量,再考虑 v-memo;它不是修复错误状态设计的快捷方式。
六、第四步:大列表只渲染用户看得见的部分
5,000 行即使数据获取很快,把每行十几个节点全部放进 DOM,也会增加创建、样式计算、布局和更新成本。
虚拟列表只渲染可视窗口和少量缓冲项。选型时验证:
- 行高固定还是动态;
- 键盘导航、焦点和读屏是否正常;
- 滚动到指定项、展开行和选中状态是否正确;
- 容器缩放、窗口变化和移动端是否稳定;
- 服务端渲染或 hydration 是否支持;
- 数据追加后是否保持当前位置。
虚拟列表不能替代后端分页。用户不需要一次下载几十万条记录时,应先分页、筛选和裁剪字段,再对单页的长列表做虚拟化。
优化后使用 Performance 和 Elements 面板确认 DOM 数量随可视区域变化,而不是仍然渲染全部节点后仅用 CSS 隐藏。
七、第五步:把重计算移出关键交互
7.1 找到长任务内部真正昂贵的函数
在 Performance 火焰图中区分:
- JavaScript 计算;
- Vue patch 和组件更新;
- Recalculate Style / Layout;
- 图片解码与 Paint;
- 第三方脚本。
如果筛选函数本身占用 150 毫秒,拆组件不会解决问题。先减少算法复杂度、预建索引、避免重复格式化,再考虑 Web Worker。
7.2 CPU 密集型纯计算使用 Worker
1 | const worker = new Worker( |
Worker 适合可序列化的 CPU 计算,不能直接操作 DOM。传输巨大对象也有复制成本;可以只传必要字段、使用 Transferable,或把索引长期保存在 Worker 内。
请求要携带 requestId,只接受最后一次查询的结果。组件卸载时终止 Worker 或移除监听,避免内存泄漏。
7.3 第三方脚本延后并隔离
埋点、客服、A/B 测试和广告脚本经常占用首屏主线程。逐个记录其下载和执行成本:
- 是否必须在首屏加载;
- 是否能在用户同意后或空闲阶段加载;
- 失败是否会阻塞业务;
- 是否有超时、版本和数据合规要求。
不能修改的第三方脚本也要纳入性能预算。否则业务代码优化出的 100 毫秒,很快会被新增脚本吃掉。
八、架构层优化:SPA 不一定适合所有页面
如果页面以营销内容、文档或商品详情为主,首屏内容和 SEO 很重要,可以评估 SSR 或 SSG;高度交互、登录后的后台页面则可能更适合 SPA。
选择架构前回答:
- 首屏是否必须等待 JS 才出现主要内容;
- 页面是否需要搜索引擎抓取;
- 个性化是否导致服务器缓存命中率很低;
- 团队能否承担 SSR 缓存、hydration、监控和部署复杂度;
- 是否可以只对部分路由预渲染。
SSR 能改善内容到达时间,但不会自动减少 hydration 的 JS 和主线程成本。架构变更要基于性能档案和业务目标,不应作为局部卡顿的第一反应。
九、建立真实用户监控
实验室能复现问题,RUM 才能说明真实用户是否受益。可使用 web-vitals 采集核心指标:
1 | import { onCLS, onINP, onLCP, type Metric } from 'web-vitals' |
采集时避免 URL 查询参数、输入内容、用户标识等敏感信息。服务端做采样、限流和字段校验,并按版本、路由、设备和网络类型查看 P75。
一次优化发布后,至少比较:
- 新旧版本样本量是否足够;
- P50、P75、P95 是否一致改善;
- 移动端是否受益,桌面端是否退化;
- 错误率、白屏率和动态导入失败是否增加;
- 业务转化或任务完成率是否受影响。
十、把性能预算放进 CI
首先保存构建产物清单:
1 | npm ci |
find -printf 适用于常见 Linux CI 环境。更完整的门禁应读取构建 manifest 或分析报告,对首路由初始 JS、CSS、最大异步 chunk、图片和字体分别设置预算。
预算应根据当前基线逐步收紧。例如:
1 | 首路由初始 JS(gzip) <= 260 KB |
这些数字只是案例。CI 需要同时输出“当前值、预算、相对主分支增量和最大贡献模块”,否则开发者只知道失败,不知道从哪里修。
不要让 gzip 体积成为唯一门禁。相同体积的 JavaScript,解析与执行成本可能不同;Web Vitals 和关键交互性能仍需在预览环境或真实用户数据中验证。
十一、一次可审计的优化顺序
建议把改动拆成可单独回退的小提交:
1 | 【PERF-231】- 记录商品列表页性能基线与预算 |
每个提交都记录同一测试矩阵下的前后对比。不要在一个提交中同时升级 Vue、替换 UI 库、重写状态管理和做性能优化,否则无法判断收益来自哪里,也很难安全回滚。
推荐实施顺序
- 固定场景、指标、设备和数据集;
- 用 Network 与 Performance 找最大瓶颈;
- 先删除无用工作,再做路由级拆包;
- 压平独立接口和资源瀑布;
- 稳定 props、computed 和大型数据引用;
- 对真正的大列表做分页与虚拟化;
- 将确认过的 CPU 热点移入 Worker;
- 配置 HTML 与哈希资源缓存;
- 接入 RUM,灰度观察后再扩大流量;
- 把产物预算和回归场景加入 CI。
十二、常见“负优化”
| 做法 | 为什么可能变慢 | 如何验证 |
|---|---|---|
| 把每个组件都异步化 | 产生碎片请求和加载闪烁 | 对比首路由瀑布和交互等待 |
| 所有数据都改成 shallowRef | 深层修改不再触发更新,状态约束变复杂 | 检查数据是否真正不可变 |
| 到处添加 v-memo | 依赖遗漏导致过期 UI,维护成本上升 | 用组件测试覆盖所有依赖变化 |
| 只看 bundle 总大小 | 无法反映路由、解析、执行和缓存 | 同时看首路由、长任务和 RUM |
| LCP 图片懒加载 | 浏览器更晚才开始下载最大内容 | 查看 LCP 资源请求起点 |
| 发布后立刻删除旧 chunk | 旧页面动态导入失败 | 监控 preload error 与白屏 |
| 为了分数删除可访问性能力 | 用户无法完成操作 | 键盘、读屏和真实任务回归 |
| 用 SSR 解决所有问题 | hydration 和运维成本仍在 | 分开测服务响应、内容绘制和交互 |
十三、上线验收清单
加载性能
- 生产构建、固定网络和 CPU 条件下完成至少三次复测;
- 首路由只加载当前页面必需代码;
- 独立接口没有无意义串行等待;
- LCP 图片尺寸正确、优先加载且未使用 lazy;
- HTML 与带哈希静态资源采用不同缓存策略;
- 旧 chunk 在滚动发布和观察窗口内仍可访问。
更新性能
- props 在列表热点路径中保持稳定;
- computed 不无条件制造新对象;
- 大型只读数据才使用 shallowRef;
- 长列表使用后端分页或虚拟化;
- 搜索和切换操作可以取消过期请求;
- 长任务已定位到具体函数并有前后录制。
发布质量
- 首路由 JS、CSS、图片和字体符合项目预算;
- 弱网、真实移动设备、键盘和读屏完成回归;
- 动态导入存在加载失败和有限重试策略;
- RUM 能按路由与发布版本观察 LCP、INP、CLS;
- 错误率、白屏率和业务成功率没有退化;
- 每个优化提交都能独立回退。
小结
Vue 3 性能优化不是“套用懒加载、缓存和虚拟列表”,而是一个证据闭环:先用相同条件复现,用网络瀑布和主线程录制定位,再删除不必要的下载、计算和 DOM,最后用真实用户指标确认收益。
最值得优先做的通常不是复杂技巧,而是路由边界清晰、请求依赖正确、首屏资源优先级合理、状态引用稳定、长列表不全量渲染,以及发布时不让旧页面失去 chunk。把这些约束写进性能预算和 CI,优化才能在后续需求中保持住。
一手资料
- Vue 官方:Performance Best Practices
- Vue 官方:Async Components
- Vue 官方:Reactivity API - Advanced
- Vite 官方:Building for Production
- Vite 官方:Performance
- web.dev:Web Vitals
- web-vitals 开源库
资料复核日期:2026-08-22。示例预算和性能数据用于说明方法,不代表你的项目现状;实施前应锁定 Vue、Vite、浏览器和构建环境版本,并以真实用户数据与同条件复测为准。
评论