前端开发

Vue 3 页面性能优化实战

2026-08-09 #TypeScript#Vue#前端性能#JavaScript#Web-Vitals

Vue 3 页面性能优化最容易走进两个误区:一上来就改代码,或者只盯着打包体积。真正影响用户体验的,可能是首屏图片、串行接口、主线程长任务、列表重复更新,也可能是发布后旧页面加载不到新 chunk。

本文以一个“商品检索与报表”页面为例,建立从测量、定位、修改到回归的完整流程。示例基于 Vue 3、Vue Router 和 Vite;代码需要按项目版本调整。

Vue 页面从固定基线、证据定位、删除无用工作到真实用户监控的性能优化闭环图

图:先测量与定位,再删除不必要工作,最后用真实用户数据和 CI 预算守住收益;点击查看原图。

一、先定义问题,而不是先写优化方案

假设页面包含:

  • 顶部筛选区和用户权限;
  • 约 5,000 条可筛选记录;
  • 一个低频打开的图表面板;
  • 首屏商品图和多个后端接口;
  • SPA 路由,由 Vite 构建并部署到 CDN。

用户反馈是“第一次打开慢,输入筛选条件也会卡”。这实际上是两个问题:

  1. 加载性能:多久看见主要内容,页面何时可以交互;
  2. 更新性能:点击、输入和切换筛选条件后,下一帧多久出现。

先写验收表。下面数字是案例目标,不是所有项目的行业标准:

指标 示例基线 本轮目标 数据来源
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
2
3
npm ci
npm run build
npm run preview -- --host 127.0.0.1

记录 Node.js、包管理器、锁文件、浏览器版本和提交 SHA。优化前后使用相同设备、网络限速、CPU 限速、账号权限和数据集。

建议至少测四组:

场景 缓存 网络与 CPU 目的
首次访问 清空 中端移动设备模拟、弱网 暴露下载、解析和首屏问题
二次访问 保留 同上 验证缓存策略
大数据筛选 保留 CPU 限速 暴露长任务与重复渲染
真实设备 自然 真实 Wi-Fi/蜂窝网络 防止只优化模拟环境

每个实验室场景至少执行 3~5 次,保存中位数和波动范围。只比较一次 Lighthouse 分数,很容易把网络噪声当成优化收益。

2.2 用三类证据定位

  1. Network:HTML、CSS、JS、图片和接口是否形成串行瀑布;
  2. Performance:LCP 元素是什么,长任务花在脚本执行、样式、布局还是绘制;
  3. Vue DevTools:哪些组件重复更新,单次渲染成本多大。

临时启用 Vue 性能标记,便于在浏览器 Performance 时间线中定位组件:

1
2
3
4
5
const app = createApp(App)

if (import.meta.env.DEV || import.meta.env.VITE_PROFILE === 'true') {
app.config.performance = true
}

性能录制要包含一个完整动作,例如“加载页面—输入关键词—切换筛选—打开图表”。只录空闲页面,无法解释交互卡顿。

三、第一步:先消除入口中的非必要代码

3.1 路由组件使用动态导入

不要在路由表顶部静态导入所有页面:

1
2
3
4
5
6
7
8
9
10
11
12
import type { RouteRecordRaw } from 'vue-router'

export const routes: RouteRecordRaw[] = [
{
path: '/products',
component: () => import('../views/ProductListView.vue'),
},
{
path: '/reports',
component: () => import('../views/ReportView.vue'),
},
]

动态导入会成为构建拆分点。验证时不要只看“chunk 数量增加”,还要看首路由 JS 是否下降、额外请求是否并行、用户跳转时是否出现明显等待。

拆得过细会增加请求调度和运行时开销。通常先按路由拆,再根据录制结果处理路由内的大型低频功能。

3.2 低频重组件异步加载

图表、富文本编辑器和导出面板不应因为“以后可能点开”就进入首屏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup lang="ts">
import { defineAsyncComponent, ref } from 'vue'
import ChartLoading from './ChartLoading.vue'
import ChartLoadError from './ChartLoadError.vue'

const opened = ref(false)

const SalesChart = defineAsyncComponent({
loader: () => import('./SalesChart.vue'),
loadingComponent: ChartLoading,
errorComponent: ChartLoadError,
delay: 150,
timeout: 10_000,
})
</script>

<template>
<button type="button" @click="opened = true">查看趋势</button>
<SalesChart v-if="opened" />
</template>

必须同时设计加载、失败、重试和超时状态。弱网下只有一个永远旋转的 loading,并不算完成异步化。

3.3 避免让 tree-shaking 失效

检查入口中这些常见问题:

  • 从聚合文件导入一个函数,却把带副作用的整个目录带入;
  • 全量注册实际只使用少数组件的 UI 库或图标库;
  • 把大型 JSON、语言包和编辑器插件静态导进入口;
  • 同一依赖因版本不一致被打入多份;
  • 为一个简单能力引入体积远大于业务代码的库。

优先从明确的子路径导入,并确认第三方包提供 ESM 构建:

1
2
3
4
5
// 含糊的聚合入口,可能加载更多模块
// import { formatMoney } from '@/utils'

// 职责明确,构建工具也更容易分析
import { formatMoney } from '@/utils/money'

不要为了少几 KB 复制第三方库实现。评估体积、维护成本、浏览器兼容和安全更新后再决定。

四、第二步:压平网络瀑布

4.1 没有依赖关系的请求并行

下面写法会让权限请求等待用户请求完成:

1
2
const profile = await fetchProfile()
const permissions = await fetchPermissions()

如果两者没有依赖,应并行启动:

1
2
3
4
const [profile, permissions] = await Promise.all([
fetchProfile(),
fetchPermissions(),
])

并行不是越多越好。请求共享限流、数据库连接或依赖前一个响应时,仍需按真实关系编排。目标是删除无意义等待,而不是瞬间压垮后端。

4.2 为搜索请求取消过期工作

快速输入可能产生多个并发请求,旧响应还可能覆盖新结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { ref, watch } from 'vue'

const query = ref('')
const rows = ref<Product[]>([])

watch(query, async (value, _oldValue, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())

const response = await fetch(`/api/products?q=${encodeURIComponent(value)}`, {
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`search failed: ${response.status}`)
}
rows.value = await response.json()
})

生产代码还要区分主动取消和真实错误,增加输入防抖、空值处理和可见错误状态。后端仍需支持幂等、限流和超时,前端取消不能替代服务端保护。

4.3 首屏图片和非首屏图片采用不同策略

LCP 图片不要 loading="lazy";应给出明确尺寸,防止加载后推开内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<img
src="https://cdn.example.com/product-hero-1280.webp"
srcset="
https://cdn.example.com/product-hero-640.webp 640w,
https://cdn.example.com/product-hero-960.webp 960w,
https://cdn.example.com/product-hero-1280.webp 1280w
"
sizes="(max-width: 720px) 100vw, 960px"
width="1280"
height="720"
fetchpriority="high"
decoding="async"
alt="本周推荐商品"
>

非首屏图片再使用 loading="lazy"。还要检查 CDN 缓存、图片编码、响应尺寸和 alt 文本,不能只把原始 4K 图片换个 WebP 后缀。

4.4 缓存策略要与发布方式配套

Vite 产物通常带内容哈希,可以长期缓存:

1
2
3
/assets/*.js  Cache-Control: public, max-age=31536000, immutable
/assets/*.css Cache-Control: public, max-age=31536000, immutable
/index.html Cache-Control: no-cache

HTML 需要及时重新验证,哈希资源则应保留足够长时间。如果发布时立即删除旧 chunk,仍停留在旧 HTML 的用户可能遇到动态导入失败。更稳妥的做法是保留旧资源一段时间,并监控 vite:preloadError。

需要自动刷新时,必须限制同一版本只刷新一次,避免缓存或网络故障导致无限循环:

1
2
3
4
5
6
7
8
9
10
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()

const key = `preload-reloaded:${__APP_VERSION__}`
if (sessionStorage.getItem(key) === '1') {
return
}
sessionStorage.setItem(key, '1')
window.location.reload()
})

__APP_VERSION__ 可在构建时注入。刷新前应上报错误,发布平台应继续保留旧静态资源;刷新只是兜底,不是资产生命周期策略。

五、第三步:减少不必要的组件更新

5.1 让子组件 props 保持稳定

下面的每一行都接收 activeId,选中项改变时所有行都会收到变化:

1
2
3
4
5
6
<ProductRow
v-for="item in products"
:key="item.id"
:item="item"
:active-id="activeId"
/>

在父层先计算布尔值,大多数行的 active 不会变化:

1
2
3
4
5
6
<ProductRow
v-for="item in products"
:key="item.id"
:item="item"
:active="item.id === activeId"
/>

同时避免每次渲染都在模板中创建新对象或新数组作为 prop。稳定引用能减少下游计算和更新,但不要为了引用稳定引入可变全局状态。

5.2 computed 不要无条件返回新对象

Vue 3.4 及以上能在 computed 值不变时减少 effect 触发,但每次返回新对象会让引用始终不同:

1
2
3
4
// 每次计算都创建新对象
const summary = computed(() => ({
hasResult: rows.value.length > 0,
}))

如果调用方只需要一个布尔值,就直接返回稳定原始值:

1
const hasResult = computed(() => rows.value.length > 0)

不要一看到 computed 就手工缓存旧对象。先用性能录制证明它位于热点路径,并保证依赖在每次计算中完整收集。

5.3 大型只读数据使用 shallowRef

Vue 响应式默认是深层的。对于从服务端获取、后续按不可变方式替换的大数组,可以只跟踪根引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { shallowRef } from 'vue'

type Product = Readonly<{
id: string
name: string
priceCent: number
}>

const products = shallowRef<readonly Product[]>([])

async function reload(): Promise<void> {
products.value = await fetchProducts()
}

function append(product: Product): void {
products.value = [...products.value, product]
}

下面的深层修改不会触发更新,也违背了约定:

1
2
// 不要这样修改 shallowRef 内部数据
// products.value[0].name = 'new name'

shallowRef 只适合大型、可按不可变方式处理的数据。普通表单和需要细粒度编辑的对象继续使用常规响应式 API 更清晰。

5.4 谨慎使用 v-once 和 v-memo

完全不会变化的子树可使用 v-once。大列表中,确认依赖非常明确时可以使用 v-memo:

1
2
3
4
5
6
7
<ProductRow
v-for="item in products"
:key="item.id"
v-memo="[item.version, item.id === activeId]"
:item="item"
:active="item.id === activeId"
/>

如果忘记把影响 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
2
3
4
5
6
7
8
9
10
const worker = new Worker(
new URL('./product-search.worker.ts', import.meta.url),
{ type: 'module' },
)

worker.postMessage({
requestId: crypto.randomUUID(),
query: query.value,
rows: products.value,
})

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { onCLS, onINP, onLCP, type Metric } from 'web-vitals'

function report(metric: Metric): void {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
route: window.location.pathname,
release: __APP_VERSION__,
})

navigator.sendBeacon('/rum', body)
}

onCLS(report)
onINP(report)
onLCP(report)

采集时避免 URL 查询参数、输入内容、用户标识等敏感信息。服务端做采样、限流和字段校验,并按版本、路由、设备和网络类型查看 P75。

一次优化发布后,至少比较:

  • 新旧版本样本量是否足够;
  • P50、P75、P95 是否一致改善;
  • 移动端是否受益,桌面端是否退化;
  • 错误率、白屏率和动态导入失败是否增加;
  • 业务转化或任务完成率是否受影响。

十、把性能预算放进 CI

首先保存构建产物清单:

1
2
3
npm ci
npm run build
find dist/assets -type f -printf '%s %p\n' | sort -nr | head -20

find -printf 适用于常见 Linux CI 环境。更完整的门禁应读取构建 manifest 或分析报告,对首路由初始 JS、CSS、最大异步 chunk、图片和字体分别设置预算。

预算应根据当前基线逐步收紧。例如:

1
2
3
4
首路由初始 JS(gzip) <= 260 KB
单个异步 JS(gzip) <= 180 KB
首屏 CSS(gzip) <= 60 KB
单张首屏图片 <= 180 KB

这些数字只是案例。CI 需要同时输出“当前值、预算、相对主分支增量和最大贡献模块”,否则开发者只知道失败,不知道从哪里修。

不要让 gzip 体积成为唯一门禁。相同体积的 JavaScript,解析与执行成本可能不同;Web Vitals 和关键交互性能仍需在预览环境或真实用户数据中验证。

十一、一次可审计的优化顺序

建议把改动拆成可单独回退的小提交:

1
2
3
4
5
【PERF-231】- 记录商品列表页性能基线与预算
【PERF-231】- 按路由延迟加载报表与图表组件
【PERF-231】- 并行用户信息与权限请求
【PERF-231】- 使用虚拟列表降低商品行 DOM 数量
【PERF-231】- 接入 LCP、INP 与 CLS 真实用户监控

每个提交都记录同一测试矩阵下的前后对比。不要在一个提交中同时升级 Vue、替换 UI 库、重写状态管理和做性能优化,否则无法判断收益来自哪里,也很难安全回滚。

推荐实施顺序

  1. 固定场景、指标、设备和数据集;
  2. 用 Network 与 Performance 找最大瓶颈;
  3. 先删除无用工作,再做路由级拆包;
  4. 压平独立接口和资源瀑布;
  5. 稳定 props、computed 和大型数据引用;
  6. 对真正的大列表做分页与虚拟化;
  7. 将确认过的 CPU 热点移入 Worker;
  8. 配置 HTML 与哈希资源缓存;
  9. 接入 RUM,灰度观察后再扩大流量;
  10. 把产物预算和回归场景加入 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,优化才能在后续需求中保持住。

一手资料

资料复核日期:2026-08-22。示例预算和性能数据用于说明方法,不代表你的项目现状;实施前应锁定 Vue、Vite、浏览器和构建环境版本,并以真实用户数据与同条件复测为准。

评论
分享

评论