前端性能优化清单:从 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 秒,靠的不是某个神奇技巧,而是把图片、脚本和缓存这三件事认真做对。