5 次优化让首屏快 3.6 秒,只有 1 次是改代码

5.1 秒到 1.5 秒,中间隔着 5 次改动,只有 1 次碰了业务代码。

上周我做了个受控实验:搭一个埋满典型性能坑的活动落地页,用 Lighthouse 移动端模拟(Slow 4G + 4 倍 CPU 降速),每改一处测一次。所有数字都是实测,不是估算。

先看总账:

步骤 改动 LCP TBT 性能分
原始页 --- 5.1s 0ms 77
第 1 次 JPG 换 WebP 3.7s 0ms 86
第 2 次 加 preload 2.6s 0ms 93
第 3 次 script 加 defer 2.6s 990ms 75 ↓
第 4 次 长任务切片(唯一改代码) 2.2s 0ms 99
第 5 次 按视口出图 1.5s 0ms 100

注意第 3 次那一行:改完分数反而从 93 跌到 75。不是测错了,是这次实验里最有意思的发现,后面细说。

实验设定

页面是一个大会落地页:一张 1920 宽的 JPG 头图(408KB,background-image 挂首屏),一段 30KB 的同步 JS(轮播初始化里藏着 6000 万次循环的长任务),加常规的标题、议程卡片、CTA。没有框架和第三方脚本,变量干净。

真机上跑,绝对值不会这么整齐,但每步改动的因果方向是可靠的------生产页上你永远说不清那 800 毫秒是谁贡献的,受控实验能。

Lighthouse 的分数看着抽象,LCP 分解倒是很诚实。原始页的 5.1 秒拆出来:

LCP 组成 耗时 意思
发现延迟 2.34s 浏览器很晚才知道要下这张图
加载时长 2.74s 图太大,下载要 2.7 秒
渲染延迟 0.01s 到位即渲染

后面 5 次优化,本质上就是把这四段挨个打掉。

第 1 次:JPG 换 WebP,5.1s → 3.7s

最没技术含量的一步,性价比却最高:

bash 复制代码
cwebp -q 90 hero.jpg -o hero.webp

408KB 变 141KB,画质肉眼无差,LCP 加载时长从 2.74s 掉到 1.33s。

1.6Mbps 带宽下,408KB 意味着光下载就 2 秒多。首屏最大的元素通常是头图,LCP 的主角就是它------头图胖,LCP 必胖。动手调代码之前,先把资源过一遍秤,图片超重是营销页最常见也最便宜的病。

第 2 次:一行 preload,3.7s → 2.6s

换完 WebP,加载时长降了,发现延迟还是 2.34s:浏览器要等 CSS 下载、同步 JS 执行完、body 开始解析,才从 style 里读到头图 URL,然后才开始下载这张最重要的图。

所以让头图插队:

html 复制代码
<link rel="preload" as="image" href="hero.webp" fetchpriority="high">

放在 head 里,浏览器解析到这行就发请求,不等 CSS 也不等 JS。发现延迟从 2.34s 砍到 0.58s。fetchpriority="high" 是让浏览器把带宽优先分给头图------没有它,preload 只是提前排队;CSS 里引用的背景图是全页面藏得最深的关键资源,这招对它尤其有效。

第 3 次:加 defer,LCP 没动,分数跌 18

前两次干掉的是图片的问题,第 3 次轮到那个 30KB 的同步 JS:

html 复制代码
<script src="app.js" defer></script>

直觉上这步稳赚:脚本不再阻塞解析。实测结果却是 LCP 纹丝不动(2.6s),TBT 从 0 涨到 990ms,性能分 93 → 75。

原因不玄:那个 6000 万次循环的长任务本来就在,只是位置变了。同步脚本时代,它执行时页面还没画出第一帧,TBT 根本看不见它(TBT 只统计首帧之后的阻塞);defer 之后首帧提前画出来了,长任务落到首帧后面执行,TBT 当场抓个正着。

阻塞用户的事件从头到尾就那么多,defer 没有消灭它,只是把它从「看不见的窗口」挪进了「看得见的窗口」。这步的教训:优化前先想清楚指标在度量哪一段,别只盯一个指标------LCP 好看了 TBT 爆了,体验是继续烂着的。

第 4 次:切片,唯一一次改代码,TBT 990ms → 0

长任务的处理思路一句话:任何超过 50ms 的任务都该拆开。

原始版本是同步跑完 6000 万次循环(模拟真实项目里「预计算 + 建索引」的重活):

javascript 复制代码
function initCarousel() {
  var acc = 0;
  // 大循环跑到底,主线程卡死 ~1.5 秒
  for (var i = 0; i < 6e7; i++) { acc = (acc * 31 + i) % 1000003; }
  window.__initSum = acc;
}

切片版拆成 60 片,每片 100 万次,片间用 setTimeout 让出主线程:

javascript 复制代码
function initCarousel() {
  var acc = 0, i = 0, N = 6e7, STEP = 1e6;
  (function chunk() {
    var end = Math.min(i + STEP, N);
    for (; i < end; i++) { acc = (acc * 31 + i) % 1000003; }
    if (i < N) { setTimeout(chunk, 0); }
    else { window.__initSum = acc; }
  })();
}

每片不到 50ms,输入响应和渲染帧都能在片间插进来。实测:TBT 990ms → 0,LCP 顺带 2.6s → 2.2s(首帧渲染不再被挡),性能分 75 → 99。

要诚实地说:这 0.4 秒是顺带的,这步真正救的是交互。总工作量一点没少,6000 万次还是 6000 万次,只是不再一次全塞给主线程。setTimeout 是最朴素的做法,任务更重还有 scheduler.yield 和 Web Worker,但「先拆开」比选哪个 API 重要。

第 5 次:别给手机喂 1920 的图,2.2s → 1.5s

最后回头看图片本身:手机视口 412px 宽,2 倍屏也只需要 824px 的图,我却给所有设备发 1920 宽原图。

bash 复制代码
cwebp -resize 840 560 -q 90 hero.jpg -o hero-sm.webp

141KB 变 25KB,加载时长 1.5s → 0.83s,LCP 收在 1.5s,性能分 100。

工程里这步通常是响应式图片(srcset/sizes)或图片服务按宽度出图,原理相同:渲染尺寸决定需要的像素,别多给。第 1 次省的是「格式的浪费」,这次省的是「尺寸的浪费」,两者经常被混为一谈------压了格式没压尺寸,头图照样虚胖。

复盘:3.6 秒里,代码只值 0.4 秒

把 5 次改动按杠杆归类:

杠杆 对应改动 贡献
资源体积 WebP、按视口出图 -1.4s、-0.7s
资源发现 preload + fetchpriority -1.1s
主线程 defer(暴露问题)、切片(解决问题) TBT 990ms → 0

4 次配置级的改动拿走 3.2 秒,唯一那次改代码贡献 0.4 秒 LCP 加一个干净的 TBT。不是说代码不重要------没有切片,页面就是个「图片秒开但点不动」的页面,TBT 990ms 是真实存在的卡顿。它想说的是:动手改代码之前,先把配置层和资源层的便宜捡完。

操作顺序三步:跑一次 Lighthouse 移动端测试,看 LCP 分解四段里哪段最长;发现延迟长 → preload,加载时长长 → 压图出小图,渲染延迟长 → 查长任务;配置层治完还剩长任务,再动代码------切片,而不是删功能。

判断「该动哪一刀」永远比「能动多少刀」值钱。你的页面最近一次跑 Lighthouse 是什么时候?评论区报个数,我猜不少人从没看过自己页面的 LCP 分解------那里面的每一项,都对应一行能落地的改动。


相关推荐
IT_陈寒1 小时前
Vite的静态资源引用把我坑惨了
前端·人工智能·后端
TF男孩1 小时前
IT风云录 02 | 中年程序员,看不惯大公司,去小公司了
程序员
风骏时光牛马1 小时前
AI工作流全链路自动化落地实践
前端
编程老船长1 小时前
模型中立——把大模型做成"可替换零件",而不是焊死在业务里
java·前端·后端
禁止摆烂_才浅1 小时前
Axios 完整封装合集(鉴权 + 重复拦截 + Loading + 缓存 + 统一错误 + 请求重试|全代码逐行注释)
前端·javascript·axios
陈随易1 小时前
傻瓜式UX:ERP的致命糖衣
前端·后端·程序员
不可能片场1 小时前
Windows 中文传参坑 命令行乱码变量来救场
前端·electron
CopyCode1 小时前
ref、reactive、toRefs 到底该用哪个?我把三个反例都写了一遍
前端·vue.js
cidy_981 小时前
第 1 章:项目初始化与技术选型
前端