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

0 阅读6分钟

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

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

先看总账:

步骤改动LCPTBT性能分
原始页5.1s0ms77
第 1 次JPG 换 WebP3.7s0ms86
第 2 次加 preload2.6s0ms93
第 3 次script 加 defer2.6s990ms75 ↓
第 4 次长任务切片(唯一改代码)2.2s0ms99
第 5 次按视口出图1.5s0ms100

注意第 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

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

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,然后才开始下载这张最重要的图。

所以让头图插队:

<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:

<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 万次循环(模拟真实项目里「预计算 + 建索引」的重活):

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 让出主线程:

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 宽原图。

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 分解——那里面的每一项,都对应一行能落地的改动。