三个月前,我们接手了一个数据看板项目:首屏 LCP 高达 3.2s,滚动时偶发卡顿,用户投诉「点进去要等半天」。经过三轮迭代,最终把 LCP 压到 0.8s,INP 从 340ms 降到 96ms。
这篇文章记录完整的优化过程。核心观点只有一句话——没有度量,就没有优化。所有「感觉会变快」的改动,都必须由数据背书。
先测量,再优化
最常见的错误是拿到项目就开始「加缓存、上 CDN、压缩图片」。这些手段本身没错,但在不知道瓶颈在哪的情况下,你无法判断哪一项真正有效,也无法证明改动带来了收益。
三个核心指标
现代 Web 性能度量围绕 Core Web Vitals 展开。对我们这个看板项目来说,真正影响体验的是下面三项:
| 指标 | 含义 | 良好 | 优化前 |
|---|---|---|---|
| LCP | 最大内容绘制 | < 2.5s | 3.2s |
| INP | 交互到下次绘制 | < 200ms | 340ms |
| CLS | 累计布局偏移 | < 0.1 | 0.24 |
CLS 偏高通常意味着资源尺寸未声明或字体加载导致回流,这一点在第二节会详细展开。先用 Lighthouse 和真实用户监控(RUM)拿到基线数据,再动手。
本地 Lighthouse 跑分容易失真。请务必接入真实用户的 web-vitals 上报,实验室数据与现场数据的差距往往超过 40%。
建立性能预算
性能预算把「快」变成可执行的数字。我们把它写进 CI,一旦超出阈值就让构建失败:
{
"budgets": [
{ "metric": "LCP", "target": 2500, "unit": "ms" },
{ "metric": "INP", "target": 200, "unit": "ms" },
{ "metric": "CLS", "target": 0.1, "unit": "" },
{ "metric": "js-bundle", "target": 170, "unit": "KB" }
]
}
预算一旦确立,后续每一次提交都有了明确的对错标准,团队讨论也从「我觉得还行」变成了「它超了 12KB,得砍掉点什么」。
没有度量,就没有优化。
— 本文核心原则
加载阶段:把首屏需要的东西最快送达
加载阶段的目标很明确:让浏览器尽早拿到渲染首屏所必需的最小资源集合。我们做了两件事——拆分资源、优化传输。
代码分割与预加载
改造前,整个应用被打包成一个 1.4MB 的 JS 文件。改造后按路由拆分,首屏只需要加载约 156KB:
// 路由级代码分割:首屏只加载必需的 chunk
const routes = [
{ path: '/', load: () => import('./pages/Dashboard.js') },
{ path: '/reports', load: () => import('./pages/Reports.js') },
{ path: '/settings', load: () => import('./pages/Settings.js') },
];
// 预加载:在浏览器空闲时提前拉取「下一个可能访问」的页面
function prefetch(path) {
const route = routes.find((r) => r.path === path);
if (route) route.load();
}
// 侧边栏 hover 时触发预加载,用户点击时资源已在缓存中
sidebar.addEventListener('mouseover', (e) => {
const link = e.target.closest('[data-route]');
if (link) prefetch(link.dataset.route);
});
同时,把关键资源提前告知浏览器,消除「发现延迟」:
<link rel="preconnect" href="https://cdn.example.com">
<link rel="preload" as="font" type="font/woff2"
href="/fonts/inter-var.woff2" crossorigin>
<link rel="modulepreload" href="/assets/dashboard.js">
图片与字体
图片通常占据页面体积的一半以上。我们的做法是:
- 格式现代化:全量转换为 AVIF,并提供 WebP 作为回退,平均体积下降 62%。
- 声明尺寸:所有
<img>显式设置width/height,消除布局偏移。 - 懒加载:首屏外的图片统一加
loading="lazy"。 - 字体子集化:只保留实际使用的中文字形,字体文件从 2.1MB 降到 180KB。
<picture>
<source srcset="/img/chart.avif" type="image/avif">
<source srcset="/img/chart.webp" type="image/webp">
<img src="/img/chart.jpg" alt="营收趋势图"
width="960" height="540" loading="lazy" decoding="async">
</picture>
字体务必使用 font-display: swap,并配合 size-adjust 调整回退字体的度量,避免字体切换瞬间的跳动。
渲染阶段:让浏览器少干活
资源送到了,接下来是渲染。这一阶段的核心是减少主线程的工作量,尤其是布局(Layout)与绘制(Paint)的开销。
减少重排与重绘
最典型的反面案例就是动画直接改 top / left / width,这些属性每帧都会触发完整的布局计算:
/* ❌ 差:每帧触发 Layout + Paint + Composite */
.card:hover {
top: -6px;
box-shadow: 0 14px 30px rgba(0, 0, 0, .18);
}
/* ✅ 好:transform 只走 Composite,跳过 Layout 与 Paint */
.card {
transform: translateY(0);
transition: transform .22s cubic-bezier(.4, 0, .2, 1);
}
.card:hover {
transform: translateY(-6px);
}
此外还要避免「布局抖动」——即读写交替导致的强制同步布局:
// ❌ 每次循环都强制浏览器重新计算布局
for (const el of items) {
el.style.height = el.offsetHeight + 10 + 'px';
}
// ✅ 先集中读,再集中写
const heights = items.map((el) => el.offsetHeight);
items.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px';
});
长列表虚拟化
看板中有一张两万行的数据表。全量渲染时,首屏需要创建两万个 DOM 节点,滚动帧率直接掉到 18fps。
引入虚拟滚动后,无论数据多少,DOM 中始终只保留可视区域附近的约 30 个节点:
- 容器高度设为
行高 × 总行数,撑出正确的滚动条; - 只渲染
scrollTop到scrollTop + 视口高之间的行; - 用
transform: translateY()定位渲染层,避免触发重排。
改造后滚动稳定在 58–60fps,内存占用下降约 70%。
运行时:让交互始终顺滑
加载优化解决的是「第一次见面的印象」,运行时优化决定的才是「长期相处是否愉快」。影响 INP 的头号敌人是长任务——任何连续占用主线程超过 50 毫秒的任务。
我们的处理方式是把大计算拆成可中断的小块:
// 把大任务切片,每处理一批就让出主线程
async function processInChunks(items, chunkSize = 50) {
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
chunk.forEach(handle);
// 让出主线程,让浏览器有机会响应用户输入
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
// 若浏览器支持,优先使用 scheduler.yield(优先级继承,体验更好)
async function yieldToMain() {
if ('scheduler' in window && 'yield' in scheduler) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
同时,把非关键逻辑挪出主线程:
- 数据解析与聚合放进 Web Worker;
- 滚动、缩放等高频事件用
requestAnimationFrame节流; - 输入框的实时校验加
debounce,避免每次按键都触发全量计算。
给所有事件都加 passive: true 并非万能。对于确实需要 preventDefault() 的场景,要改用非 passive 监听,否则会出现「滚动正常但手势失效」的诡异问题。
总结与收益复盘
三个月、三轮迭代,最终数据如下(取 P75 真实用户数据):
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| LCP | 3.2s | 0.8s | ↓ 75% |
| INP | 340ms | 96ms | ↓ 72% |
| CLS | 0.24 | 0.02 | ↓ 92% |
| 首屏 JS | 1.4MB | 156KB | ↓ 89% |
回头看,真正起作用的其实只有四件事:
- 度量先行——没有基线数据的优化都是玄学。
- 预算驱动——把性能目标写进 CI,才能长期守住。
- 分阶段拆解——加载、渲染、运行时,三个阶段的瓶颈和解法完全不同。
- 让主线程休息——无论是加载还是交互,主线程空闲度决定了一切。
性能优化没有终点,只有持续的权衡。希望这条路径能帮你少走一些弯路。如果你在实践中遇到了别的瓶颈,欢迎在评论区一起讨论。