Appearance
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 的延迟由三段组成:
- Input Delay:用户点了按钮,但主线程正在跑别的任务(比如正在解析一个 200KB 的 JSON),事件处理函数被排队等着
- Processing Time:事件处理函数实际执行的时间。如果你的 click handler 里做了同步 DOM 操作再触发连锁更新,这个阶段会很长
- 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 以内。
FCP 和 LCP 的关系:FCP 是"用户看到第一个像素",LCP 是"用户看到主要内容"。如果 FCP 很快但 LCP 很慢,说明首屏骨架渲染得快但主要内容(通常是 hero image 或大段文本)加载得慢。
TBT 和 INP 高度相关: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.90 和 minScore: 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 间的依赖关系。
