官网性能优化实战:LCP 3.2s → 2.1s、CLS 0.107 → 0,以及被我推翻的 6 个判断

一个官网多站点重构项目的性能收尾记录: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 ---

三个真正的根因,全是"时序问题",跟"代码写得快不快"没关系:

  1. LCP 图在隐藏视图里 ------ 它在 display:none 的容器中,浏览器不加载,要等 JS 水合翻转才发起请求 → 加载延迟 2338ms
  2. 图片到位了却画不出来 ------ 视图要等水合才显示 → 渲染延迟 1051ms
  3. 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。

十一、可复用的排查清单

按这个顺序走,基本不会迷路:

  1. 先量真实值 :CDP 注入 PerformanceObserver,采集 LCP / CLS / FCP / TTFB 与资源瀑布;不要一上来就看分数
  2. 拆 LCP 四段:TTFB / Load Delay / Load Time / Render Delay ------ 谁最大就打谁
  3. Load Delay 大 → 资源是不是晚发现 (隐藏容器、JS 注入、display:none、懒加载阈值)
  4. Load Time 大但资源很小 → 优先级问题(preload / fetchpriority),不是带宽问题
  5. Render Delay 大 → 元素是否等 JS(水合、动画、客户端渲染)
  6. CLS 排查 :高频采样 + 记录 previousRect/currentRect,直接锁定元素
  7. 图片三件套 :width+height(防 CLS,配 height:auto)、loading="lazy"(非首屏)、fetchpriority="high"(仅 LCP)
  8. 每次只改一项,前后各测一次,无效就回退并记录
  9. 门禁用真实限速 + 产物 + 三次中位,别用模拟值做决策

小结

  • 性能优化的瓶颈,八成在"时序/优先级",而不是"代码快不快"
  • 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

排出优先级的三个信号:

  1. FCP 很小、LCP 很大 (如 3.7s vs 0.9s)→ 资源发现/加载问题,本文第四节那套直接可用
  2. FCP 等于 LCP 且都很小 (如 8.0s / 8.0s,传输仅 11KB)→ 整页在等 JS,是渲染模式问题,得改 SSR/SSG
  3. CLS 超标(0.276 / 0.243)→ 用高频采样锁元素,通常是图片缺尺寸或异步内容插入

一句话:同一套测量口径跑全站,比逐个凭感觉优化高效得多。

相关推荐
xiancai_xianyu1 小时前
Text2SQL落地生产库前,SQL要过哪三道闸
前端·数据库·sql·sql注入·text2sql·ai agent开发·企业级ai框架
小羊没烦恼!1 小时前
T-SQL查询进阶--理解SQL SERVER中的分区表
服务器·开发语言·前端·javascript·html
史瓦辛格1 小时前
给 DeepSeek Harness 写费用插件:把「这次会话花了多少钱」算准
前端·deepseek
星光不问赶路人1 小时前
Vue 3 + TypeScript 中 enum 的使用实践
前端·vue.js
鸥梨菌Honevid1 小时前
UVM 中 REQ 和 RSP 类型详解:从通信模型到 item_done 与 put_response
java·前端·servlet
IT_陈寒1 小时前
Python多进程共享变量这个坑,差点让我加班到凌晨
前端·人工智能·后端
鸿蒙开发2 小时前
PDF 盖黑框不等于脱敏:用 PDF.js + Canvas + pdf-lib 实现永久遮盖
前端·react.js
xixingzhe22 小时前
Nginx 看懂请求耗时瓶颈
服务器·前端·nginx
Lank_M2 小时前
前端AVIF编码方案评审,九个想当然逐个实测
前端