前端性能优化清单:从 3 秒到 1 秒的实践
性能优化最容易犯的错误,是一上来就压缩图片、合并文件,却没有先弄清楚「慢在哪里」。 这篇文章按指标、加载、渲染、交互、缓存的顺序,给出一份可以直接执行的清单。
一、先定指标,再谈优化
Core Web Vitals 给出了三个互补的维度:加载速度、交互响应和视觉稳定性。 脱离指标的优化,很容易把精力花在用户感知不到的地方。
| 指标 | 含义 | 良好 | 需要改进 |
|---|---|---|---|
| LCP | 最大内容绘制,首屏主要内容出现的时间 | ≤ 2.5s | 2.5s – 4s |
| INP | 交互到下一次绘制,衡量响应速度 | ≤ 200ms | 200ms – 500ms |
| CLS | 累计布局偏移,衡量视觉稳定性 | ≤ 0.1 | 0.1 – 0.25 |
建议以真实用户数据(RUM)为准,实验室数据(Lighthouse)只用于定位问题。 两者的差别往往来自网络环境、设备性能和缓存状态,而这三项恰恰是真实用户最在意的。
二、加载阶段:让关键资源先到
1. 找出关键请求链
打开开发者工具的 Network 面板,看首屏渲染前必须完成的请求有哪些。 通常是一条 HTML → CSS → 字体 → 首屏图片的链路,链路上的每一跳都在增加 LCP。 能内联的关键 CSS 就内联,能预加载的关键资源就预加载。
<link rel="preconnect" href="https://cdn.isidid.cn" />
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high" />
<link rel="preload" as="font" href="/fonts/inter.woff2" type="font/woff2" crossorigin />
preconnect 提前完成 DNS 与 TLS 握手,preload 让浏览器尽早发现关键资源。
但不要滥用:预加载过多资源会挤占带宽,反而推迟真正关键的那一个。
2. 图片是 LCP 的大头
- 使用 AVIF 或 WebP,并保留 JPEG 兜底;同一张图通常能减少 40% 以上体积。
- 明确写出
width与height,或使用aspect-ratio,避免布局偏移。 - 首屏主图加
fetchpriority="high",其余图片使用loading="lazy"。 - 用
srcset与sizes让浏览器按设备像素比选择合适的尺寸。
3. 字体不要阻塞渲染
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: swap; /* 先用系统字体显示,避免空白 */
unicode-range: U+0000-00FF; /* 只加载需要的字符集 */
}
4. 按路由拆分代码
单页应用的首屏体积,往往被用不到的路由和组件撑大。用动态 import()
把非首屏模块拆出去,配合打包工具的 vendor 拆分,能让首屏 JS 减少一半以上。
const routes = [
{ path: '/', component: () => import('./pages/Home.js') },
{ path: '/about', component: () => import('./pages/About.js') },
];
三、渲染阶段:减少无谓的工作
1. 避免强制同步布局
在同一帧里「先写样式、再读布局、再写样式」,会迫使浏览器反复计算布局。 把读取操作集中到前面,写入操作集中到后面,是成本最低的优化之一。
// 差:读写交替,触发多次布局
items.forEach((item) => {
const h = item.offsetHeight;
item.style.height = h + 10 + 'px';
});
// 好:先读后写
const heights = items.map((item) => item.offsetHeight);
items.forEach((item, i) => {
item.style.height = heights[i] + 10 + 'px';
});
2. 动画只用 transform 与 opacity
这两个属性可以只触发合成,不引起布局与重绘。改变 width、top、
box-shadow 则会让浏览器在每一帧重新布局或重绘,移动端尤其明显。
3. 长列表使用 content-visibility
.product-card {
content-visibility: auto;
contain-intrinsic-size: 0 320px; /* 预估占位高度 */
}
浏览器会跳过屏幕外元素的渲染工作,同时对占位尺寸有所准备,滚动条不会乱跳。 这是目前性价比最高的长列表优化手段之一。
四、交互阶段:别让主线程堵住
INP 差的根本原因,通常是某个任务执行时间过长,用户点击后必须等它结束才能看到反馈。 经验法则是单个任务不超过 50ms。
- 把大循环拆成分片,每片之间用
setTimeout或scheduler.yield()让出主线程。 - 输入框搜索做防抖,并在请求返回后判断是否仍是最新一次输入。
- 列表事件用委托,避免注册成百上千个监听器。
- 复杂计算移到 Web Worker,尤其是解析大 JSON、图片处理等场景。
async function processInChunks(items, chunkSize = 200) {
for (let i = 0; i < items.length; i += chunkSize) {
items.slice(i, i + chunkSize).forEach(handleItem);
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
五、传输与缓存:第二次访问应该更快
- 带哈希的静态资源使用
Cache-Control: public, max-age=31536000, immutable。 - HTML 使用
no-cache配合 ETag,保证发版后立即生效。 - 开启 Brotli 压缩,文本资源通常还能再小 15% 到 20%。
- 静态资源走 CDN,并确保源站与 CDN 之间的回源链路稳定。
# Nginx 示例
location ~* \.(js|css|woff2|avif|webp|png|svg)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
六、把指标测出来
上线之后需要用代码持续采集,而不是只在发布前跑一次 Lighthouse。 下面的片段可以拿到 LCP 与 CLS 的真实值:
new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries[entries.length - 1];
console.log('LCP', Math.round(last.startTime), 'ms');
}).observe({ type: 'largest-contentful-paint', buffered: true });
let cls = 0;
new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (!entry.hadRecentInput) cls += entry.value;
});
console.log('CLS', cls.toFixed(3));
}).observe({ type: 'layout-shift', buffered: true });
采集到的数据建议按设备类型、网络类型和页面路径分组。只看全局平均值, 很容易被大量快速访问掩盖掉真正糟糕的那部分体验。
七、优化清单
| 优先级 | 动作 | 预期收益 |
|---|---|---|
| 高 | 首屏图片改用 AVIF/WebP,并加 fetchpriority | LCP 明显下降 |
| 高 | 路由级代码分割,移除首屏无用 JS | 缩短主线程阻塞 |
| 高 | 为图片与广告位预留尺寸 | CLS 接近 0 |
| 中 | 关键 CSS 内联,非关键样式异步加载 | 加快首次渲染 |
| 中 | 拆分长任务,输入事件做防抖 | 改善 INP |
| 中 | 静态资源长缓存 + Brotli + CDN | 回访几乎瞬时 |
| 低 | content-visibility 优化长列表 | 滚动更顺滑 |
八、小结
性能优化的顺序应该是:先测量,找到瓶颈;再处理影响面最大的那一项;然后回归验证,确认没有引入新的问题。 大多数站点从 3 秒优化到 1 秒,靠的不是某个神奇技巧,而是把图片、脚本和缓存这三件事认真做对。