Skip to content

Chrome Lighthouse 与网页性能:从指标到优化

Lighthouse 是什么

Lighthouse 本质上是一个运行在 Chrome 中的自动化审计工具。它通过 Chrome DevTools Protocol(CDP)控制浏览器,在受控条件下加载页面、收集数据并执行审计规则,最后输出报告。

它的内部架构是标准的三段式:Gatherer → Audit → Report。Gatherer 负责收集原始数据(Performance Trace、DOM 快照、网络请求瀑布),Audit 拿到 Gatherer 产出的数据后执行诊断逻辑,Report 再将结果组织成可读报告。

还有一点需要注意:Lighthouse 默认使用的不是真实网络环境,而是 Lantern 模型。它会模拟 4G 慢速网络和中等性能 CPU(Moto G4 级别),因此 Lighthouse 分数和高性能开发机上的实际体验可能差异明显。

三种模式什么时候用

Navigation 模式是最常用的,模拟用户从空白页导航到目标页面。会走完整的页面生命周期:网络请求 → HTML 解析 → 资源加载 → 渲染。适合首屏性能评估。

Timespan 模式比较特殊——你手动控制开始和结束时间,中间可以随意操作页面。这个模式专治 SPA 场景:路由切换、表单提交、弹窗交互。Navigation 模式测不了这些,因为它只关注页面加载。

Snapshot 模式最简单,就是拍一张当前页面的"快照",不做任何加载。适合测已经渲染好的页面的可访问性、SEO 状态。

评分到底怎么算的

Lighthouse 的 0-100 分不是随便给的。每个审计项有明确权重:

  • TBT(Total Blocking Time)占 30%——权重最高,说明 Google 最在乎主线程空闲度
  • LCP 和 CLS 各占 25%
  • FCP 和 SI 各占 10%

这里面有个值得注意的点:TBT 权重最高,但它和 LCP 经常是矛盾的。你把 JS bundle 拆小、延迟加载非关键脚本,TBT 会改善;但如果 LCP 元素恰好依赖某段异步加载的 JS 去触发渲染,你的 LCP 反而会恶化。这种 tradeoff 在 Lighthouse 的单一分数里完全体现不出来。


性能指标,从浏览器渲染管线说起

理解性能指标,最好从浏览器怎么渲染一帧开始。

一个典型的渲染帧走三个阶段:Layout → Paint → Composite。Layout 计算元素的位置和大小,Paint 生成像素数据,Composite 把各层合成最终画面。

然后每个指标几乎都跟这些阶段有因果关系。

LCP — 最大内容绘制

LCP 测量的是视口内最大的可见元素渲染出来的时间。这里的"最大"是动态变化的——浏览器在解析 HTML 的过程中不断检测,一旦出现更大的文本块或图片,LCP 就会更新。直到用户首次交互或页面完全加载,LCP 才最终确定。

这意味着如果首屏 hero image 的 src 依赖 JS,在 hydration 完成后才赋值,LCP 往往会明显偏慢。浏览器在 JS 执行完成前根本不知道这张图的存在。

LCP 的实际瓶颈通常在这几个地方:

  • TTFB 太高:服务器响应慢,HTML 都没回来,后面的都免谈
  • 关键资源加载链太长:HTML → CSS → Web Font → Hero Image,每个环节都在排队
  • 渲染被 JS 阻塞:LCP 元素已经加载完了,但主线程被长任务占着,浏览器没空渲染

INP — 取代 FID 的原因

FID 只测第一次交互的输入延迟。问题是,用户最卡的交互往往不是第一次点击——用户操作了几分钟后,DOM 越来越复杂、事件监听器越绑越多,后面的交互延迟可能比第一次大几倍。

Google 在 2024 年 3 月用 INP 正式取代 FID 成为 Core Web Vital,原因在于它不再只关注第一次交互,而是统计整个页面生命周期内交互延迟的 P75。

INP 的延迟由三段组成:

  1. Input Delay:用户点了按钮,但主线程正在跑别的任务(比如正在解析一个 200KB 的 JSON),事件处理函数被排队等着
  2. Processing Time:事件处理函数实际执行的时间。如果你的 click handler 里做了同步 DOM 操作再触发连锁更新,这个阶段会很长
  3. Presentation Delay:事件处理完了,但浏览器要等到下一个 vsync 信号才会绘制新帧

优化 INP 的重点不只是“把代码写快”,而是拆长任务。浏览器一帧的预算是 16.6ms,任务一旦超过 50ms(长任务阈值),就会明显阻塞用户交互。

CLS — 布局偏移

CLS 的公式很简单:影响区域比例 × 距离比例。但真正理解它需要知道什么操作会触发 Layout 重计算。

关键在于:只有意外的布局偏移才计入 CLS。用户在 500ms 内触发的交互导致的布局变化不算——浏览器认为这是用户预期的。

常见的 CLS 肇事者:

  • 图片没有 width/height,加载完成后撑开页面
  • Web Font 加载后 fallback 字体被替换,同屏文本的 metrics 变化
  • 广告脚本异步注入一个 300px 高的 iframe
  • Cookie Consent Banner 在页面顶部动态插入

其他指标怎么用

TTFB 拆开看是一串网络延迟的叠加:Redirect → Service Worker → DNS → TCP → TLS → Request → Response。每一步都可能成为瓶颈。800ms 以内通常算良好,但如果是 CDN 托管的静态站点,TTFB 一般应控制在 100ms 以内。

FCPLCP 的关系:FCP 是"用户看到第一个像素",LCP 是"用户看到主要内容"。如果 FCP 很快但 LCP 很慢,说明首屏骨架渲染得快但主要内容(通常是 hero image 或大段文本)加载得慢。

TBTINP 高度相关:TBT 高意味着主线程长期被阻塞,INP 必然好不了。但 TBT 只在 FCP 到 TTI 之间计算,INP 覆盖整个页面生命周期——所以 TBT 好不代表 INP 一定好。


优化不是堆技术,是排优先级

LCP 优化——把关键路径缩短

LCP 的优化核心就一条:让 LCP 元素尽快被浏览器发现、加载、渲染

服务端侧

  • SSR / SSG 直接把 HTML 带 LCP 内容下发,省掉 CSR 那一大段 JS 解析时间。这个决策的 tradeoff 是服务器成本和构建时间——大规模 SSR 需要不少 Node 实例
  • CDN 缓存静态 HTML,离用户越近 TTFB 越低

资源加载侧

  • 对 LCP 元素用 preload<link rel="preload" as="image" href="hero.webp">。但别滥用——preload 多了会跟其他资源抢带宽,反而拖慢整体加载
  • 图片用 WebP/AVIF 格式,尺寸能砍 30%-50%。但 AVIF 编码慢,不适合用户上传图片的实时处理场景
  • LCP 元素不要加 loading="lazy"——lazy loading 的设计就是延迟加载,和 LCP 优化的目标背道而驰
  • 关键 CSS 内联到 <head>,避免 CSSOM 构建阻塞渲染

渲染侧

  • JS 用 defer 加载,不要用 async——defer 保证执行顺序且不阻塞 HTML 解析,async 谁先下载完谁执行,可能打断解析
  • 第三方脚本(分析、广告、客服)全部延迟加载。这些脚本通常不在你的控制范围内,它们导致的性能问题往往比你自己写的代码严重得多

INP 优化——拆长任务

INP 的本质问题是主线程不够用

拆任务

  • 把大段同步计算移到 Web Worker。但 Worker 不能直接操作 DOM,你需要设计数据层和 UI 层的交互协议
  • scheduler.postTask()setTimeout(fn, 0) 聪明——它理解任务优先级(user-blocking > user-visible > background),浏览器会在帧之间插入执行
  • requestIdleCallback 适合跑低优先级任务,但它不保证执行时机——如果浏览器一直忙,回调可能永远不执行

减少事件处理开销

  • 事件委托:一个父元素监听代替 N 个子元素各绑定。但这要求事件冒泡能正常工作——部分第三方库会 stopPropagation
  • passive: true 告诉浏览器不会调 preventDefault(),浏览器可以立即滚动而不等事件处理完
  • 避免在 scroll/touchmove handler 里触发 Layout——这些事件频率很高,一次 Layout 抖动就掉帧

React/Vue 特定

  • React 的 useMemo / useCallback 不是免费的——它们本身有内存和比较成本。只在计算确实昂贵或引用相等性影响子组件渲染时用
  • 大列表用虚拟滚动。DOM 节点从 10000 个降到 30 个,Layout/Paint 成本呈指数级下降
  • Vue 的 v-memo 在 v-for 里可以跳过子树更新,但它依赖你传对依赖数组

CLS 优化——给浏览器预留空间

CLS 的修复往往最直接,但最容易被忽略。

给所有媒体元素设置尺寸

css
img { aspect-ratio: attr(width) / attr(height); }

或者用 CSS aspect-ratio 属性显式声明。浏览器拿到 HTML 就能算出占位空间,图片加载完不会挤页面。

字体加载

  • font-display: optional 最适合性能优先的场景:如果字体在 100ms 内没加载完,浏览器就用 fallback,且之后不切换——零 CLS。代价是用户可能看不到自定义字体
  • font-display: swap 会给字体一个加载窗口期(通常 3s),超时后先用 fallback 显示,字体加载完再切换——有 CLS 风险但用户能看到自定义字体
  • size-adjust 属性可以调整 fallback 字体的 metrics 使其接近 Web Font,从根本上减少切换时的偏移

动态内容

  • 广告位用 min-height 或 CSS Grid 预留空间。即使广告脚本没加载成功,页面布局也不会塌陷
  • Cookie Banner、通知栏从页面顶部插入时,用 CSS transform 做动画而不是 height/width——transform 只触发 Composite,不走 Layout

Lighthouse CI — 把性能检测放进 CI/CD

Lighthouse 最容易被低估的用法不是手动跑一次看分数,而是 Lighthouse CI。

在 CI 流程里跑 Lighthouse,每次 PR 都能看到性能变化:

yaml
# .github/workflows/lighthouse.yml
- name: Run Lighthouse CI
  run: |
    lhci autorun --config=.lighthouserc.js

.lighthouserc.js 里设置断言:

javascript
module.exports = {
  ci: {
    assert: {
      assertions: {
        'categories:performance': ['error', { minScore: 0.9 }],
        'largest-contentful-paint': ['error', { maxLength: 2500 }],
        'total-blocking-time': ['error', { maxLength: 300 }],
        'cumulative-layout-shift': ['error', { maxLength: 0.1 }],
      },
    },
  },
}

一旦有 PR 让 LCP 超过 2500ms,CI 直接报红——性能回退在合并前就被拦截了。

但这里有个坑:Lighthouse CI 的分数会波动。同一段代码跑两次,分数可能差 3-5 分。因为 CI runner 的 CPU/网络不是完全稳定的。所以阈值要设置得合理——别把 minScore: 0.90minScore: 0.87 当成本质区别。


实验室数据 vs 真实用户数据

Lighthouse 的分数是实验室数据(Lab Data)——在受控环境下测出来的。它可复现、适合调试,但它不是你用户的真实体验。

真实用户数据来自 Chrome UX Report(CrUX)或者你自己埋点的 RUM 系统。CrUX 是 Google 从 Chrome 用户那里匿名采集的数据,覆盖数百万网站,可以通过 PageSpeed Insights 或 BigQuery 查询。

常见的不一致场景:

  • 你的 Lighthouse LCP 是 1.5s,但 CrUX 显示 P75 LCP 是 3.2s。因为你的用户里有很多中低端 Android 设备
  • 你在美国西海岸测 TTFB 是 100ms,但印度用户的 TTFB 是 800ms
  • 你的页面在 4G 下表现很好,但用户里有 30% 在 3G 网络下

所以实际的性能监控体系应该是:Lighthouse CI 做回归检测 + RUM 做真实体验监控。两者缺一不可。


容易被忽略的几个点

第三方脚本是性能的最大变量。一次我排查一个页面的 TBT,发现一个 GTM 容器里装的聊天插件在主线程占用了 1.2s。把它延迟到 onload 之后加载,TBT 直接降了 40 分。第三方脚本的性能成本不应该被低估。

preload 和 preconnect 是双刃剑<link rel="preconnect"> 提前建立到第三方源的连接(DNS + TCP + TLS),看起来无害。但如果页面引用了 15 个不同的第三方域,全都 preconnect——每个连接消耗客户端和服务端的资源,而且过早建立的连接可能在你真正请求之前就超时关闭了。

will-change 的 GPU 内存代价will-change: transform 告诉浏览器"这个元素可能会变",浏览器提前创建合成层。但如果给一整个列表的 500 个元素都加了 will-change,等于告诉浏览器"给 500 个元素各建一个合成层"——你的 GPU 内存直接爆炸。只在真正需要动画的元素上用,动画结束后移除。

Code Splitting 可能导致瀑布流。把 bundle 拆成 20 个小 chunk,看起来合理——但如果 chunk A 加载完才去请求 chunk B,chunk B 加载完才去请求 chunk C,你等于把一个大文件的一次加载,变成了一串小文件按顺序加载。用 <link rel="modulepreload"> 或 webpack 的 prefetch 可以缓解,但架构上需要考虑 chunk 间的依赖关系。