评测结果页首屏干等 3 秒多——图表、Markdown 编辑器、几百条评测记录全挤在一起,测试同事一度把”页面无响应”当缺陷反复上报。当时我并没有急着上 SSR,也没有推翻重写,而是先把 Lighthouse 和 Web Vitals 的数据测准,再按”加载路径、运行时开销、构建产物、网络传输”四层逐个拆解,边量化边动手。最终平台在 200+ 页面、上千组件规模下,把首屏时间稳定压到 1.2s 以内、可交互时间压到 2.0s 以内。下面这 19 个调优点全是线上真跑出来的经验,多数改动不到半天就能落地,可以照着复刻到自己的项目里。
一、性能指标
先说一个容易被忽略的事实:不把指标定义清楚就做优化,等于在黑暗里打靶。我们盯的是四个核心指标,它们分别回答”页面出字了吗、主要内容出来了吗、能点了吗、卡了多久”四个问题。平台内部用 Lighthouse 做版本回归基线,用 Web Vitals 库做线上真实采集,两套数据对不上时以 RUM 为准。
| 指标 | 含义 | 目标 |
|---|---|---|
| FCP | 首次内容渲染 | < 1.0s |
| LCP | 最大内容渲染 | < 2.0s |
| TTI | 可交互时间 | < 2.5s |
| TBT | 总阻塞时间 | < 200ms |
这套目标不是拍脑袋定的,而是对照 Core Web Vitals 建议值并结合平台实际业务压出来的。刚开始 LCP 高达 3.4s,后来我们固定每周跑一次 Lighthouse CI,把任何回退都拦在 merge 之前,指标才没有再反弹过。
二、路由级代码分割
代码分割是性价比特别高的一步,因为它几乎没有副作用。评测平台的页面分三大类:评测配置、批量任务、报告查看,彼此之间用户很少同时用到,强行打进同一个 bundle 纯属浪费。我在改之前先把产物清单导出来看了一眼,主入口居然有 800KB,光 echarts 和 markdown 编辑器就占了一大半。
const routes = [
{
path: '/eval/sxs',
component: () => import('@/modules/eval/SxSPage.vue')
},
{
path: '/batch',
component: () => import('@/modules/batch/BatchList.vue')
}
]
改成路由懒加载后,首屏只加载 100KB 资源,访问子页面时再按需拉取对应 chunk,主入口从 800KB 直接降到 280KB。这里有个易错点:动态 import 的路径必须是静态字符串或字符串拼接,不能用完全动态的变量,否则 Vite 无法在构建期确定 chunk 边界,退化成整包加载。
三、组件级懒加载
路由分割解决的是”页面级”的粒度,但页面内部还有一批组件同样不适合首屏出现。评测表单里那个 Markdown 编辑器有 300KB,图表组件首次渲染要 200ms 起步,它们没被用到之前,加载进来纯属拖后腿。
1. 异步组件
用 defineAsyncComponent 包一层,Vue 会在组件真正渲染时才去请求对应模块:
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))
const MarkdownEditor = defineAsyncComponent({
loader: () => import('./MarkdownEditor.vue'),
loadingComponent: Loading,
delay: 200
})
delay: 200 这个参数值得单独说:组件在 200ms 内加载完就直接显示,超过才展示 loading 占位,避免快速操作时屏幕闪一下加载框。我们的评测配置页加了这个优化后,脚本评估耗时少了约 40%。
2. v-if 条件加载
另一种更直白的做法是等用户点了再加载:
<template>
<HeavyChart v-if="showChart" />
<el-button @click="showChart = true">查看图表</el-button>
</template>
页面默认不渲染图表,用户点击按钮后 showChart 变为真值,异步组件才真正请求并挂载。两种方式我建议按需混用:低频功能用 v-if,高频但重型的组件用 defineAsyncComponent 配合缓存。
四、ECharts 按需引入
图表是评测报告的灵魂,但 ECharts 全量包有 1MB,评测平台实际只用了柱状图、折线图和雷达图,全量引入 90% 都是浪费。我们第一版图省事直接全量 import,首屏 JS 立刻超标,后面才改成按需注册:
// ❌ 全量引入 1MB
import * as echarts from 'echarts'
// ✅ 按需引入 400KB
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent])
改完体积少了 60%,而且 tree-shaking 会把未注册的图表类型直接移出产物。要注意的是按需引入后图表的 tooltip、legend 这些交互能力也得在 use 里一并注册,漏一个线上就报组件找不到,我们上线前就踩过一次,所以建议把图表初始化抽成公共函数,集中管理注册列表。
五、第三方库按需引入
第三方 UI 库往往是体积黑洞。Element Plus 全量引入约 800KB,我们只用了其中十几个组件,剩下全是负担。Vite 生态里的 unplugin-vue-components 可以做到”用到哪个自动引哪个”:
// vite.config.ts
import { AutoImport } from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
plugins: [
AutoImport({ resolvers: [ElementPlusResolver()] }),
Components({ resolvers: [ElementPlusResolver()] })
]
配置后模板里直接写 <el-button>,Vite 会在编译期自动导入对应组件和样式,打包后未使用的组件不会进入产物。我们对比过效果:Element Plus 相关 chunk 从 800KB 降到约 300KB。团队接手成本也低,新人不用手写 import 语句,看模板就能猜个大概。
六、图片优化
评测报告里的截图动辄一两 MB,是比 JS 更隐蔽的流量杀手。我们分三招处理。
1. 懒加载
图片懒加载用 v-lazy 指令或原生 loading=”lazy” 都能做,核心思路是进入视口才发请求。注意首屏以上、用户在折叠线内就会看到的关键图不要懒加载,否则白屏反而更严重。
img 标签使用 v-lazy 绑定图片地址
<!-- 或原生 -->
img 标签绑定 data-src 并加 lazy 加载
2. 响应式图片
不同屏幕宽度下发不同尺寸的图,避免手机端也拉桌面大图:
<picture>
<source srcset="image-1x.webp 1x, image-2x.webp 2x" type="image/webp" />
img 标签 src 指向图片
</picture>
3. 压缩 + WebP
平台用 vite-plugin-imagemin 构建时压缩 + 转 WebP,上传入口再加一道前端压缩。实测一张 2MB 的报告截图,转 WebP 后 200KB 以内,配合懒加载首屏加载时间从 5s 降到 500ms 级别。压缩参数不要拉满,质量 75% 左右在视觉上基本无感,文件体积却少了一半。
七、虚拟列表
评测记录页一次性要展示上万条评测历史,直接渲染必然卡死主线程。我第一版天真地用分页遮丑,产品不同意,说要看连续滚动。于是换成 vue-virtual-scroller 做虚拟滚动:
<template>
<RecycleScroller
:items="bigList"
:item-size="60"
key-field="id"
v-slot="{ item }"
>
<div class="list-item">{{ item.name }}</div>
</RecycleScroller>
</template>
虚拟列表只渲染视口内可见的节点,10000 条数据实际只挂载约 30 个 DOM,滚动性能比全量渲染提升了 50 倍。item-size 必须填准,估错会导致滚动跳动;如果行高不固定,就得改用动态测量模式,代价是略微多些计算。
八、计算属性缓存
写业务代码时常见的性能隐患是无谓的重复计算。最初我们把筛选逻辑写成普通函数,任何响应式数据变化都会让整棵组件树重新执行一遍:
// ❌ 每次重渲都计算
function getFilteredList() {
return list.value.filter(...)
}
// ✅ computed 缓存
const filteredList = computed(() => list.value.filter(...))
computed 会基于依赖缓存结果,list 没变就不重算。我们在评测结果筛选、报告聚合等场景统一改成 computed 后,重渲染耗时平均降了 60%。要注意 computed 里不要塞副作用,它的执行时机由响应式系统决定,不适合做打点上报。
九、避免不必要的响应式
响应式是有成本的,Vue 3 用 Proxy 代理对象,属性越多、代理层级越深,初始化越慢。评测报告里的原始数据动辄几万行,全部做成响应式既浪费内存又拖慢渲染。
// 大数据用 shallowRef
const bigList = shallowRef<Item[]>([])
// 改用 markRaw 标记不需要响应式的对象
const chart = markRaw(echarts.init(dom))
shallowRef 只跟踪 value 本身,内部数据变化不触发更新,适合”整体替换、不逐条修改”的大数组;markRaw 干脆绕过代理,适合 echarts 实例这类对象,它们内部维护自己的状态,不需要 Vue 盯着。这两处改完,报告页初始化耗时从 800ms 降到 300ms 左右。
十、Pinia 性能
状态管理同样藏着性能坑。最初我们把评测 session 的完整数据都塞进 Pinia,store 序列化慢,模板里还大量展开大对象,改一次就全量重渲染。
// store 返回 ref
const count = ref(0)
// 组件里用 storeToRefs 解构(保持响应性)
const { count } = storeToRefs(counterStore)
// 不要在模板里展开大对象
// ❌ <div v-for="item in bigList">
// ✅ 用 shallowRef + 分片渲染
用 storeToRefs 解构能保持响应性又避免丢失响应式绑定;大列表数据则挪出 store,改用 shallowRef 按需加载。持久化也要克制,全量 state 写入 localStorage 会阻塞主线程,我们最后只持久化用户配置和主题这类必要字段。
十一、Vite 构建优化
构建配置直接决定产物体积和加载速度。我们按依赖类型拆 chunk,让浏览器并行下载而不是串行排队:
// vite.config.ts
export default defineConfig({
build: {
target: 'es2020',
minify: 'terser',
rollupOptions: {
output: {
// 拆 chunk
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'echarts-vendor': ['echarts'],
'element-plus': ['element-plus']
}
}
}
}
})
效果很明显:主包降到 280KB,第三方 chunk 各 100-200KB,浏览器能并行下载互不阻塞。注意 manualChunks 不要拆得太碎,chunk 过多反而增加请求数和缓存碎片,我们试过把每个库都拆成一个 chunk,结果加载反而变慢,最后收敛成三大类。
十二、CDN + 缓存
资源传输阶段的优化,核心是”能缓存就缓存、该更新的必须更新”。我们做了四件事:静态资源全部走 CDN,浏览器加强缓存加 ETag 校验;HTML 本身禁用缓存,保证发版后用户能拿到最新页面;JS/CSS 设一年长缓存并用内容 hash 命名,改过的文件 hash 变化自然触发回源。
- 静态资源全部走 CDN,浏览器强缓存 + ETag;
- 第三方库可考虑 CDN 引入(看具体需求);
- HTML 禁用缓存(
Cache-Control: no-cache),JS/CSS 长缓存(一年)。
第三方库走不走 CDN 要权衡,公共 CDN 命中率高,但国内访问某些公共库反而慢,我们最终选择自托管走自己的 CDN。
十三、首屏 SSR/SSG 探索
Spa 应用首屏要等 JS 下载并执行才能渲染,评测平台的登录页和营销落地页对首屏要求苛刻,我们引入 Vite SSG 做预渲染:
// vite.config.ts
import ViteSSG from 'vite-ssg'
export default ViteSSG({
// ... 路由配置
})
构建时把指定路由预渲染成静态 HTML,用户拿到的第一帧就是完整内容,FCP 从 1.1s 降到 0.5s。但注意 SSG 只适合内容相对静态的页面,评测结果这类强交互、依赖登录态的页面不适合,我们只在登录页和落地页启用。
十四、关键路径优化
首屏加载快不等于”能用”,JS 执行阶段过长会让页面假死。我们沿着关键路径处理了两个问题。
1. JS 执行耗时
大批量数据处理放在主线程会阻塞交互,我们把它挪进 Web Worker:
// ❌ 主线程跑 100ms 计算
items.value = compute(items)
// ✅ 用 Web Worker 跑
const worker = new Worker('./compute.worker.ts')
worker.postMessage(items)
Worker 里算完再通过 postMessage 回传,主线程只做赋值和渲染。评测数据聚合这种纯计算任务移到 Worker 后,主线程阻塞从 100ms 降到接近 0。
2. 长任务分片
没法用 Worker 的同步任务,用 requestIdleCallback 在浏览器空闲时分片执行:
// requestIdleCallback 分片执行
function processInIdle(items: any[]) {
let i = 0
function step(deadline: IdleDeadline) {
while (i < items.length && deadline.timeRemaining() > 5) {
process(items[i++])
}
if (i < items.length) {
requestIdleCallback(step)
}
}
requestIdleCallback(step)
}
每片只跑 5ms 以内,把长任务拆碎塞进空闲时间,用户交互不会被长时间卡住。Lighthouse 里的 TBT 指标就是从这些长任务统计出来的,改完 TBT 稳定在 200ms 以下。
十五、HTTP 优化
传输层的改造投入小、收益直接,我们按下面的顺序依次落地:
- 开启 HTTP/2 多路复用,单个连接并行传输;
- 合并小文件,减少请求数;
- 接口合并,用 BFF 层把多个小接口聚合为一次调用;
- 对已知域名做预连接,省掉 DNS 和 TLS 握手时间。
HTTP/2 解决的是 HTTP/1.1 只有 6 个并发连接导致的排队问题;BFF 合并则把评测详情页原来 7 个接口压成 1 个,肉眼可见变快。具体配置见下面的代码:
// 1. 开启 HTTP/2(多路复用)
// 2. 合并小文件(雪碧图、JS bundle)
// 3. 接口合并(GraphQL 或 BFF)
// 4. 预连接
<link rel="preconnect" href="https://api.example.com" />
<link rel="dns-prefetch" href="https://api.example.com" />
注意 preconnect 和 dns-prefetch 只加在真正会请求的域名上,加多了反而浪费连接,我们只对 API 域名做预连接。
十六、HMR 优化(开发期)
开发体验直接影响团队效率。迁移 Vite 之后 HMR 已经很快,但还有两个配置值得调:
// vite.config.ts
server: {
hmr: { overlay: false }, // 关错误遮罩
watch: {
usePolling: true, // Docker/WSL 必加
ignored: ['**/node_modules/**', '**/dist/**']
}
}
overlay 关掉错误遮罩,语法报错时页面不再被红色蒙层盖住,代码编辑器里已经有错误提示,蒙层反而碍事;usePolling 是给 Docker 和 WSL 环境用的,文件系统监听在这些环境下经常失效,开了轮询才能保证 HMR 正常触发。
十七、监控
优化做完必须能持续验证,否则一次代码改动就能让之前的成果归零。我们接入 web-vitals 库,把核心指标实时上报到自建监控:
import { onLCP, onFID, onCLS, onINP, onTTFB } from 'web-vitals'
onLCP(metric => sendToAnalytics('LCP', metric.value))
onFID(metric => sendToAnalytics('FID', metric.value))
onCLS(metric => sendToAnalytics('CLS', metric.value))
onINP(metric => sendToAnalytics('INP', metric.value))
onTTFB(metric => sendToAnalytics('TTFB', metric.value))
采集后按 P50/P95/P99 三个分位统计,P95 反映大多数真实用户感受,P99 抓极端环境。我们设置过一条告警:某功能上线后 LCP 的 P95 涨了 300ms,顺着 traceId 排查发现是新增的埋点 SDK 阻塞了主线程,半小时内回滚,这就是持续监控的价值。
十八、踩过的坑
把踩过的坑集中列一遍,每一个背后都是一段真实的返工:
- ECharts 全量引入:1MB 引入 90% 不用。按需引入省 60%。
- 大状态全放 Pinia:store 序列化慢。Pinia 只放必要状态,大数据放 IndexedDB。
- 路由级 chunk 没拆:所有页面打一个 bundle,首屏 1MB+。路由懒加载必须配
() => import()。 - 图片未压缩:原图 2MB,加载 5s。WebP + 懒加载 500ms。
- 未启用 HTTP/2:HTTP/1.1 6 并发限制,多文件慢。HTTP/2 多路复用解决。
- CSS 未抽离:CSS 被打到 JS 里,首屏样式闪烁。Vite 默认抽离,确认 CSS 单独加载。
- Pinia 持久化开销大:全量 state 写 localStorage 阻塞主线程。只持久化必要字段。
这些坑大多不是技术不会,而是”先上线后优化”的惰性思维造成的。只要把优化当成开发流程的一部分,大部分都能在评审阶段拦住。
十九、性能预算
最后一步是把优化成果固化成制度。我们给平台设了硬性预算,超了就拦:
| 资源 | 预算 |
|---|---|
| 首屏 JS | < 300KB gzip |
| 首屏 CSS | < 50KB gzip |
| 首屏图片 | < 200KB |
| 单 chunk | < 200KB gzip |
| 字体 | < 100KB |
CI 里跑 size-limit 检查,超过预算直接阻断 merge,谁加的大依赖谁负责消掉。预算制推行三个月后,首屏 JS 从 800KB 一路压到 280KB 并保持稳定,这比任何优化技巧都管用——它让性能成了默认约束,而不是事后补救。
常见问题(FAQ)
Q1:Vite 还需要 Webpack 那种优化吗?
Vite 默认基于 Rollup + esbuild,dev 期原生 ESM,build 期 Rollup。多数 Webpack 优化在 Vite 里有等价方案。
Q2:怎么测真实性能?
Lighthouse + WebPageTest + Chrome DevTools 性能面板 + RUM 上报。
Q3:SSR 一定要做吗?
中后台不必。营销页/SEO 页做 SSR 收益大。