前端性能优化:Lighthouse 跑满分,用户还是说慢?

5 阅读1分钟

前端性能优化: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 秒,差在哪?

拉了一下数据分布,发现几个规律:

  1. 设备差异巨大。Lighthouse 默认模拟的是 Moto G Power 级别的中端机,但线上有大量千元安卓机,CPU 性能差 3-5 倍
  2. 网络环境不可控。实验室是稳定的模拟 4G,线上有地铁里信号时断时续的用户
  3. 缓存状态不同。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。如果第一天就发现趋势不对去排查,能少影响几百万次页面加载。

建议做两件事:

  1. 性能指标接入告警,P75 超过阈值或者周环比恶化超过 15% 就报警
  2. 每次上线前后对比性能数据,把性能当作和 bug 一样重要的回归指标

聊到这

Lighthouse 是个好工具,但别把分数当 KPI。它给你的是一个受控环境的快照,真实用户体验要靠线上数据说话。

优化的闭环应该是:Lighthouse 发现问题方向 → 线上数据确认优先级 → 针对性优化 → 线上数据验证效果。不是跑个分截个图就完事了。