一个官网多站点重构项目的性能收尾记录:LCP 3.2s → 2.1s、CLS 0.107 → 0、传输量降 51%。全文只用一个原则------每个结论都要有实测数据支撑,无效改动一律回退并记录。
性能优化最容易犯的错,不是不会优化,而是优化了不存在的瓶颈。
这篇文章记录一次真实的排查:从一个"看起来已经达标"的门禁结果出发,最后把 LCP 从 3.2s 压到 2.1s、CLS 从 0.107 压到 0、传输量降 51%。中间有三个根因,以及六个被我自己推翻的判断------后面这部分可能比结论更值钱。
术语表:本文出现的指标分别是什么
体验指标(Core Web Vitals 及其邻居)
| 缩写 | 英文全称 | 中文 | 衡量什么 | 达标线(用户侧 p75) |
|---|---|---|---|---|
| LCP | Largest Contentful Paint | 最大内容绘制 | 首屏最大元素渲染完成的时间 | ≤2.5s |
| FCP | First Contentful Paint | 首次内容绘制 | 页面首次画出内容的时间 | ≤1.8s |
| CLS | Cumulative Layout Shift | 累积布局偏移 | 视觉稳定性:元素意外位移的累计量 | ≤0.1 |
| TBT | Total Blocking Time | 总阻塞时间 | 主线程被长任务阻塞的累计时长 | ≤200ms |
| TTFB | Time to First Byte | 首字节时间 | 服务器/网络返回第一个字节的耗时 | ≤800ms |
| INP | Interaction to Next Paint | 交互到下次绘制 | 交互响应性(2024-03 起取代 FID) | ≤200ms |
| FID | First Input Delay | 首次输入延迟 | 旧指标,已被 INP 取代 | --- |
方法 / 数据类
| 缩写 | 英文全称 | 中文 | 含义 |
|---|---|---|---|
| RUM | Real User Monitoring | 真实用户监控 | 采集真实用户的体验数据(唯一考核口径) |
| Lab | Lab Data | 实验室数据 | Lighthouse 等在受控设备与网络下的测量(做回归门禁) |
| p75 | 75th percentile | 75 分位 | 75% 的用户体验不低于此值 |
| CDP | Chrome DevTools Protocol | Chrome 开发者工具协议 | 直接给浏览器打点、采集真实时序 |
| DPR | Device Pixel Ratio | 设备像素比 | 物理像素 ÷ CSS 像素 |
| SSG | Static Site Generation | 静态站点生成 | 构建期就把 HTML 生成好 |
| SSR | Server-Side Rendering | 服务端渲染 | 请求到达时在服务端渲染 HTML |
LCP 的四段拆分(第四节会用到)
| 阶段 | 中文 | 含义 | 数值大说明什么 |
|---|---|---|---|
| TTFB | 首字节时间 | 请求发出 → 收到第一个字节 | 服务端或网络慢 |
| Load Delay | 加载延迟 | 收到首字节 → 开始下载 LCP 资源 | 资源发现太晚(隐藏容器、JS 注入、懒加载阈值) |
| Load Time | 加载耗时 | 资源下载耗时 | 带宽或优先级问题 |
| Render Delay | 渲染延迟 | 资源就绪 → 实际绘制 | 主线程忙 / 元素还不可见 |
为什么看 p75 而不是平均值:性能数据是长尾分布,平均值会被"一部分极快用户"拉平。p75 = 75% 的用户体验不差于此值,这也是 Core Web Vitals 的官方口径。
为什么不能只看 Lab 分数:Lab 是受控环境,模拟模型可能失真(本文第二节有实测:同一次运行模拟 3082ms、观测 146ms)。对外汇报要用 RUM 的 p75。
一、先给结论
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| LCP | 3.2s | 2.1s(三次中位) | -34% |
| CLS | 0.107 | 0 | 达标 |
| FCP | 1.4s | 0.9s | -36% |
| TBT | 30ms | 20ms | -33% |
| 总传输 | 868 KiB | 427 KiB | -51% |
| 首屏外图片浪费 | 400 KiB | 96 KiB | -76% |
| Lighthouse Performance | 93 | 97~100 | --- |
三个真正的根因,全是"时序问题",跟"代码写得快不快"没关系:
- LCP 图在隐藏视图里 ------ 它在
display:none的容器中,浏览器不加载,要等 JS 水合翻转才发起请求 → 加载延迟 2338ms - 图片到位了却画不出来 ------ 视图要等水合才显示 → 渲染延迟 1051ms
- Chrome 给
rel=preload的图片默认 Low 优先级 ,而且会覆盖<img fetchpriority="high">
再加上一个 CLS 根因:图片没有尺寸属性,加载完成把下方内容推下去 116px。
二、第一件事:把口径定对,否则后面全是白干
这次最大的教训在开头:Lighthouse 的模拟值(Lantern)和真实限速的观测值,同一次运行里差了 20 倍。
同一份产物、同一次 Lighthouse 运行:
LCP 模拟值(simulate) = 3082ms
LCP 观测值(observed) = 146ms
原因:模拟模型不知道我们已经在 <head> 里 preload 了 LCP 图、也不知道视图在首帧前就切换好了,于是按"所有请求都从慢网排队开始"来推算。
所以口径要分三层,别混:
| 层 | 数据 | 用途 | 判定 |
|---|---|---|---|
| 真实用户(RUM) | Core Web Vitals p75 | 对外汇报、体验考核 | LCP ≤2.5s · INP ≤200ms · CLS ≤0.1 |
| 实验室(Lab) | Lighthouse 真实限速(devtools) | 回归门禁 | Perf ≥90 · 同一组 CWV 阈值 |
| CDP 实测 | 自己用 DevTools Protocol 打点 | 定位瓶颈 | --- |
结论:门禁用 throttlingMethod: "devtools"(真实限速),汇报用 RUM 的 p75,定位用 CDP。 用 simulate 的分数做决策,会优化错地方。
三、第二步:把门禁建起来,不然优化完就还回去
优化前先立规矩。性能预算写进 CI,超了就失败:
json
{
"categories:performance": ["error", { "minScore": 0.9 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"total-byte-weight": ["warn", { "maxNumericValue": 500000 }],
"uses-responsive-images": ["warn", { "maxLength": 0 }]
}
两个容易忽略的点:
1. 门禁要测「构建产物」,不是「线上地址」。 我们的重构版还没切流,线上跑的是旧站------如果门禁测线上,它一直在量旧代码,重构成果永远进不了门禁。做法是跑构建产物,并处理子路径部署(产物 base 是 /tmp-index/,用一层软链挂回原路径)。
2. 三次取中位。 真实限速的方差比模拟大,单次结果会误导。CI 里跑 3 次取 median-run。
四、根因 1:LCP 图在 display:none 里(加载延迟 2338ms)
把 LCP 拆成四段后,问题一眼可见:
TTFB 452ms
Load Delay 2338ms ← 全卡在这
Load Time 35ms ← 图本身只有 12KB,传输极快
Render Delay 259ms
为什么加载延迟 2.3 秒? 因为移动端首屏图和桌面首屏图在 DOM 里是两棵树,用 v-show 切换:
html
<div v-show="!isMobile"> ...桌面 ... </div>
<MobileView v-show="isMobile" /> <!-- LCP 图在这里 -->
服务端渲染时 isMobile 还是 false,所以移动视图带着 display:none 输出;而浏览器不会加载 display:none 容器里的图片 ------它要等 JS 水合、把 isMobile 翻成 true 之后才开始下载。
修法:在 <head> 里 preload,让浏览器解析 HTML 时就发起请求。
但这里有个坑,我第一次就踩了:preload 必须带 imagesrcset / imagesizes,且和 <img> 完全一致 。否则 preload 选中的候选和 <img> 最终选中的候选不是同一个文件,等于下载两遍------实测总传输从 532KB 涨到 848KB,LCP 反而更差。
正确的写法:
html
<link rel="preload" as="image"
href="/img/hero-1x.webp"
imagesrcset="/img/hero-1x.webp 700w, /img/hero.webp 1284w"
imagesizes="(max-width: 1280px) 90vw, 1284px"
media="(max-width: 1279px)"
fetchpriority="high">
五、根因 2:图片到位了,但画不出来(渲染延迟 1051ms)
preload 之后加载延迟从 2338ms 降到 1538ms,但 LCP 没怎么变------因为渲染延迟从 259ms 涨到 1051ms。
同一个原因:移动视图要等 JS 水合才 display:block。图片早就在内存里了,但它在 display:none 的树里,浏览器不会绘制。
修法:让视图切换在首屏绘制前用 CSS 生效(预水合 class)。
html
<!-- 内联在 <head>,先于首屏绘制执行 -->
<script>
(function(){try{
var w=innerWidth,h=innerHeight;
if(w<1280||(w/h<=1.2&&w<=1440)) document.documentElement.classList.add('is-mobile-pre');
}catch(e){}})();
</script>
css
html.is-mobile-pre .desktop-scroll-container { display: none !important; }
html.is-mobile-pre .mobile-wrapper { display: block !important; }
!important 是用来覆盖 v-show 写在内联 style 上的 display:none。判断条件必须和 JS 里的 isMobile 逐字一致,否则会出现"水合后视图跳一下"。
这一招把渲染延迟直接压到 40~60ms。
六、根因 3:Chrome 给 preload 图默认 Low 优先级
这是最反直觉的一条。
<img src="hero.webp" fetchpriority="high"> ← 我们以为够了
<link rel="preload" as="image" href="hero.webp"> ← 但它覆盖了上面
Chrome 对 rel=preload as=image 默认给 Low 优先级 。当 preload 和 <img> 指向同一资源时,preload 的优先级生效 ,于是那个 12.6KB 的图在慢网下要 1.3~1.6 秒------不是传输慢,是排队靠后。
加一个属性就解决:
html
<link rel="preload" as="image" href="hero.webp" fetchpriority="high">
实测该请求优先级从 Low 变成 High,LCP 从 2.4s 降到 2.1s。
顺带还发现一个自己挖的坑 :我们给首屏下方 的一张 43KB 图(floor2)也加了 fetchpriority="high"------它根本不是 LCP 元素,却在最关键的窗口里抢带宽。改成 loading="lazy" 后,LCP 波动明显变小。
七、根因 4:图片没尺寸,把页面推下去 116px(CLS 0.107)
CLS 排查有个高效办法:高频采样 + 记录 rect 变化。定位结果非常干净------一次偏移,源头两个:
IMG.warp-logo h 0 → 116px ← 加载前高 0,加载后 116
grid-item-logo IMG h 0 → 149px ← 首屏下方宫格图,同样没尺寸
css
/* 只有 width,没有 height → 加载前占位 0 */
.warp-logo { display: block; width: 180px; }
修法:补 width/height 属性 + CSS height: auto。
这里必须成对出现,否则会踩另一个坑:如果只加属性不加 height: auto,属性里的 height 会变成固定高度 ,图片被纵向拉伸。我们中途就出过一次这样的视觉缺陷,最后是 Lighthouse 的 image-aspect-ratio 审计抓出来的(它比对"显示比例"和"真实比例",一旦不等就是变形):
floor1_bg 显示 353×723 实际 1284×723 ← 比例 0.49 vs 1.77,明显被拉长
八、顺手捡到的两个失控资产
排查带宽竞争时,报告里冒出两个不该存在的体积:
| 资产 | 问题 | 处理 | 结果 |
|---|---|---|---|
platform-qxxz.svg |
1549 KB,只有 15 条路径 | 坐标数据爆炸,svgo 只能减 0.1% → 栅格化成 WebP | 10.1 KB(-99%) |
platform-wdytb.svg |
173 KB,内嵌 base64 位图 | 同上 | 5.3 KB(-96%) |
图片方面也一样,移动端几张图重新编码:
| 图片 | 前 | 后 | PSNR(白底合成) |
|---|---|---|---|
| 全新协作 | 139 KB | 22 KB | 40.3 dB |
| 文档云同步 | 130 KB | 17 KB | 45.3 dB |
| 设备协同 | 127 KB | 16 KB | 43.4 dB |
| 文档流转 | 89 KB | 13 KB | 45.8 dB |
| 合计 | 591 KB | 157 KB(-73%) | 全部 ≥40 dB |
质量核验有个坑:这几张图带 alpha 通道 。直接比对 RGB 会得到 27dB 的"严重劣化"假象------因为透明区域的 RGB 是无意义的。正确做法是合成到白底再比,那才是用户看到的画面。
九、被我推翻的 6 个判断
这部分建议细看:每一个都"看起来很有道理"。
| # | 我的判断 | 实测结果 | 处置 |
|---|---|---|---|
| 1 | CSS 到 2070ms 才到位、LCP 紧随其后 → CSS 是瓶颈 | 把 CSS 改成渲染关键资源后,LCP 不变、FCP 反而变差(0.3→0.5s) | 回退 |
| 2 | features.inlineStyles: true 能把样式内联进 HTML |
产物无任何变化 | 回退 |
| 3 | 给未占位图片加 aspect-ratio 能消除 CLS |
A/B 两轮 CLS 都是 0.099,无可测量效果 | 移除(width/height 已够) |
| 4 | 所有大图都值得重新编码 | 有两张重编码后体积几乎不变(78→78KB、73→73KB) | 还原,避免二次压缩 |
| 5 | 生成 700w 中间档能进一步省流量 | 源图 981px → 700px 只小 30%,单张省 4KB | 不值得,不实施 |
| 6 | preload 加上就完事 | 不带 imagesrcset 时选中候选不同 → 下载两遍,LCP 更差 |
补齐属性 |
共同点 :都是"局部数据看着像因果"就下了结论。唯一可靠的裁判是「改完再测一遍」。
所以我们的规矩是:一次只改一项 → 前后各测一轮 → 无效就回退 → 并把无效记录写进文档。第 1 条那个判断,正是被"改成渲染关键资源后 LCP 没变"直接证伪的。
十、最终结果
| 指标 | 首轮达标 | 最终 |
|---|---|---|
| LCP | 2.4s(2.3/2.4/2.6) | 2.1s(2.1/1.9/2.3) |
| CLS | 0.107 | 0 |
| FCP | 1.4s | 0.9s |
| Performance | 97--98 | 98--100 |
| 总传输 | 533 KiB | 427 KiB |
| 图片尺寸不当 | 7 项 | 4 项 |
另外用 CDP 真实限速(Slow 4G + 4× CPU)实测:LCP 0.9s、FCP 0.3s。
十一、可复用的排查清单
按这个顺序走,基本不会迷路:
- 先量真实值 :CDP 注入
PerformanceObserver,采集 LCP / CLS / FCP / TTFB 与资源瀑布;不要一上来就看分数 - 拆 LCP 四段:TTFB / Load Delay / Load Time / Render Delay ------ 谁最大就打谁
- Load Delay 大 → 资源是不是晚发现 (隐藏容器、JS 注入、
display:none、懒加载阈值) - Load Time 大但资源很小 → 优先级问题(preload / fetchpriority),不是带宽问题
- Render Delay 大 → 元素是否等 JS(水合、动画、客户端渲染)
- CLS 排查 :高频采样 + 记录
previousRect/currentRect,直接锁定元素 - 图片三件套 :
width+height(防 CLS,配height:auto)、loading="lazy"(非首屏)、fetchpriority="high"(仅 LCP) - 每次只改一项,前后各测一次,无效就回退并记录
- 门禁用真实限速 + 产物 + 三次中位,别用模拟值做决策
小结
- 性能优化的瓶颈,八成在"时序/优先级",而不是"代码快不快"
display:none是隐形杀手:它同时干掉"加载"和"绘制"- Chrome 的 preload 图片默认 Low 优先级,必须显式提权
- 图片没尺寸 = CLS;但加了尺寸属性必须同时给
height: auto,否则变成拉伸 - 最有价值的一步是"改完再测" ------ 我这次有 6 个判断被实测推翻,如果照着直觉改下去,工期会白花一大半
下一次优化,先别写代码。先把 LCP 拆成四段,看看时间到底花在哪。
附:多站点体检(2026-10,同一套方法)
这套方法不只对一个站有效。把 9 个官网用同一口径(CDP 真实节流,移动端 Slow 4G + 4× CPU)过一遍,差距一眼可见:
| 站点 | LCP | FCP | CLS | 传输 | 对照大厂线(LCP≤2.5s / CLS≤0.1) |
|---|---|---|---|---|---|
| linux | 1.4s | 1.4s | 0 | 14KB | ✅ 标杆 |
| 投资者关系 | 1.8s | 1.0s | 0.276 | 541KB | ❌ 布局偏移超标 |
| 产品页 A | 2.5s | 1.1s | 0 | --- | ⚠️ 贴线 |
| 平台首页(旧版) | 2.6s | 2.3s | 0.243 | 894KB | ❌(重构版实测 2.1s / 0) |
| 企业版下载页 | 2.9s | 0.6s | 0 | 298KB | ⚠️ |
| 产品页 B | 3.7s | 0.9s | 0 | --- | ❌ LCP |
| 企业版首页 | 4.1s | 1.1s | 0 | 1456KB | ❌ LCP + 传输最重 |
| 主站 | 8.0s | 8.0s | 0.109 | 11KB | ❌ FCP=LCP,整页等 JS |
| 校对站(旧版) | 9.2s | 9.2s | 0 | 1093KB | ❌ 1MB 单包 JS |
排出优先级的三个信号:
- FCP 很小、LCP 很大 (如 3.7s vs 0.9s)→ 资源发现/加载问题,本文第四节那套直接可用
- FCP 等于 LCP 且都很小 (如 8.0s / 8.0s,传输仅 11KB)→ 整页在等 JS,是渲染模式问题,得改 SSR/SSG
- CLS 超标(0.276 / 0.243)→ 用高频采样锁元素,通常是图片缺尺寸或异步内容插入
一句话:同一套测量口径跑全站,比逐个凭感觉优化高效得多。