用 Lighthouse 把首屏从 4.2 秒压到 1.1 秒:一次真实的前端性能优化复盘
用 Lighthouse 把首屏从 4.2 秒压到 1.1 秒:一次真实的前端性能优化复盘
上个月接手一个内部工具站,用户反馈「点进去要等半天」。我用 Lighthouse 跑了一遍,首屏 LCP 4.2 秒,Performance 评分 42。
花了两个下午优化到 LCP 1.1 秒、评分 94。这篇把每一步的「改了什么、为什么有效、验证数据」记录下来,都是一次性可复现的操作。
## 一、先量化,别猜
很多人一上来就说「图片太大」「要上 CDN」。但在没有数据之前,这些都是猜。
```bash
npx lighthouse https://your-site.com \
--only-categories=performance \
--output=json \
--output-path=./lh-before.json \
--chrome-flags="--headless"
```
跑完看 `lh-before.json`,重点看三个指标:
| 指标 | 优化前 | 含义 |
|---|---|---|
| FCP | 2.8s | 首次内容绘制 |
| LCP | 4.2s | 最大内容绘制(用户感知的「加载完」)|
| TBT | 780ms | 主线程阻塞总时长 |
LCP 4.2s 说明**主要问题在资源加载**,TBT 780ms 说明**JS 执行也拖了后腿**。两个都要治。
## 二、第一步:找出到底是谁慢
Lighthouse 的 `audit` 里有个 `network-requests`,把所有请求按耗时排序:
```javascript
const report = require('./lh-before.json');
const items = report.audits['network-requests'].details.items;
items
.filter(i => i.transferSize > 50000)
.sort((a, b) => b.transferSize - a.transferSize)
.slice(0, 10)
.forEach(i => {
console.log(`${(i.transferSize/1024).toFixed(0)}KB ${i.url.split('/').pop()}`);
});
```
输出结果让我意外:
```
1240KB vendor.js
890KB hero-banner.png
340KB font-awesome.woff2
210KB moment-locales.js
```
**四个文件占了 2.7MB。** 而整个页面实际用到的功能,可能只需要其中 10%。
## 三、逐个击破
### 3.1 vendor.js 1240KB → 280KB
用 `webpack-bundle-analyzer` 看构成:
```bash
npm install --save-dev webpack-bundle-analyzer
```
发现三个问题:
```javascript
// 问题 1:全量引入 lodash
import _ from 'lodash'; // 引入整个库 530KB
// 改成按需
import debounce from 'lodash/debounce'; // 只有 2KB
// 问题 2:moment 带了所有语言包
import moment from 'moment';
// 改成
import moment from 'moment';
import 'moment/locale/zh-cn';
// 并在 webpack 里忽略其他 locale
new webpack.IgnorePlugin({ resourceRegExp: /^\.\/locale$/, contextRegExp: /moment$/ })
// 问题 3:整个 UI 库全量引入
import { Button, Table, Modal } from 'antd';
// antd 4+ 默认支持 tree-shaking,但要确认 babel 配置没有把 ESM 转成 CJS
```
**关键**:antd 的 tree-shaking 生效前提是 `babel-plugin-import` 配置正确,且 `sideEffects: false` 写在 package.json 里。我漏了后者,导致整个库都打进去了。
### 3.2 hero-banner.png 890KB → 62KB
```bash
# 原图 2400x1600 PNG
# 转成 WebP,并生成多尺寸
npx sharp-cli -i hero-banner.png -o hero-banner.webp --format webp --quality 82
```
配合 `srcset` 让浏览器自己选:
```html
<picture>
<source
srcset="hero-800.webp 800w, hero-1600.webp 1600w"
sizes="(max-width: 800px) 100vw, 1600px"
type="image/webp">
<img src="hero-800.jpg" alt="banner" width="1600" height="1067" loading="eager" fetchpriority="high">
</picture>
```
**注意两个细节**:
1. `width` / `height` 必须写,否则会触发 CLS(布局偏移)
2. LCP 图片用 `fetchpriority="high"`,别用 `loading="lazy"`——lazy 会让 LCP 更慢
### 3.3 font-awesome 340KB → 4KB
只用了 6 个图标,却加载整个字体包。换成内联 SVG:
```html
<!-- 原来 -->
<i class="fa fa-search"></i>
<!-- 改成 -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
```
或者用 `@iconify/tailwind` 按需引入。
### 3.4 moment-locales 210KB → 0
上面 `IgnorePlugin` 已经解决。如果项目允许重构,直接换 `date-fns` 或 `dayjs`,体积能再降 90%。
## 四、第二步:让关键资源更早开始加载
资源瘦身后 LCP 从 4.2s 降到 2.6s,还不够。剩下的时间花在「等待」上。
### 4.1 预连接
```html
<link rel="preconnect" href="https://cdn.your-domain.com" crossorigin>
<link rel="dns-prefetch" href="https://api.your-domain.com">
```
`preconnect` 省掉 DNS + TCP + TLS 三轮握手,实测省了 180ms。
### 4.2 关键 CSS 内联
```html
<style>
/* 首屏可见区域的样式,约 4KB */
.header { ... }
.hero { ... }
</style>
<link rel="preload" href="/css/main.css" as="style" onload="this.rel='stylesheet'">
```
内联的 CSS 让 FCP 从 2.8s 降到 1.4s,因为不再阻塞渲染。
### 4.3 字体加载策略
```css
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap; /* 关键:先显示后备字体,避免白屏 */
unicode-range: U+0000-00FF, U+4E00-9FFF; /* 只加载用到的字符集 */
}
```
`font-display: swap` 让文字立即用系统字体显示,字体加载完再替换。视觉上会闪一下,但比白屏 2 秒好得多。
## 五、第三步:拆掉主线程的阻塞
TBT 780ms 主要来自一个同步执行的图表库初始化。
```javascript
// 原来:页面加载时立即执行
import Chart from 'chart.js';
const chart = new Chart(ctx, config);
// 改成:等到空闲时再执行
if ('requestIdleCallback' in window) {
requestIdleCallback(() => initChart());
} else {
setTimeout(initChart, 200);
}
```
更彻底的做法是等元素进入视口再加载:
```javascript
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
import('./chart.js').then(({ initChart }) => initChart(entry.target));
observer.unobserve(entry.target);
}
});
}, { rootMargin: '200px' });
observer.observe(document.querySelector('#chart-container'));
```
TBT 从 780ms 降到 120ms。
## 六、最终数据
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| FCP | 2.8s | 0.9s | **-68%** |
| LCP | 4.2s | 1.1s | **-74%** |
| TBT | 780ms | 120ms | **-85%** |
| 总传输 | 3.1MB | 620KB | **-80%** |
| Performance | 42 | 94 | **+52** |
## 七、几条经验
**1. 先测量,再动手**
Lighthouse 跑三次取中位数。我第一次跑出来 4.2s,第二次 3.8s,第三次 4.5s,波动很大。用中位数才准。
**2. 优化要有对照组**
每改一项就重新跑一次 Lighthouse,记录下来。否则最后不知道哪一步起了作用。
**3. 优先治「大头」**
2.7MB 的资源里,vendor.js 一个就占 46%。先解决它,比纠结某个 20KB 的小图标划算得多。
**4. 不要为了分数优化**
Lighthouse 100 分不等于用户体验好。我见过把所有内容塞进内联 CSS 拿到满分的页面,但首屏该等还是要等。指标是手段,不是目的。
**5. 上线后继续监控**
Lighthouse 是实验室数据。真实用户数据要看 Web Vitals:
```javascript
import { onLCP, onFID, onCLS } from 'web-vitals';
onLCP(console.log);
onFID(console.log);
onCLS(console.log);
```
---
以上每一步都在真实项目里验证过,命令和数据可直接复现。
- 点赞
- 收藏
- 关注作者
评论(0)