前端性能优化:Lighthouse 跑满分,用户还是说慢?
上周上线了一个活动页,Lighthouse 跑了一把,Performance 96 分。截图往群里一甩,觉得稳了。
结果运营第二天就来找我:"这页面怎么这么卡?点了半天没反应。"
我一脸问号,96 分啊兄弟。后来拉了线上真实用户的性能数据一看——P75 的 LCP 4.2 秒,INP 580ms。跟实验室数据完全是两个世界。
这事让我重新想了一下:我们天天盯着的 Lighthouse 分数,到底在衡量什么?它和真实用户体验之间,差了多远?
Lighthouse 在测什么
先搞清楚 Lighthouse 的测试环境。它跑在 Chrome DevTools 里,用的是模拟节流:CPU 4x slowdown,网络模拟成中端 4G。注意,是模拟。
这意味着几件事:
- 它用的是你本机的 CPU,然后乘以一个系数假装慢
- 不存在真实的网络抖动、丢包、重连
- 没有其他 Tab 在后台抢资源
- 每次只测一次页面加载,不测交互
所以 Lighthouse 给你的是一个"理想受控环境下的表现"。有参考价值,但离线上真实情况有距离。
打个比方:Lighthouse 像体检报告,各项指标正常不代表你跑马拉松不喘。
三个核心指标,各自盯着什么
Google 现在主推三个 Core Web Vitals:LCP、INP、CLS。逐个聊聊它们到底在度量什么,以及实际优化时容易踩的坑。
LCP — 最大内容绘制
LCP 盯的是视口内最大的那个"内容元素"什么时候渲染完。通常是首屏的 hero image、大标题、或者一整段文字块。
// 用 PerformanceObserver 采集真实用户的 LCP
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1] // 取最后一个,因为 LCP 会随渲染更新
console.log('LCP:', lastEntry.startTime) // 毫秒
console.log('LCP 元素:', lastEntry.element) // 具体是哪个 DOM 节点
})
observer.observe({ type: 'largest-contentful-paint', buffered: true })
容易踩的坑:你以为 LCP 元素是那张大图,实际上可能是背后的一个 <div>。打开 DevTools → Performance 面板,录一次加载,看看 LCP 标记打在哪个节点上。很多时候优化了半天图片,结果 LCP 元素根本不是那张图。
LCP 优化的几个实操点:
<!-- 首屏大图:preload + fetchpriority 双管齐下 -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high" />
<!-- 非首屏图片:懒加载,别抢带宽 -->
<img src="/below-fold.webp" loading="lazy" alt="..." />
// 如果首屏内容依赖接口数据,考虑用 streaming SSR 或者把关键数据内联到 HTML
// ❌ 客户端渲染:HTML 到达 → 下载 JS → 执行 → 请求接口 → 渲染(串行地狱)
// ✅ SSR + 数据预取:HTML 到达时内容已经在里面了,LCP 直接吃 TTFB 那一程
INP — 交互到下一帧绘制
INP 是 2024 年替代 FID 的新指标。FID 只测"第一次"交互的延迟,INP 测的是整个页面生命周期内最慢的那次交互。
这个变化很关键。以前 FID 好看,是因为页面刚加载时主线程可能还没那么忙。用户点了几下之后,各种定时器、动画、懒加载回调堆上来,某一次点击卡了 400ms——FID 不管,INP 管。
// 一个典型的 INP 杀手:点击按钮触发同步的大量 DOM 操作
button.addEventListener('click', () => {
// ❌ 一次性渲染 2000 条数据,主线程直接被锁死
const fragment = document.createDocumentFragment()
for (let i = 0; i < 2000; i++) {
const div = document.createElement('div')
div.textContent = `Item ${i}`
fragment.appendChild(div)
}
container.appendChild(fragment) // 这一帧的渲染时间爆炸
})
怎么治?把长任务拆成小块,让浏览器有机会喘口气:
button.addEventListener('click', async () => {
const items = generateItems(2000)
const CHUNK_SIZE = 50
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE)
renderChunk(chunk)
// 每渲染一批就让出主线程,浏览器可以处理其他事件和绘制
await scheduler.yield() // Chrome 129+ 原生支持
// 兼容写法:await new Promise(resolve => setTimeout(resolve, 0))
}
})
scheduler.yield() 是个好东西,比 setTimeout(0) 优先级更高,任务恢复更快。不过兼容性还不够好,生产环境建议加个 fallback。
CLS — 累计布局偏移
CLS 衡量的是页面内容有没有"跳来跳去"。用户正要点一个按钮,突然上面插进来一张图把按钮挤下去了——点到了广告。这种体验谁都受不了。
/* ❌ 图片没设尺寸,加载完突然撑开 → 下面的内容集体下移 */
img {
width: 100%;
/* 高度未知,浏览器先给 0,图片加载完才知道 → 布局偏移 */
}
/* ✅ 用 aspect-ratio 预留空间 */
img {
width: 100%;
aspect-ratio: 16 / 9; /* 浏览器提前算好高度,不会跳 */
}
另一个 CLS 高发场景:Web Font 加载。系统字体和自定义字体的宽度不一样,字体切换时文本会重排。
/* 用 font-display: optional → 如果字体没在极短时间内加载完,直接放弃,用系统字体 */
/* 代价是有些用户看不到自定义字体,但换来零布局偏移 */
@font-face {
font-family: 'BrandFont';
src: url('/fonts/brand.woff2') format('woff2');
font-display: optional;
}
这是个取舍。swap 保证字体最终会显示但会闪,optional 不闪但可能不显示。我个人倾向于用 optional 配合字体预加载——大部分情况下字体能在短窗口内加载完,少数慢网络用户就降级到系统字体,体验反而更好。
实验室数据 vs 真实数据:中间差了什么
回到开头那个问题。Lighthouse 96 分,线上 LCP 4.2 秒,差在哪?
拉了一下数据分布,发现几个规律:
- 设备差异巨大。Lighthouse 默认模拟的是 Moto G Power 级别的中端机,但线上有大量千元安卓机,CPU 性能差 3-5 倍
- 网络环境不可控。实验室是稳定的模拟 4G,线上有地铁里信号时断时续的用户
- 缓存状态不同。Lighthouse 默认测冷启动,但线上大部分用户有部分缓存,性能分布呈双峰态
所以需要采集真实用户数据(RUM,Real User Monitoring)。可以用 web-vitals 这个库,Google 官方维护的:
import { onLCP, onINP, onCLS } from 'web-vitals'
// attribution 版本能告诉你具体是哪个元素 / 哪次交互导致的
import { onLCP, onINP, onCLS } from 'web-vitals/attribution'
onLCP((metric) => {
report({
name: 'LCP',
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
element: metric.attribution.element, // LCP 元素的 CSS 选择器
url: metric.attribution.url, // 如果是图片,给出图片 URL
})
})
onINP((metric) => {
report({
name: 'INP',
value: metric.value,
// attribution 能告诉你是哪个事件处理函数慢了
eventTarget: metric.attribution.eventTarget,
eventType: metric.attribution.eventType, // 'click' | 'keydown' | ...
})
})
function report(data) {
// 用 sendBeacon 发送,页面关闭时也不会丢数据
navigator.sendBeacon('/analytics', JSON.stringify(data))
}
拿到数据之后看 P75 而不是平均值。平均值会被极端值带偏,P75 更能反映"大部分用户的真实感受"。
一个实际优化案例
分享一个之前做的电商商品详情页优化。
初始状态: LCP P75 = 3.8s,INP P75 = 420ms
排查 LCP: 通过 attribution 数据发现 LCP 元素是商品主图。图片本身不大(80KB webp),但加载链路是:HTML → JS bundle → API 请求商品数据 → 拿到图片 URL → 开始下载图片。四级串行。
HTML ──→ JS(180KB) ──→ API(/detail) ──→ Image(80KB)
↑
LCP 在这里才开始
优化方案:在 SSR 阶段把商品主图 URL 直接输出到 HTML 里,加上 preload:
<!-- 服务端渲染时动态插入 -->
<link rel="preload" as="image" href="{{productImageUrl}}" fetchpriority="high" />
这样图片下载和 JS 下载并行,不用等 JS 执行完再发请求。LCP 从 3.8s 降到 1.9s。
排查 INP: attribution 数据显示最慢的交互是"切换 SKU"。点击不同规格,会触发价格计算、库存判断、图片切换,全在一个同步函数里跑。
拆解方案:把价格计算和库存判断放到 Web Worker 里,主线程只负责 UI 切换。
// sku-worker.ts
self.onmessage = ({ data: { skuId, skuMatrix } }) => {
const price = calculatePrice(skuId, skuMatrix) // 复杂的价格阶梯计算
const stock = checkStock(skuId) // 库存组合判断
self.postMessage({ price, stock })
}
// 主线程
const worker = new Worker('/sku-worker.js')
skuButton.addEventListener('click', (e) => {
const skuId = e.target.dataset.sku
// 图片切换立刻做,用户有即时反馈
updateProductImage(skuId)
// 价格和库存丢给 Worker 算,不阻塞主线程
worker.postMessage({ skuId, skuMatrix })
})
worker.onmessage = ({ data }) => {
updatePrice(data.price)
updateStockStatus(data.stock)
}
INP 从 420ms 降到 85ms。
优化的顺序很重要
碰到性能问题别上来就怼细节。我的经验是按这个优先级排:
网络层面先搞定。 CDN 配了没?Gzip/Brotli 开了没?HTTP/2 或 HTTP/3 用了没?这些是"配置一下就能生效"的事情,投入产出比最高。
加载策略其次。 关键资源 preload,非关键资源 lazy load,第三方脚本 async/defer。把加载瀑布图拉出来看看有没有串行可以改并行的。
渲染和交互最后。 长任务拆分、虚拟滚动、Web Worker 这些。因为这类优化往往要改业务代码,成本高,先把前两层低垂的果子摘了。
有一个反例是我之前花了两天给列表页加虚拟滚动,INP 确实好了,后来发现只要把一个阻塞渲染的第三方统计脚本改成 async,LCP 直接快了 1.5 秒。那两天虚拟滚动白干了吗?也不算,但优先级确实搞反了。
监控别只盯数字
最后聊一下监控。光看 P75 数值不够,要看趋势和分布。
有一次 LCP P75 从 2.1s 涨到 2.4s,幅度不大,没人在意。过了一周涨到 3.2s。回头一查,是某次上线加了一个同步的 A/B 实验 SDK,在 <head> 里阻塞了 300ms。如果第一天就发现趋势不对去排查,能少影响几百万次页面加载。
建议做两件事:
- 性能指标接入告警,P75 超过阈值或者周环比恶化超过 15% 就报警
- 每次上线前后对比性能数据,把性能当作和 bug 一样重要的回归指标
聊到这
Lighthouse 是个好工具,但别把分数当 KPI。它给你的是一个受控环境的快照,真实用户体验要靠线上数据说话。
优化的闭环应该是:Lighthouse 发现问题方向 → 线上数据确认优先级 → 针对性优化 → 线上数据验证效果。不是跑个分截个图就完事了。