一、技术难点
Day25 讲的是"资源都到齐之后,一帧怎么画出来"。这一篇讲它前面那一段:从用户敲下回车,到屏幕上出现第一个有意义的像素,这段时间都花在哪儿了。
先给结论:加载性能不是"资源总量"的问题,是"排队"的问题。 一次页面加载里,绝大部分浪费掉的毫秒并不是在传输数据,而是在等 ------等上一次往返回来、等上一个脚本执行完、等浏览器知道"原来还有这个东西要下"。实测(waterfall-sim.mjs)里,深链场景的链路利用率只有 76%,也就是说带宽有四分之一的时间在空转,而用户等了 827ms。
本文所有数字都来自三份零依赖 Node 脚本的实跑输出,并且每份脚本都带不变量断言,断言失败就 exit 1。模型是确定性的简化模型(参数与边界在文末「运行环境」里写清楚了),它的价值在于把不同策略放进同一个坐标系里比较,而不是预测某台机器上的绝对毫秒数。
注意边界:本篇只讲时序------资源什么时候被发现、按什么优先级排队、LCP 的 1510ms 分别归谁。协议与缓存(HTTP/2、HTTP/3、Cache-Control、CDN 命中率)留给 Day27,拆包留给 Day30,图片格式与字体加载策略留给 Day31,SSR/SSG/ISR 留给 Day28。四篇加起来才是完整的"加载优化",混着讲的结果通常是四件事都说不透。
难点一:延迟和带宽是两笔账,而人们总在还带宽那笔
一次资源请求的耗时可以粗分成两段:
单次请求耗时 = 等待段(DNS + TCP + TLS + 服务端处理 + 排在前面的往返) + 传输段(字节数 / 带宽)
这两段的性质完全不同。传输段可以并行摊薄 :把 1MB 拆成 10 个 100KB 的资源同时下,总时间接近 1/10(受并发数和带宽上限约束)。等待段不能摊薄,只能叠加------每一个"必须先拿到 A 才知道要下 B"的依赖,就是一次完整的往返,而往返时间由物理距离决定,你写什么代码都改不了。
于是有了第一个反直觉结论:10MB 并行下载,可能比 500KB 三层串行更快。 实测对照(同一份 943KB 的页面,只改发现时序,不改一个字节):
| 策略 | 依赖链深 | LCP 请求发出 | LCP 完成 | 全站完成 | 总字节 | 链路利用率 |
|---|---|---|---|---|---|---|
| A 深链 + 无提示 | 4 | 601ms | 827ms | 827ms | 943KB | 76% |
| B 扁平 + 精准提示 | 2 | 60ms | 574ms | 689ms | 943KB | 91% |
| C 扁平 + 滥用 preload | 2 | 60ms | 1329ms | 1329ms | 1903KB | 95% |
A 和 B 的字节数一模一样(943KB),B 只是把"发现的顺序"重排了一下,LCP 从 827ms 降到 574ms(-31%),而 A 的链路有 24% 的时间是空的------白等掉的。
C 更值得看:它同样"扁平",同样"提前发现",但 LCP 反面涨到 1329ms,是 B 的 2.3 倍 ,还多下了 960KB 用户根本看不见的图。"提前"和"插队"是两件事,这是本篇后面反复要用的区分。
难点二:浏览器只在三个时刻发现资源,其中两个是"顺带"
资源不是"存在就会被下载",是"被发现才会被下载"。浏览器发现资源只有三个时刻:
- HTML 流式扫描期(预扫描器) :HTML 字节流一边到达,预扫描器一边往前扫,把静态写死的
<script src>、<link rel=stylesheet>、<img src/srcset>、<source>全部挖出来发请求。这个阶段不需要等 HTML 解析完,也不需要等任何 JS。 - 资源解析期 :外部 CSS 解析出 CSSOM 时,才能看到里面的
@import和@font-face;一个模块下载并解析完,才能看到它import了什么。 - 代码执行期 :
fetch('/api/feed')拿到数据、拼出图片 URL、setState渲染出<img src>------这才是最常见的真实情况,也是一切灾难的源头。
关键在于:只有第 1 个时刻是"免费"的 (不用等任何东西),第 2、3 个时刻都必须等上一层资源下载完 + 解析/执行完。所以"把资源写进 HTML"这件事,价值远大于它看起来的样子。
实测(discovery-cost.mjs,同一张 280KB 的 LCP 主图):
| 发现方式 | 请求发出时刻 | LCP 首字节 | 比最优晚 |
|---|---|---|---|
| ① 全靠 JS 运行时发现(无提示) | 799ms | 839ms | +739ms |
② 只加 modulepreload(拆包仍是瀑布) |
271ms | 311ms | +211ms |
③ modulepreload + preload 主图 |
60ms | 100ms | +0ms |
| ④ 主图直接写死在 HTML 里 | 60ms | 100ms | +0ms |
①和③之间差的 739ms,没有一毫秒是花在传输上的,全是在等:等主包下载、等主包解析执行、等拿数据、等下一个 chunk、等渲染。739ms 足够把任何一个"性能达标"的页面打成不及格。
注意③和④的结果完全一样。这不是脚本的巧合,是事实:对于本来就写在 HTML 里的 <img>,preload 在"发现"这一维度上的收益是 0 (预扫描器早就发现它了)。preload 真正的价值在另外两处:让发现不了 的东西被发现(CSS 里的字体、JS 拼出来的 URL),以及抢优先级 (这是难点三)。给已经静态可发现的图片加 preload,多数时候只是多写一行代码安慰自己。
还有一条藏得最深的链:每加一层 import,成本正好是 168ms (实测:链深 1→5,主图请求时刻 249 → 417 → 585 → 753 → 921ms,每层恒定 +168ms)。这 168ms 拆开是:RTT 40ms + 120KB 模块的解析执行 48ms + 该模块的传输 80ms。也就是说,一个 import 层级 = 一次完整往返 + 一次主线程解析 + 一次传输,三者串成了硬等待。
难点三:优先级不是"优化建议",是调度器的输入
很多人以为 fetchpriority 是"告诉浏览器我希望快一点"的礼貌请求。不是。浏览器网络栈里有一个全局的优先级队列,每个资源进队时带着一个优先级,出队时机、带宽份额、甚至是否复用已打开的连接,都由它决定。你的提示直接改的是这个队列的排序。
优先级从哪儿来:
- 资源类型:HTML 最高(Highest),阻塞渲染的 CSS 也最高,同步脚本高,图片默认低。
- 是否在视口内 :视口内的图片被抬到高,视口外的图片掉到最低。这也是
loading="lazy"的机制------它同时降低优先级 并把发现推迟到布局接近视口时。 - 显式提示 :
fetchpriority="high|low|auto"(<img>/<link>/<script>都支持)、<link rel=preload>的as决定进哪个队列。
于是就有了加载优化里最经典的自杀式写法:给首屏那把大图加 loading="lazy"。它同时做了两件坏事------把 LCP 资源降到最低优先级,又把它藏到布局之后才被发现。LCP 图片是全页面唯一应该被"抢"的资源,绝不能懒加载。
优先级也是把双刃剑,滥用它的代价在实测里非常直白(waterfall-sim.mjs 场景 C):8 张首屏缩略图被 preload 提前、且和主图同权重(各 2),于是主图只能拿到 2 / (2×9 + 4 + 0.5) ≈ 10% 的带宽,280KB 传成了 1315ms 的 LCP。把 9 个资源都标成"关键",等于一个都没标。
难点四:带宽是守恒的,扁平化买到的是"顺序"而不是"总量"
场景 B 里有一个必须诚实说出来的代价:扁平化让 LCP 从 827ms 降到 574ms,但主包执行完的时刻从 399ms 推迟到了 686ms。
原因简单到不需要解释:带宽是守恒的。A 场景里主包在 L1 独占链路(只有它和 CSS 两个大件),所以 399ms 就跑完了;B 场景里字体、主包、vendor、主图、次要 CSS 全在 L1 同时抢,主图因为 fetchpriority=highest 抢到了大头,主包就只能往后排。
这不是"扁平化不好",而是你必须显式决定谁先谁后:扁平化把"发现顺序"这个隐式排序权交回给了你,你不排,浏览器就按类型默认规则排,而那个规则的默认值不一定符合你的业务目标。首屏是"先看到主图"还是"先能点击"?这是产品问题,不是技术问题,技术只负责让你能选。
顺带一个真实的默认规则陷阱:A 场景里 LCP 资源和主包都是 high,所以它们平分带宽,谁都没占到便宜;而 A 的 LCP 之所以慢,根本不是因为带宽不够(链路 76% 利用率),是因为它 601ms 才被发现。
难点五:LCP 是一个四段和式,不是一个数字
"我的 LCP 是 1510ms"这句话没有任何可执行信息。把它拆成四段,它立刻变成四个人的工单:
ini
LCP = TTFB + Resource Load Delay + Resource Load Duration + Element Render Delay
- TTFB:首字节时间。归后端 / CDN / 边缘渲染。
- Resource Load Delay :从 TTFB 到 LCP 资源开始下载 。归前端 HTML 结构与资源提示------这是"发现延迟",也是前端唯一能独立改善的一段。
- Resource Load Duration:从开始下载到下载完。归资源体积、格式、带宽与优先级。
- Element Render Delay:从下载完到真的画出这一帧。归主线程 JS、关键 CSS、字体。
四段相加恒等于 LCP,这个恒等式是所有归因的基础(脚本 lcp-phases.mjs 的第一条断言就是它)。实测两个真实形态:
| 段 | 图片 LCP(hero.jpg 被 JS 发现) | 文本 LCP(h1 直出) |
|---|---|---|
| TTFB | 320ms (21.2%) | 320ms (58.7%) |
| Resource Load Delay | 600ms (39.7%) | 0ms |
| Resource Load Duration | 380ms (25.2%) | 0ms |
| Element Render Delay | 210ms (13.9%) | 225ms (41.3%) |
| 合计 | 1510ms | 545ms |
同一个"慢"字,两个完全不同的责任人。 图片 LCP 是前端的锅(发现延迟占近 40%,后端只占 21%);文本 LCP 是后端的锅(TTFB 占近 59%,前端唯一能做的是把那 225ms 的渲染阻塞砍掉)。不看这个分解就去"优化 LCP",一半的概率会优化错的那一段,然后把责任推给另一边。
这里还有一个非常容易踩的坑:跨域图片如果没有配 Timing-Allow-Origin,lcpEntry.renderTime 会是 0 ,于是你只能退回 startTime------而 startTime 是"资源加载完成时刻"。结果是你的分解里"渲染延迟"看起来是 0,LCP 被系统性低估,你还以为渲染很健康。
难点六:preload 的真实语义是"提前发现 + 插队",插错队就是负优化
把前面几点合起来,preload 的语义就清楚了:
- 它让预扫描器看不到的资源提前被发现(这是纯收益);
- 它把资源的优先级抬到它
as对应的队列(这可能是收益,也可能是灾难); - 它会让浏览器在 3 秒内没用上就打一条 unused preload 警告------这是浏览器在替你标记"插错队了"。
三条常见的插错队方式:给一堆图都加 preload(场景 C,LCP 变成 1329ms);as 写错(as 与实际请求上下文不匹配会导致重复下载与错误优先级);字体 preload 忘了 crossorigin(字体请求恒为 CORS 模式,属性不一致就是两份字体 )。这三条的共同点是:它们都不会报错,只会让页面变慢。
难点七:@import、字体、动态 import 是三条藏起来的串行链
三条最容易被忽略、也最容易一次改掉一大截的串行链:
链一:@import。 @import url(base.css) 写在 CSS 里,必须等父 CSS 下载 + 解析 后才发现,直接多出一整层。而且它连"预扫描器"都救不了------那玩意在 HTML 里扫的是标签,扫不到外部 CSS 里的 @import。
链二:字体。 同一个 96KB 的字体文件,三种发现方式实测(discovery-cost.mjs):
| 发现方式 | 请求发出 | 额外等待 |
|---|---|---|
preload 写进 HTML |
60ms | +0ms |
@font-face 在外部 CSS 里 |
156ms | +96ms |
FontFace API 在 JS 里注入 |
377ms | +317ms |
317ms 的差别,就是"字体写在 CSS 里"和"字体在 JS 里动态 new 出来"的区别。而且注意:字体加载完成之后还有一层 font-display 决策(阻塞窗口多长、要不要先上回退字体),那部分策略属于 Day31。
链三:动态 import()。 路由级懒加载本身是对的,但"入口 chunk → 页面 chunk → 组件 chunk → 数据 → 图片"这样叠到 5 层,主图请求时刻实测从 249ms 一路涨到 921ms。modulepreload 把这条链压平到一层后,无论链深多少,请求时刻都是恒定的 60ms------这是加载优化里投入产出比最高的一行代码,前提是你得知道该 preload 哪几个 chunk(就是那条链上的所有节点,不是全部 chunk)。
小结:失效范围的视角
Day25 讲渲染时,整篇文章的核心问题是一句话:"这次改动的最小失效范围是什么,我实际触发了多大。"
加载性能的核心问题也是一句话,只是把"失效范围"换成了"发现层次":"这个资源最早能在哪一层被发现,我现在让它等到第几层?" 每一层的成本是固定的(RTT + 解析执行 + 传输,实测 168ms),所以加载优化在很大程度上不是玄学调优,而是一次图论作业:把依赖图变浅,把该先走的放进高优先级队列,把不该下的一开始就别下。
二、完整解法
三件工具 + 一条纪律。三件工具分别回答三个不同的问题,刻意不重叠:
- waterfall-sim.mjs:算"带宽怎么分、谁先完成"(离散事件模拟,带优先级权重与并发上限)
- discovery-cost.mjs:算"资源最早什么时候发得出请求"(纯发现延迟,不含带宽竞争)
- lcp-phases.mjs:算"线上这 1510ms 分别归谁、先修哪一段"(四段分解 + 收益排序)
2.1 工具一:把页面写成有向图,先算链深
这个模拟器把加载写成一张有向图:节点是资源,边是"发现依赖"。每个节点带三样东西:体积、优先级、以及它是怎么被发现的 (stream = 跟着父资源的首字节发现,也就是 HTML 里的静态标签 / preload;done = 必须等父资源下载完 才发现,也就是 CSS 里的 @import/@font-face、JS 里的 import/setState)。
事件循环按"优先级权重比例分配带宽"推进,每个请求先付一个 RTT 才收到首字节(等待期不占带宽但占并发流),直到所有节点完成。三个场景对照的是同一份 943KB 页面,只改发现方式与优先级。
瀑布流模拟器 waterfall-sim.mjs(完整可运行,零依赖):
js
#!/usr/bin/env node
/**
* waterfall-sim.mjs ------ 加载瀑布流离散事件模拟器(零依赖)
*
* 模型(确定性简化模型,用于策略对比,不做绝对毫秒预测):
* - 单条链路带宽 bw (KB/ms),所有在传资源按「优先级权重」比例分配带宽
* - 每个请求发出后先付一个 RTT 才收到第一个字节(等待期不占带宽,但占一个并发流)
* - 资源的「发现时刻」由依赖决定:
* when: 'stream' 跟随父资源的首字节(HTML 流里静态写死的标签 / preload)
* when: 'done' 必须等父资源下载完(CSS 里的 @import/@font-face、JS 里的 import/setState)
* - 并发流上限 maxStreams,超出则按优先级排队
*
* 运行: node waterfall-sim.mjs
* 断言失败即 exit 1。
*/
const P = { highest: 4, high: 2, medium: 1, low: 0.5 };
function simulate(scn) {
const { nodes, bw = 1.5, rtt = 40, tls = 60, maxStreams = 100 } = scn;
const byId = new Map(nodes.map((n) => [n.id, n]));
const children = new Map();
for (const n of nodes) {
if (n.parent) {
if (!children.has(n.parent)) children.set(n.parent, []);
children.get(n.parent).push(n);
}
}
const html = nodes.find((n) => !n.parent);
if (!html) throw new Error('缺少根节点 html');
const done = new Map();
const dataStart = new Map();
const ready = [];
let active = [];
let now = 0;
let seq = 0;
const log = [];
// HTML 自身:根请求,rtt 位置放 TLS 握手成本
ready.push({ n: html, readyTime: 0, s: seq++ });
const admit = () => {
for (;;) {
const cands = ready.filter((r) => r.readyTime <= now);
if (!cands.length) break;
if (active.length >= maxStreams) break;
cands.sort((a, b) => P[b.n.w] - P[a.n.w] || a.s - b.s);
const c = cands[0];
ready.splice(ready.indexOf(c), 1);
const r = c.n.root ? tls : rtt;
const ds = now + r;
dataStart.set(c.n.id, ds);
active.push({ n: c.n, dataStart: ds, remaining: c.n.size });
log.push({ id: c.n.id, req: now, dataStart: ds });
// HTML 首字节到手即可继续流式扫描:静态标签 / preload 的发现时刻 = 父资源首字节
for (const ch of children.get(c.n.id) || []) {
if (ch.when === 'stream') ready.push({ n: ch, readyTime: ds, s: seq++ });
}
}
};
let guard = 0;
while (done.size < nodes.length) {
if (++guard > 10000) throw new Error('死循环:依赖图里有环');
admit();
const transferring = active.filter((a) => a.dataStart <= now && a.remaining > 1e-9);
const W = transferring.reduce((s, a) => s + P[a.n.w], 0);
let nextStart = Infinity;
for (const a of active) if (a.dataStart > now) nextStart = Math.min(nextStart, a.dataStart);
let nextFinish = Infinity;
if (W > 0) {
for (const a of transferring) {
const rate = (P[a.n.w] / W) * bw;
nextFinish = Math.min(nextFinish, now + a.remaining / rate);
}
}
const t = Math.min(nextStart, nextFinish);
if (!isFinite(t)) throw new Error('事件队列为空:有节点永远无法就绪');
const dt = t - now;
if (W > 0 && dt > 0) {
for (const a of transferring) a.remaining = Math.max(0, a.remaining - (P[a.n.w] / W) * bw * dt);
}
now = t;
let progressed = true;
while (progressed) {
progressed = false;
for (const a of active.slice()) {
if (a.dataStart <= now && a.remaining <= 1e-6) {
done.set(a.n.id, now);
active = active.filter((x) => x !== a);
progressed = true;
for (const c of children.get(a.n.id) || []) {
if (c.when !== 'stream') ready.push({ n: c, readyTime: now, s: seq++ });
}
}
}
if (progressed) admit();
}
}
// stream 发现的节点:随 HTML 首字节就绪
for (const n of nodes) {
if (n.when === 'stream') {
const ds = dataStart.get(n.id);
if (ds < dataStart.get(html.id)) throw new Error(`${n.id} 在 HTML 首字节前就开始请求了`);
}
}
const totalBytes = nodes.reduce((s, n) => s + n.size, 0);
const makespan = Math.max(...done.values());
return {
done,
dataStart,
totalBytes,
makespan,
utilization: totalBytes / (makespan * bw),
bytesAt: (t) => nodes.filter((n) => done.get(n.id) <= t).reduce((s, n) => s + n.size, 0),
finishedBy: (t) => nodes.filter((n) => done.get(n.id) <= t).length,
depth: maxDepth(nodes, byId),
};
}
function maxDepth(nodes, byId) {
const memo = new Map();
const d = (n) => {
if (memo.has(n.id)) return memo.get(n.id);
const v = n.parent ? d(byId.get(n.parent)) + 1 : 0;
memo.set(n.id, v);
return v;
};
return Math.max(...nodes.map(d));
}
const KB = (n) => n;
// ── 场景 A:深链,无任何提示(图片靠 JS 运行时发现)────────────────
const baseline = {
nodes: [
{ id: 'html', size: KB(32), w: 'highest', parent: null, root: true },
{ id: 'critical.css', size: KB(48), w: 'highest', parent: 'html', when: 'stream' },
{ id: 'app.js', size: KB(240), w: 'high', parent: 'html', when: 'stream' },
{ id: 'base.css', size: KB(64), w: 'highest', parent: 'critical.css', when: 'done' },
{ id: 'font.woff2', size: KB(96), w: 'high', parent: 'critical.css', when: 'done' },
{ id: 'vendor.js', size: KB(180), w: 'high', parent: 'app.js', when: 'done' },
{ id: 'feed.json', size: KB(3), w: 'high', parent: 'vendor.js', when: 'done' },
{ id: 'hero.jpg', size: KB(280), w: 'high', parent: 'feed.json', when: 'done' },
],
};
// ── 场景 B:扁平化 + 精准提示(关键 CSS 内联、字体/主依赖/主图 preload)──
const flattened = {
nodes: [
{ id: 'html', size: KB(80), w: 'highest', parent: null, root: true }, // 32KB HTML + 48KB 内联关键 CSS
{ id: 'font.woff2', size: KB(96), w: 'high', parent: 'html', when: 'stream' },
{ id: 'vendor.js', size: KB(180), w: 'high', parent: 'html', when: 'stream' },
{ id: 'app.js', size: KB(240), w: 'high', parent: 'html', when: 'stream' },
{ id: 'hero.jpg', size: KB(280), w: 'highest', parent: 'html', when: 'stream' },
{ id: 'base.css', size: KB(64), w: 'low', parent: 'html', when: 'stream' },
{ id: 'feed.json', size: KB(3), w: 'high', parent: 'vendor.js', when: 'done' },
],
};
// ── 场景 C:扁平化但滥用 preload(首屏 8 张图全部提前、且都当关键资源)──
const overPreload = {
nodes: [
{ id: 'html', size: KB(80), w: 'highest', parent: null, root: true }, // 32KB HTML + 48KB 内联关键 CSS
{ id: 'font.woff2', size: KB(96), w: 'high', parent: 'html', when: 'stream' },
{ id: 'vendor.js', size: KB(180), w: 'high', parent: 'html', when: 'stream' },
{ id: 'app.js', size: KB(240), w: 'high', parent: 'html', when: 'stream' },
{ id: 'hero.jpg', size: KB(280), w: 'high', parent: 'html', when: 'stream' },
...Array.from({ length: 8 }, (_, i) => ({
id: `thumb${i + 1}.jpg`,
size: KB(120),
w: 'high',
parent: 'html',
when: 'stream',
})),
{ id: 'base.css', size: KB(64), w: 'low', parent: 'html', when: 'stream' },
{ id: 'feed.json', size: KB(3), w: 'high', parent: 'vendor.js', when: 'done' },
],
};
const scenarios = [
['A 深链+无提示', baseline],
['B 扁平+精准提示', flattened],
['C 扁平+滥用 preload', overPreload],
];
const results = new Map();
console.log('=== 加载瀑布流模拟(bw=1.5KB/ms,RTT=40ms,H2 单连接并发 100)===');
console.log('场景 链深 LCP请求发出 LCP首字节 LCP完成 主包执行完 全站完成 总字节 带宽利用率 300ms完成 LCP时已下字节');
for (const [name, scn] of scenarios) {
const r = simulate(scn);
results.set(name, r);
const lcpReq = r.done.has('hero.jpg') ? r.dataStart.get('hero.jpg') - 40 : NaN;
console.log(
[
name.padEnd(20),
String(r.depth).padStart(4),
`${lcpReq.toFixed(0)}ms`.padStart(13),
`${r.dataStart.get('hero.jpg').toFixed(0)}ms`.padStart(12),
`${r.done.get('hero.jpg').toFixed(0)}ms`.padStart(10),
`${r.done.get('app.js').toFixed(0)}ms`.padStart(12),
`${r.makespan.toFixed(0)}ms`.padStart(9),
`${r.totalBytes}KB`.padStart(8),
`${(r.utilization * 100).toFixed(0)}%`.padStart(10),
`${r.finishedBy(300)}/${scn.nodes.length}`.padStart(13),
`${((r.bytesAt(r.done.get('hero.jpg')) / r.totalBytes) * 100).toFixed(0)}%`.padStart(13),
].join('')
);
}
// 各资源完成时刻明细(场景 A / B)
for (const name of ['A 深链+无提示', 'B 扁平+精准提示']) {
const r = results.get(name);
const scn = scenarios.find((s) => s[0] === name)[1];
console.log(`\n--- ${name} 明细 ---`);
for (const n of scn.nodes) {
const ready = r.dataStart.get(n.id);
console.log(
` ${n.id.padEnd(13)} ${String(n.size).padStart(4)}KB w=${String(n.w).padEnd(7)} 首字节 ${ready
.toFixed(0)
.padStart(4)}ms 完成 ${r.done.get(n.id).toFixed(0).padStart(4)}ms`
);
}
}
// ── 断言:模型不变量 ───────────────────────────────────────────
const A = results.get('A 深链+无提示');
const B = results.get('B 扁平+精准提示');
const C = results.get('C 扁平+滥用 preload');
const checks = [];
const ck = (name, cond) => checks.push([name, !!cond]);
ck('依赖图深度 A > B(深链确实更深)', A.depth > B.depth);
ck('B 的 LCP 完成早于 A', B.done.get('hero.jpg') < A.done.get('hero.jpg'));
ck('C 的 LCP 完成晚于 B(滥用 preload 挤兑带宽)', C.done.get('hero.jpg') > B.done.get('hero.jpg'));
ck('B 与 A 总字节相同(扁平化只改时序,不改体积)', B.totalBytes === A.totalBytes);
ck('C 总字节比 B 多 960KB(8 张图被提前下载)', C.totalBytes === B.totalBytes + 8 * 120);
ck('所有资源都完成', scenarios.every(([n, s2]) => results.get(n).done.size === s2.nodes.length));
ck('首字节不早于请求发出', [...A.dataStart.entries()].every(([id, ds]) => ds >= 0));
ck('带宽利用率不超过 100%', [A, B, C].every((r) => r.utilization <= 1 + 1e-9));
ck('C 中 LCP 图片被 8 张同权重图分走带宽,其完成时间 > 单独传输时间', C.done.get('hero.jpg') > 280 / 1.5);
let bad = 0;
for (const [n, ok] of checks) {
if (!ok) bad++;
console.log(`${ok ? 'PASS' : 'FAIL'} ${n}`);
}
if (bad) {
console.error(`\n${bad} 条断言失败`);
process.exit(1);
}
console.log(`\n全部 ${checks.length} 条断言 PASS`);
输出怎么读(跑一遍就有,实测结果):
text
场景 链深 LCP请求发出 LCP首字节 LCP完成 主包执行完 全站完成 总字节 带宽利用率 300ms完成 LCP时已下字节
A 深链+无提示 4 601ms 641ms 827ms 399ms 827ms 943KB 76% 3/8 100%
B 扁平+精准提示 2 60ms 100ms 574ms 686ms 689ms 943KB 91% 1/7 48%
C 扁平+滥用 preload 2 60ms 100ms 1329ms 1299ms 1329ms 1903KB 95% 1/15 100%
四个必须看懂的点:
LCP 请求发出61ms→601ms 这一列,是加载优化真正的主战场。 它和"下载"无关,纯粹由依赖链深决定。带宽利用率76% 那一格,说明"带宽不够"是假象。 链路闲着四分之一的时长,用户却等了快一秒。- A 在 300ms 时已经完成 3/8 个资源 (HTML、关键 CSS、base.css)------这是深链的唯一好处:少量资源能更早独占链路完成 ,首屏骨架可以更早出来。B 在 300ms 只完成 1 个(HTML,因为它内联了 48KB 关键 CSS 所以变 80KB)------扁平化用"大家都早开始"换掉了"少数人早完成"。这个取舍没有绝对答案,取决于你要的是"更快看到骨架"还是"更早看到成品"。
主包执行完那一列在 B 里反而更晚(399→686ms),这就是难点四说的带宽守恒。
参数都可以直接改:bw(带宽)、rtt、tls、maxStreams(并发上限,模拟 HTTP/1.1 的每源 6 连接只改这一个值),以及每个节点的 w(优先级)和 when(发现方式)。断言部分保证模型不会自欺:依赖图深度、LCP 单调性、总字节守恒、利用率不超过 100%、事件队列不死锁。
这里有个刻意的设计 :场景 A 和 B 的总字节被断言为严格相等(943KB)。这是为了让"扁平化不改体积、只改时序"这句话在代码层面成立------如果哪天有人往 B 里塞了新资源,断言会立刻失败。
2.2 工具二:算"资源最早什么时候发得出请求"
模拟器回答"多久下完",但它把发现延迟和带宽竞争混在一起了。要单独看发现延迟,需要一个不含带宽的纯解析计算器------因为发现延迟的本质是串行等待,而带宽是可以并行摊薄的,两者混在一张表里就说不清是谁的锅。
这个计算器只做一件事:沿依赖链走一遍,算每个资源的请求发出时刻。
发现时刻计算器 discovery-cost.mjs(完整可运行,零依赖):
js
#!/usr/bin/env node
/**
* discovery-cost.mjs ------ 资源"被发现的时刻"计算器(零依赖)
*
* 只算一件事:一个资源的请求**什么时候才发得出去**。
* 传输时间受带宽竞争影响,那是 waterfall-sim.mjs 的活;这里刻意把两者分开,
* 因为发现延迟是**串行等待**(每一层都是一次完整往返 + 一次解析执行),
* 而带宽时间是可并行摊薄的。
*
* 每层发现代价 = RTT(等父资源下载完) + 父资源解析/执行时间 + RTT(新请求的首字节)
*
* 运行: node discovery-cost.mjs
*/
const RTT = 40; // ms
const TLS = 60; // ms,DNS+TCP+TLS,H2 单连接只付一次
const BW = 1.5; // KB/ms
const PARSE_PER_KB = { css: 0.05, js: 0.4, html: 0.05 }; // ms/KB,主线程解析(+执行)单价
const tx = (kb) => kb / BW; // 传输时间
const parse = (kb, kind) => kb * PARSE_PER_KB[kind];
/**
* 计算一条发现链上某资源的请求发出时刻。
* chain: [{ id, size, kind }],chain[0] 是 HTML。
* opts.preloadedFromHtml: 从 HTML 静态/preload 直接发现的资源 id 集合(绕过链深)
* opts.anchor: 目标资源 id
*/
function discoverTime(chain, opts = {}) {
const { preloadedFromHtml = new Set(), anchor } = opts;
const firstByte = new Map(); // id -> 首字节时刻
const finish = new Map();
const reqAt = new Map();
const detail = [];
for (let i = 0; i < chain.length; i++) {
const n = chain[i];
let req;
if (i === 0) {
req = 0;
} else if (preloadedFromHtml.has(n.id)) {
req = firstByte.get('html'); // HTML 首字节即可发现
} else {
const prev = chain[i - 1];
// 必须等上一个资源下载完 + 解析/执行完(HTML 解析期间由预扫描器提前发现子资源)
const prevCost = prev.kind === 'html' ? 0 : parse(prev.size, prev.kind);
req = finish.get(prev.id) + prevCost;
}
const fb = req + (i === 0 ? TLS : RTT);
const fin = fb + tx(n.size);
reqAt.set(n.id, req);
firstByte.set(n.id, fb);
finish.set(n.id, fin);
detail.push({ id: n.id, req, firstByte: fb, finish: fin });
}
return { reqAt, firstByte, finish, detail, anchorReq: reqAt.get(anchor), anchorFirstByte: firstByte.get(anchor) };
}
// ── 场景 1:同一张 LCP 图,三种发现方式 ───────────────────────────
const uiChain = [
{ id: 'html', size: 32, kind: 'html' },
{ id: 'app.js', size: 240, kind: 'js' },
{ id: 'page.js', size: 160, kind: 'js' },
{ id: 'chart.js', size: 120, kind: 'js' },
{ id: 'feed.json', size: 3, kind: 'js' },
{ id: 'hero.jpg', size: 280, kind: 'js' },
];
const strategies = [
['① 全靠 JS 运行时发现(无提示)', { preloadedFromHtml: new Set() }],
['② 只加 modulepreload(拆包仍是瀑布)', { preloadedFromHtml: new Set(['page.js', 'chart.js']) }],
['③ modulepreload + preload 主图', { preloadedFromHtml: new Set(['page.js', 'chart.js', 'hero.jpg']) }],
['④ 主图写死在 HTML(静态可发现)', { preloadedFromHtml: new Set(['page.js', 'chart.js', 'hero.jpg']) }],
];
console.log('=== 场景 1:LCP 图片的请求发出时刻(RTT=40ms, TLS=60ms, 1.5KB/ms)===');
console.log('策略 请求发出 首字节 比最优晚');
const t1 = [];
for (const [name, opt] of strategies) {
const r = discoverTime(uiChain, { ...opt, anchor: 'hero.jpg' });
t1.push([name, r.anchorReq, r.anchorFirstByte]);
}
const best1 = Math.min(...t1.map((x) => x[2]));
for (const [name, req, fb] of t1) {
console.log(`${name.padEnd(40)} ${`${req.toFixed(0)}ms`.padStart(8)} ${`${fb.toFixed(0)}ms`.padStart(9)} ${`+${(fb - best1).toFixed(0)}ms`.padStart(10)}`);
}
// ── 场景 2:同一个字体,三档发现模式 ─────────────────────────────
console.log('\n=== 场景 2:同一个字体文件的发现代价 ===');
const cssChain = [
{ id: 'html', size: 32, kind: 'html' },
{ id: 'critical.css', size: 48, kind: 'css' },
{ id: 'font.woff2', size: 96, kind: 'css' },
];
const fontModes = [
['preload 在 HTML 里', { chain: cssChain.slice(0, 2).concat([cssChain[2]]), preload: new Set(['font.woff2']) }],
['@font-face 在外部 CSS 里', { chain: cssChain, preload: new Set() }],
[
'FontFace API 在 JS 里注入',
{
chain: [
{ id: 'html', size: 32, kind: 'html' },
{ id: 'app.js', size: 240, kind: 'js' },
{ id: 'font.woff2', size: 96, kind: 'js' },
],
preload: new Set(),
},
],
];
const fontRows = [];
for (const [name, cfg] of fontModes) {
const r = discoverTime(cfg.chain, { preloadedFromHtml: cfg.preload, anchor: 'font.woff2' });
fontRows.push([name, r.anchorReq]);
}
const bestFont = Math.min(...fontRows.map((x) => x[1]));
for (const [name, req] of fontRows) {
console.log(`${name.padEnd(30)} 请求发出 ${`${req.toFixed(0)}ms`.padStart(8)} 额外等待 +${(req - bestFont).toFixed(0)}ms`);
}
// ── 场景 3:import 链深度的收益曲线 ──────────────────────────────
console.log('\n=== 场景 3:import 链深度 vs 主图请求时刻 ===');
const depthRows = [];
for (const depth of [1, 2, 3, 4, 5]) {
const chain = [{ id: 'html', size: 32, kind: 'html' }];
for (let i = 1; i <= depth; i++) chain.push({ id: `chunk${i}.js`, size: 120, kind: 'js' });
chain.push({ id: 'hero.jpg', size: 280, kind: 'js' });
const noHint = discoverTime(chain, { anchor: 'hero.jpg' });
const withHint = discoverTime(chain, {
preloadedFromHtml: new Set(chain.filter((n) => n.kind === 'js' && n.id !== 'html').map((n) => n.id)),
anchor: 'hero.jpg',
});
depthRows.push([depth, noHint.anchorReq, withHint.anchorReq]);
const prev = depthRows.length > 1 ? depthRows[depthRows.length - 2][1] : null;
const marginal = prev === null ? '---' : `+${(noHint.anchorReq - prev).toFixed(0)}ms`;
console.log(
` 链深 ${depth}: 无 modulepreload 请求于 ${noHint.anchorReq.toFixed(0).padStart(4)}ms (${marginal.padStart(7)}) | 有 modulepreload ${withHint.anchorReq
.toFixed(0)
.padStart(4)}ms`
);
}
const layerCost = RTT + parse(120, 'js') + tx(120);
console.log(` 单层发现成本拆解: RTT ${RTT} + 解析执行 ${parse(120, 'js').toFixed(0)} + 传输 ${tx(120).toFixed(0)} = ${layerCost.toFixed(0)}ms`);
// ── 断言 ───────────────────────────────────────────────────────
const checks = [];
const ck = (n, ok) => checks.push([n, !!ok]);
ck('策略①(全靠运行时发现)比策略③(提前发现)晚', t1[0][2] > t1[2][2]);
ck('modulepreload 把 page/chart 压平后请求更早', t1[1][2] < t1[0][2]);
ck('策略③ 与 ④ 首字节相同(都在 HTML 流里被发现)', Math.abs(t1[2][2] - t1[3][2]) < 1e-9);
ck('字体:preload 优于 CSS 内 @font-face', fontRows[0][1] < fontRows[1][1]);
ck('字体:CSS 内 @font-face 优于 JS 注入', fontRows[1][1] < fontRows[2][1]);
ck('链深越大主图越晚(单调不减)', depthRows.every((row, i) => i === 0 || row[1] >= depthRows[i - 1][1]));
ck('modulepreload 后请求时刻与链深无关', new Set(depthRows.map((r) => r[2].toFixed(6))).size === 1);
ck('链深每加一层,成本正好 = RTT + 解析执行 + 传输 = 168ms',
depthRows.every((row, i) => i === 0 || Math.abs(row[1] - depthRows[i - 1][1] - layerCost) < 1e-6));
let bad = 0;
for (const [n, ok] of checks) {
if (!ok) bad++;
console.log(`${ok ? 'PASS' : 'FAIL'} ${n}`);
}
if (bad) {
console.error(`\n${bad} 条断言失败`);
process.exit(1);
}
console.log(`\n全部 ${checks.length} 条断言 PASS`);
text
=== 场景 1:LCP 图片的请求发出时刻(RTT=40ms, TLS=60ms, 1.5KB/ms)===
策略 请求发出 首字节 比最优晚
① 全靠 JS 运行时发现(无提示) 799ms 839ms +739ms
② 只加 modulepreload(拆包仍是瀑布) 271ms 311ms +211ms
③ modulepreload + preload 主图 60ms 100ms +0ms
④ 主图写死在 HTML(静态可发现) 60ms 100ms +0ms
=== 场景 2:同一个字体文件的发现代价 ===
preload 在 HTML 里 请求发出 60ms 额外等待 +0ms
@font-face 在外部 CSS 里 请求发出 156ms 额外等待 +96ms
FontFace API 在 JS 里注入 请求发出 377ms 额外等待 +317ms
=== 场景 3:import 链深度 vs 主图请求时刻 ===
链深 1: 无 modulepreload 请求于 249ms ( ---) | 有 modulepreload 60ms
链深 2: 无 modulepreload 请求于 417ms ( +168ms) | 有 modulepreload 60ms
链深 3: 无 modulepreload 请求于 585ms ( +168ms) | 有 modulepreload 60ms
链深 4: 无 modulepreload 请求于 753ms ( +168ms) | 有 modulepreload 60ms
链深 5: 无 modulepreload 请求于 921ms ( +168ms) | 有 modulepreload 60ms
单层发现成本拆解: RTT 40 + 解析执行 48 + 传输 80 = 168ms
+168ms/层 这个数字值得记住。它意味着一次评审时你可以直接口算:"这个改动会多一层依赖吗?会,那就是 +168ms(移动端 4G 上更接近 +400ms)。" 有了这个单价,"要不要为了代码整洁多套一层动态 import"就不再是审美问题,而是一道有价格的算术题。
脚本里有一条断言专门锁住这个单价(链深每加一层,成本正好 = RTT + 解析执行 + 传输),另一条断言锁住"modulepreload 后请求时刻与链深无关"------后者是"压平依赖链"这个动作的数学定义。
2.3 工具三:把线上那个 LCP 数字拆成四个人的工单
前两个工具是设计期 用的(还没上线,先算清楚)。第三个是线上用的:拿真实的 PerformanceObserver 数据,把 LCP 拆成四段,然后按"单段能省多少"排序。
注意脚本第一部分里 decomposeLCP() 的输入是真实的性能条目字段 (ttfb、resourceStart、resourceEnd、lcpTime),也就是说这段逻辑可以直接抄进你的监控 SDK,不需要改动。
浏览器侧采集这段可以直接抄进监控 SDK,和上面的 decomposeLCP() 用的是同一套字段:
js
// lcp-observer.js ------ 采集 LCP 四段(可直接进监控 SDK)
const phases = { ttfb: 0, loadDelay: 0, loadDuration: 0, renderDelay: 0, lcpTime: 0, resource: '' };
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lcp = entries[entries.length - 1]; // 必须取最后一条:LCP 会随新元素出现而更新
const nav = performance.getEntriesByType('navigation')[0];
const ttfb = nav ? nav.responseStart : 0; // 相对 navigationStart
// renderTime 为 0 = 跨域资源且未配 Timing-Allow-Origin,只能退回 startTime
const lcpTime = lcp.renderTime || lcp.startTime;
let resourceStart = null;
let resourceEnd = null;
if (lcp.url) {
const res = performance.getEntriesByName(lcp.url, 'resource')[0];
if (res) { resourceStart = res.startTime; resourceEnd = res.responseEnd; }
}
phases.ttfb = ttfb;
phases.loadDelay = resourceStart === null ? 0 : Math.max(0, resourceStart - ttfb);
phases.loadDuration = resourceStart === null ? 0 : Math.max(0, resourceEnd - resourceStart);
phases.renderDelay = Math.max(0, lcpTime - (resourceEnd === null ? ttfb : resourceEnd));
phases.lcpTime = lcpTime;
phases.resource = lcp.element ? `${lcp.element.tagName}${lcp.url ? ' ' + lcp.url : ''}` : lcp.url || 'text';
// 跨域图片没配 TAO 时这个分解是不可信的,先修埋点再排查
phases.unreliable = !!lcp.url && lcp.renderTime === 0;
console.table(phases);
}).observe({ type: 'largest-contentful-paint', buffered: true });
三点使用注意:① 必须取 entries 的最后一条 ,LCP 是"到目前为止最大的元素",新元素出现会覆盖旧值,取第一条会系统性低估;② unreliable 为 true 时这份数据不能用来定责 ,请先让图片域回 Timing-Allow-Origin: *;③ 文本 LCP 的 lcp.url 为空,此时两个 load 段本来就该是 0,别把它当成"数据缺失"。
LCP 四段分解 lcp-phases.mjs(完整可运行,零依赖):
js
#!/usr/bin/env node
/**
* lcp-phases.mjs ------ LCP 四段分解 + 优化收益排序(零依赖)
*
* 分解模型(web.dev 标准四段,四段之和恒等于 LCP):
* TTFB 首字节时间(后端 / CDN / 边缘)
* Resource Load Delay 从 TTFB 到 LCP 资源**开始下载**(= 发现延迟,HTML 结构与提示说了算)
* Resource Load Duration 从开始下载到下载完(带宽 / 体积 / 优先级说了算)
* Element Render Delay 从下载完到真的画出这一帧(主线程 JS / CSSOM / 字体说了算)
*
* 运行: node lcp-phases.mjs
*/
const PHASES = ['ttfb', 'loadDelay', 'loadDuration', 'renderDelay'];
const LABEL = {
ttfb: 'TTFB(首字节)',
loadDelay: 'Resource Load Delay(发现延迟)',
loadDuration: 'Resource Load Duration(下载)',
renderDelay: 'Element Render Delay(渲染阻塞)',
};
const OWNER = {
ttfb: '后端 / CDN / SSR',
loadDelay: '前端 HTML 结构与资源提示',
loadDuration: '资源体积 / 格式 / 带宽与优先级',
renderDelay: '主线程 JS / 关键 CSS / 字体',
};
/** 从真实性能条目算出四段。text LCP 没有资源条目,load 两段为 0。 */
function decomposeLCP({ ttfb, resourceStart = null, resourceEnd = null, lcpTime }) {
if (resourceStart === null || resourceEnd === null) {
return { ttfb, loadDelay: 0, loadDuration: 0, renderDelay: lcpTime - ttfb, lcpTime };
}
const p = {
ttfb,
loadDelay: resourceStart - ttfb,
loadDuration: resourceEnd - resourceStart,
renderDelay: lcpTime - resourceEnd,
lcpTime,
};
return p;
}
const sum = (p) => PHASES.reduce((s, k) => s + p[k], 0);
// ── 第 1 部分:两个真实形态的分解 ────────────────────────────────
const cases = [
{
name: '图片 LCP(hero.jpg,被 JS 运行时发现)',
// ttfb=320, 资源 600ms 后才开始下载, 下载 380ms, 下载完到画出来 210ms
input: { ttfb: 320, resourceStart: 920, resourceEnd: 1300, lcpTime: 1510 },
},
{
name: '文本 LCP(h1,服务端直出)',
input: { ttfb: 320, lcpTime: 545 },
},
];
console.log('=== LCP 四段分解 ===');
const decomposed = [];
for (const c of cases) {
const p = decomposeLCP(c.input);
decomposed.push(p);
console.log(`\n${c.name} → LCP ${p.lcpTime}ms`);
for (const k of PHASES) {
const pct = ((p[k] / p.lcpTime) * 100).toFixed(1);
console.log(` ${LABEL[k].padEnd(38)} ${`${p[k].toFixed(0)}ms`.padStart(8)} ${`${pct}%`.padStart(6)} 归 ${OWNER[k]}`);
}
console.log(` 四段之和 = ${sum(p).toFixed(0)}ms ${Math.abs(sum(p) - p.lcpTime) < 1e-9 ? '= LCP ✓' : '≠ LCP ✗'}`);
}
// ── 第 2 部分:优化收益排序(同一个图片 LCP 基线)────────────────
const base = decomposeLCP(cases[0].input);
const fixes = [
{ name: '主图改静态可发现 + preload + fetchpriority=high', phase: 'loadDelay', to: 90, effort: '低', risk: '低' },
{ name: '关键 CSS 内联 + 主线程 JS 让路(首屏组件优先 hydration)', phase: 'renderDelay', to: 60, effort: '中', risk: '中' },
{ name: '图片转 AVIF + 按 DPR 出图(280KB → 110KB)', phase: 'loadDuration', to: 150, effort: '低', risk: '低' },
{ name: 'SSR / 边缘渲染,砍掉后端排队(320 → 140)', phase: 'ttfb', to: 140, effort: '高', risk: '中' },
{ name: '主图改为内联 SVG 占位 + 骨架屏(视觉口径变化)', phase: 'renderDelay', to: 40, effort: '低', risk: '高' },
{ name: '全站 CDN 预热 + Brotli(TTFB 再降 30ms)', phase: 'ttfb', to: 110, effort: '中', risk: '低' },
];
console.log('\n=== 单项优化的收益排序(基线 LCP ' + base.lcpTime + 'ms)===');
console.log('优化项 命中阶段 收益 优化后 LCP');
const rows = fixes.map((f) => {
const next = { ...base, [f.phase]: Math.min(base[f.phase], f.to) };
next.lcpTime = sum(next);
return { ...f, saved: base.lcpTime - next.lcpTime, result: next.lcpTime };
});
rows.sort((a, b) => b.saved - a.saved);
for (const r of rows) {
console.log(`${r.name.padEnd(52)} ${LABEL[r.phase].split('(')[0].padEnd(26)} ${`${r.saved.toFixed(0)}ms`.padStart(7)} ${`${r.result.toFixed(0)}ms`.padStart(10)}`);
}
// ── 第 3 部分:全量优化后的残值 = 各段地板之和 ───────────────────
const floor = { ttfb: 110, loadDelay: 70, loadDuration: 150, renderDelay: 40 };
console.log('\n=== 全量优化后的残值 ===');
let all = { ...base };
for (const k of PHASES) all[k] = Math.min(base[k], floor[k]);
all.lcpTime = sum(all);
for (const k of PHASES) {
console.log(` ${LABEL[k].padEnd(38)} ${`${base[k].toFixed(0)}ms → ${all[k].toFixed(0)}ms`.padStart(18)} 收益 ${(base[k] - all[k]).toFixed(0)}ms`);
}
console.log(` LCP 合计: ${base.lcpTime}ms → ${all.lcpTime}ms(-${(((base.lcpTime - all.lcpTime) / base.lcpTime) * 100).toFixed(0)}%)`);
// 只优化 TTFB 的天花板:证明"后端一优化前端就没事了"是错的
const ttfbOnly = decomposeLCP({ ...cases[0].input, ttfb: floor.ttfb, lcpTime: cases[0].input.lcpTime - (base.ttfb - floor.ttfb) });
console.log(`\n只优化 TTFB(320→110,省 210ms)后 LCP = ${ttfbOnly.lcpTime}ms,仍有 ${(
(ttfbOnly.lcpTime / all.lcpTime - 1) * 100
).toFixed(0)}% 超出全量优化残值 ${all.lcpTime}ms`);
// ── 断言 ────────────────────────────────────────────────────────
const checks = [];
const ck = (n, ok) => checks.push([n, !!ok]);
ck('四段之和恒等于 LCP(图片样例)', Math.abs(sum(decomposed[0]) - decomposed[0].lcpTime) < 1e-9);
ck('四段之和恒等于 LCP(文本样例)', Math.abs(sum(decomposed[1]) - decomposed[1].lcpTime) < 1e-9);
ck('文本 LCP 的两个 load 段为 0', decomposed[1].loadDelay === 0 && decomposed[1].loadDuration === 0);
ck('发现延迟是基线里占比最大的一段', PHASES.every((k) => base[k] <= base.loadDelay));
ck('单项收益排序第一名命中发现延迟', rows[0].phase === 'loadDelay');
ck('任一项优化都不会让 LCP 变差', rows.every((r) => r.saved >= 0 && r.result <= base.lcpTime));
ck('全量优化后 LCP 严格下降', all.lcpTime < base.lcpTime);
ck('各段优化后不突破各自地板', PHASES.every((k) => all[k] >= floor[k]));
ck('只优化 TTFB 达不到全量优化的效果', ttfbOnly.lcpTime > all.lcpTime);
let bad = 0;
for (const [n, ok] of checks) {
if (!ok) bad++;
console.log(`${ok ? 'PASS' : 'FAIL'} ${n}`);
}
if (bad) {
console.error(`\n${bad} 条断言失败`);
process.exit(1);
}
console.log(`\n全部 ${checks.length} 条断言 PASS`);
text
=== LCP 四段分解 ===
图片 LCP(hero.jpg,被 JS 运行时发现) → LCP 1510ms
TTFB(首字节) 320ms 21.2% 归 后端 / CDN / SSR
Resource Load Delay(发现延迟) 600ms 39.7% 归 前端 HTML 结构与资源提示
Resource Load Duration(下载) 380ms 25.2% 归 资源体积 / 格式 / 带宽与优先级
Element Render Delay(渲染阻塞) 210ms 13.9% 归 主线程 JS / 关键 CSS / 字体
四段之和 = 1510ms = LCP ✓
=== 单项优化的收益排序(基线 LCP 1510ms)===
优化项 命中阶段 收益 优化后 LCP
主图改静态可发现 + preload + fetchpriority=high Resource Load Delay 510ms 1000ms
图片转 AVIF + 按 DPR 出图(280KB → 110KB) Resource Load Duration 230ms 1280ms
全站 CDN 预热 + Brotli(TTFB 再降 30ms) TTFB 210ms 1300ms
SSR / 边缘渲染,砍掉后端排队(320 → 140) TTFB 180ms 1330ms
主图改为内联 SVG 占位 + 骨架屏(视觉口径变化) Element Render Delay 170ms 1340ms
关键 CSS 内联 + 主线程 JS 让路(首屏组件优先 hydration) Element Render Delay 150ms 1360ms
=== 全量优化后的残值 ===
TTFB(首字节) 320ms → 110ms 收益 210ms
Resource Load Delay(发现延迟) 600ms → 70ms 收益 530ms
Resource Load Duration(下载) 380ms → 150ms 收益 230ms
Element Render Delay(渲染阻塞) 210ms → 40ms 收益 170ms
LCP 合计: 1510ms → 370ms(-75%)
只优化 TTFB(320→110,省 210ms)后 LCP = 1300ms,仍有 251% 超出全量优化残值 370ms
这张表直接给出了排期顺序 :第一名的收益(510ms)比第二名(230ms)大一倍多,而且它是纯前端改动 (不用等后端、不用等设计);最后两名的收益都不到第一名的一半,其中"内联 SVG 占位 + 骨架屏"还被标了高风险------因为它改的是视觉口径,用户看到的不再是真实内容了,LCP 好看了,体验未必好。
最后一行是专门写给"后端优化完就没前端什么事了"这种论调的:把 TTFB 从 320ms 优化到 110ms(认真做一遍 SSR + CDN + Brotli 的水平),LCP 只从 1510ms 降到 1300ms,仍然比全量优化的残值 370ms 高出 251% 。反过来说也成立:只修前端的发现延迟(-510ms)也到不了 370ms。这四段是并联的,不是串联的,只修一段永远到不了终点。 脚本里九条断言锁的正是这些性质:四段和恒等于 LCP、文本 LCP 的两个 load 段必须为 0、各段优化后不得突破地板、只优化 TTFB 达不到全量效果。
2.4 改动清单:按四栏归位
先看"四栏"在 HTML 里的实际形态------同一个页面的两种写法,左边每个字都在给 LCP 加延迟:
html
<!-- ❌ 反例:三层依赖 + 关键资源被懒加载 + 提示缺失 -->
<html>
<head>
<link rel="stylesheet" href="/css/app.css"> <!-- ↑ 里面还有 @import "critical.css" -->
<script type="module" src="/js/app.js"></script> <!-- 主图 URL 由它运行时拼出来 -->
</head>
<body>
<img src="/img/hero.jpg" loading="lazy"> <!-- LCP 资源被降级 + 推迟发现 -->
<img src="/img/t1.jpg" loading="lazy">
<img src="/img/t2.jpg" loading="lazy">
</body>
</html>
<!-- ✅ 正例:静态可发现 + 精确提示 + 让路 -->
<html>
<head>
<style>/* 关键 CSS 内联,零层发现 */</style>
<link rel="preload" href="/fonts/brand.woff2" as="font" type="font/woff2" crossorigin>
<link rel="modulepreload" href="/js/app.js">
<link rel="modulepreload" href="/js/page.js"> <!-- 把整条 import 链压平到一层 -->
<link rel="preload" href="/img/hero.avif" as="image" type="image/avif" fetchpriority="high">
<link rel="stylesheet" href="/css/base.css" media="print" onload="this.media='all'"> <!-- 非关键 CSS 不阻塞 -->
<script type="module" src="/js/app.js"></script>
</head>
<body>
<img src="/img/hero.avif" width="1200" height="675" fetchpriority="high" decoding="async">
<img src="/img/t1.avif" width="320" height="320" loading="lazy" fetchpriority="low">
<img src="/img/t2.avif" width="320" height="320" loading="lazy" fetchpriority="low">
</body>
</html>
(width/height 是给 CLS 用的,不属于本篇范围,但和 LCP 图片一起改最省事。另外 media="print" onload 这个非阻塞 CSS 技巧依赖 JS,记得配 <noscript> 兜底,否则关掉 JS 的用户会看到裸 HTML。)
拿到上面三张表之后,所有加载优化动作都能归到四栏里,每一栏只有少数几个正确动作,其余都是安慰剂:
第一栏:让它更早被发现(收益最大,成本最低)
- 关键 CSS 内联进 HTML(而不是"关键 CSS 用 preload 提前下"------内联是零层,preload 是一层)
- 首屏图片写死在 HTML 里,不要等 JS 渲染出来
- LCP 资源、首屏必需字体、主包依赖的 chunk 用
preload/modulepreload提前到 HTML 流里 - 服务端能提前决定的话,用 103 Early Hints 把
Link: rel=preload放在最终响应之前发出去------这是唯一能把"发现"提前到 TTFB 之前的杠杆 - 干掉
@import(改<link>),干掉"字体在 JS 里 new FontFace"
第二栏:排对优先级(收益中等,最容易做错)
- LCP 图片
fetchpriority="high",其余首屏图fetchpriority="low"(不是都 high) - 视口外交互图
loading="lazy",视口内的 LCP 图绝对不要 lazy - 主包/关键 chunk
high,埋点、监控、AB 实验 SDK 一律low - 第三方脚本能延后的一律延后(
defer不够,要让它们在 LCP 之后启动) <img>上写fetchpriority时要检查框架:React 18 只认小写fetchpriority,写成驼峰fetchPriority会被丢掉并告警;React 19 起支持驼峰。升级框架时这是最容易静默失效的一处
第三栏:减字节(收益稳定,但别只做这栏)
- 图片按 DPR 出图、转 AVIF/WebP、
srcset+sizes配对 - 压缩、Brotli、去掉首屏用不到的 polyfill
- 这一栏的边际收益递减很快,而它是最容易做的一栏,所以团队经常停在这里
第四栏:别让主线程拖住渲染(决定"下载完了要等多久才画")
- 首屏关键组件优先 hydration(React 的 selective hydration、Vue 的异步组件边界)
- 首屏不要跑重计算;把非关键初始化推到
requestIdleCallback - 字体
font-display: swap+ 尺寸匹配的回退字体(细节见 Day31)
清单之外还要有两件"反面动作" :删掉所有未使用的 preload(浏览器 3 秒后的 unused preload 警告就是免费审计工具),以及删掉"给已经静态可发现的图片再加 preload"这类零收益代码------每一行无收益的提示都是下一次事故的伏笔。
2.5 十二条纪律
- 先量后改。 没跑过 LCP 四段分解就不要动代码------否则你不知道自己在修哪一段。
- 链深优先于字节。 一层依赖 = 一次往返 + 一次解析执行 + 一次传输(实测 168ms),先把图变浅,再谈瘦身。
- LCP 资源必须静态可发现。 做不到静态,就必须
preload;两者都没有,剩下的都是安慰。 - 给 LCP 图
fetchpriority="high",给别的图low。 全都 high = 全都没排。 - LCP 图永不
loading="lazy"。 preload每一行都要能说出"它为什么不是静态可发现的"。 说不出就删掉。- 字体永远
preload+crossorigin。 少一个属性就是双份下载。 @import一律换成<link>。 没有例外。modulepreload要覆盖整条链上的节点,不是全部 chunk。 全部 preload 等于把懒加载关掉。- 扁平化之后必须重新排优先级。 发现顺序交回给你了,你不排,浏览器就按类型默认规则排。
- 第三方面板/埋点 SDK 是 LCP 头号公敌。 它们在主线程上花的每一毫秒都直接进 Element Render Delay。
- 改了就要回归。 加了
preload之后 LCP 变差的案例比变好的多,因为多数人只测了"资源变早",没测"LCP 是否变早"。
三、应用场景
场景一:首屏大图靠 setState 渲染出来
最典型的深链:app.js → vendor.js → fetch('/api/feed') → setState → <img src>。四层依赖、图片在 601ms 才被请求、827ms 完成,链路空转 24%。
改法按收益顺序:① 图片 URL 由服务端直出(SSR/边缘)或者至少在构建时可推断,写进 HTML(-601ms 量级的发现延迟);② 服务端直出做不到,就在 HTML 里 preload 那张图的 URL(需要后端把 URL 提前告诉模板);③ 加 fetchpriority="high" 保证它排在其他首屏资源前面;④ 图片本身降体积(Day31)。 ①②③ 都是纯前端+模板改动,加起来就是那条 510ms 的收益。
顺带一个真实约束:如果图片 URL 确实依赖用户数据(个性化封面),静态化这条路走不通,那就必须走 preload + 服务端在 HTML 里内联一张占位图,让"发现"和"视觉"解耦。
场景二:@import + 字体的三层串行
critical.css → @import base.css → @font-face font.woff2。这条链的问题是每一层都藏着一个"必须解析完才知道" :字体在 CSS 里,必须先下完 CSS、构建完 CSSOM 才能发现;而 @import 让这件事又多一层。
改法:@import 改 <link>(跟着预扫描器走,和关键 CSS 同层);字体在 HTML 里 preload 且带 crossorigin;font-display 选 swap 还是 optional 取决于"品牌字体是不是体验的一部分"------别再用 block,那是拿 3 秒的白屏赌一个字体。
场景三:把首屏 8 张图全部 preload
"首屏图片性能预算"最常见的执行方式就是"把首屏图都 preload"。实测结果:LCP 从 574ms 涨到 1329ms,多下 960KB。原因在难点三算过------主图只拿到约 10% 的带宽。
正确做法:只 preload LCP 那一张,给 highest;其余首屏图不 preload,给 low,让浏览器按"视口内"的默认规则自然抬升;展开后才出现的图 loading="lazy"。 如果这 8 张图确实都必须在首屏,那正确的问题不是"怎么 preload",而是"为什么首屏需要 8 张 120KB 的图"------那是产品与设计的问题。
场景四:主包抢到了带宽,LCP 图没抢到
对称的问题:主包 240KB 和 LCP 图 280KB 同为 high,平分带宽,两件事都变慢。表现是"LCP 慢了 200ms,同时可交互也慢了 200ms"。
改法:明确业务目标是"先看到内容"还是"先能操作"。先看到内容 → 图片 highest、主包 high、非首屏 chunk 全部推迟;先能操作 → 主包 highest、图片用低分辨率占位(LQIP)先画出来,高清图延后替换。这两种选择在指标上体现为 LCP 与 INP 的取舍,没有免费的第三种。
场景五:第三方 SDK 把 Element Render Delay 顶起来
四段分解里如果发现 Element Render Delay 异常高(比如 210ms 里占了 180ms),而首屏自己的代码很少,那基本就是第三方脚本:埋点、AB 实验、客服、地图、广告。它们的特点是都在主线程长任务里初始化,而且不在你的 code review 视野里。
改法:让它们全部在 LCP 之后启动(requestIdleCallback 或 load + setTimeout),fetchpriority="low",能用 worker 的搬进 worker;并用"第三方面积"当指标纳入监控------首屏第三方字节数和主线程占用,应该和 LCP 一起挂在看板上。
场景六:多页应用/MVC 站点,导航变慢
对不是 SPA 的站点(电商列表、内容站),跨页导航的加载快慢同样值得管,而且这里有个前端几乎没用过的杠杆:Speculation Rules API (Chromium 已支持,取代旧的 <link rel="prerender">)。规则写在 HTML 里声明"哪些链接可以被预取/预渲染",浏览器在空闲时提前做,用户点下去就是零等待。
代价是资源消耗与副作用 (预渲染的页面会执行 JS、发请求、可能触发埋点),所以规则要保守:只对高概率的下一跳(详情页、下一页)开 prerender,其余用 prefetch;并且要配合 document.prerendering 检测,避免在预渲染阶段做有副作用的事。这是把"发现延迟"压到负数(提前到用户点击之前)的唯一手段。
场景七:上线后发现 LCP 变差,但没人改过首屏
这是最典型的"排查"场景,也是最容易归错因的。三步定责法:
- 先看分解 :四段里哪一段涨了。发现延迟涨 → 有人往首屏前面塞了新依赖(新 SDK、新字体、新
@import);下载段涨 → 图片变大或缓存失效;渲染段涨 → 主线程多了东西;TTFB 涨 → 后端/缓存命中率/CDN。 - 再看 LCP 元素变没变:如果 LCP 元素从图片变成了文本(或反过来),四段的分布规律整个换一套(见难点五的对照表),"变差"可能只是元素换了。
- 最后查跨域 :如果 LCP 元素是跨域图,检查
Timing-Allow-Origin------没配的话renderTime为 0,你的分解本身就是错的,先修埋点再排查。
四、总结
- 加载性能的本质是排队,不是体积。 同一份 943KB 的页面,只改发现时序,LCP 从 827ms 到 574ms(-31%),而链路利用率从 76% 升到 91%------原来那 24% 的空转时间,就是用户在等的"不知道要下什么"。
- 资源只在三个时刻被发现,只有第一个是免费的。 HTML 流式扫描(预扫描器)> 资源解析期(CSSOM、模块 import)> 代码执行期(fetch、setState、拼 URL)。每往后一级,成本是 RTT + 解析执行 + 传输,实测 168ms/层,链深 5 层就是 921ms 的请求延迟。
- 优先级是调度器的输入,不是礼貌请求。 它的用法是"给唯一的关键资源抢,其余全部让",而不是"都标 high"。滥用实测代价:LCP 574ms → 1329ms(2.3 倍)外加 960KB 白下载。
- 扁平化买到的是顺序,不是总量。 带宽守恒,扁平化之后必须显式重排优先级------B 场景里主包从 399ms 推迟到 686ms 就是没重排的账单。
- LCP 是一个四段和式。 图片 LCP 的发现延迟占 39.7%(前端),文本 LCP 的 TTFB 占 58.7%(后端)。不定段就优化,等于抛硬币。
- 四段是并联的,只修一段永远到不了终点。 只把 TTFB 从 320ms 打到 110ms,LCP 仍比全量优化残值高 251%。
- 三件工具 + 一条纪律。 设计期算链深(waterfall-sim.mjs)、算发现时刻(discovery-cost.mjs),线上算归因(lcp-phases.mjs);纪律是那句每天都要问的话------这个资源最早能在哪一层被发现,我现在让它等到第几层?
下一篇 Day27 讲网络层:HTTP/2 与 HTTP/3 的差异、缓存策略与失效、CDN 与边缘计算------那是把本篇里"传输段"和"TTFB 段"压下去的一篇,和本篇的"发现段"合起来才是完整的加载链路。
运行环境
- Node.js 16+,零依赖,三个文件各自独立可跑:
node waterfall-sim.mjs、node discovery-cost.mjs、node lcp-phases.mjs。每份脚本自带不变量断言,失败即exit 1;本文引用的所有数字均为上述脚本的实跑输出,并与文中表格逐条核对过。 - 模型边界(重要) :三个脚本都是确定性简化模型,不是浏览器模拟器。具体假设:链路带宽固定 1.5KB/ms(≈12Mbps)、同源复用连接下每个请求付 40ms RTT、首个请求额外付 60ms DNS+TCP+TLS、并发流上限默认 100(H2 单连接)、带宽按优先级权重(highest 4 / high 2 / medium 1 / low 0.5)比例分配、CSS 解析 0.05ms/KB、JS 解析+执行 0.4ms/KB。
- 真实浏览器的调度器比这复杂得多(优先级档位、依赖关系(资源依赖树)、HTTP/2 流优先级、服务器行为、缓存命中与否都会影响),所以这些数字只能用来做策略之间的相对比较,不能当成绝对预测。它们的价值恰恰在于:把"深链 vs 扁平""全量 preload vs 精准提示"这两组差异放到同一个坐标系里,得到的结论(差 1~2 倍,且方向与硬件无关)比具体毫秒数更重要。
- 三个场景的节点体积与依赖关系来自一个中等规模真实页面的形态抽象(32KB HTML + 内联关键 CSS、48KB 关键 CSS、240KB 主包 + 180KB vendor、96KB 字体、280KB 首屏图),不是某个具体站点的抓包数据。
- 浏览器侧清单里的行为约定(预扫描器的发现范围、
fetchpriority的取值、loading="lazy"的时机与优先级影响、字体 preload 需要crossorigin、unused preload 警告、跨域图片renderTime为 0、103 Early Hints、Speculation Rules)均为 Chromium/主流浏览器当前行为;Safari/Firefox 对fetchpriority与 Speculation Rules 的支持进度不同,落地前需按目标浏览器逐条确认------这些差异属于"兼容性核对",不属于"模型不确定性"。