一、技术难点
Day28 的结尾留了一句话:峡谷里那 1215ms 的水合长任务,在浏览器里到底怎么切、怎么让。
这一篇就是回答它。但我要先把问题重新问一遍,因为**"运行时性能"被当成一个任务名的时候,它几乎总是做错**。先给结论:
运行时性能不是"把活干得更快",而是"决定这块活和用户的那一次点击谁先跑"。 主线程只有一个,CPU 每秒就那么多毫秒。你只有两条路:让工作让位给输入,或者让用户等着工作。所谓"优化到可以交互",本质是在时间轴上给输入事件腾出位置 ------而腾位置是有价格的,价格表只由两个数决定:切片粒度 g (一次占住主线程多久)和 yield 成本 o(两次切片之间让出多久)。
本文要拆掉的三个"常识",都是我在写脚本时先当成真、后来被自己的断言推翻的:
- 「把长任务切碎,INP 就好了」 ------切片只对"别人的活"有效。切一段和这次交互无关的启动水合,输入延迟从 1190.7ms 掉到 49ms ;但在这次交互自己的 handler 里 切,16 片换来的是处理时间 +2.3ms ,一分不省还倒赔(
inp-anatomy.mjs,第七部分)。 - 「INP 就是最慢的那一次交互」 ------不是。100 次交互的会话里,p98 落在第 3 差 上。把最差的那一次从 662.3ms 修到 120ms,INP 从 506.3ms 变成 332.1ms ------不是不变,是"第 4 名顶上来了"。更反直觉的是:99 次 ≤149ms + 1 次 5000ms 的会话是 good(148ms),而 97 次 60ms + 3 次 250ms 的会话是 poor(250ms) 。因为 INP 数的是"越过阈值的次数",不是"越过了多少"。
- 「先把长任务降到 50ms 以下」 ------50ms 是 Long Task 的口径,不是 INP 的口径。INP 的 good 门槛只要求 g ≤ 126.2ms 。为一个 100ms 的任务焦虑、花掉 30% 的墙钟预算去切到 50ms,换来的 INP 收益是 49ms ------这笔钱通常更该花在 handler 上(
scheduler-ledger.mjs,第六部分)。
本篇所有数字都来自三份零依赖 Node 脚本的实跑输出,每份都带不变量断言(合计 500 条),断言失败即 exit 1。和 Day25~Day28 一样,它们是确定性简化模型 ,价值是把不同策略放进同一个坐标系里比较,而不是预测某台机器上的绝对毫秒数。参数与每个脚本的乐观之处在文末「运行环境」逐条写清楚了。
边界也要先说清:本篇讲运行中的主线程 ------任务怎么切、优先级怎么排、INP 怎么分解和量化。拆包 留给 Day30(那是"字节怎么到浏览器"),图片字体 留给 Day31,Worker/OffscreenCanvas 留给 Day32(那是"活搬到另一个线程"),性能监控与 RUM 留给 Day34。本篇的活儿全在一个线程内部的调度上------混着讲的结果通常是四件事都说不透。
难点一:切片不改变"活的总量",它改变"最坏的那一次"
先看最干净的一张表。一段 1215ms 的主线程工作(沿用 Day28 的水合账),切成 g 毫秒一片。输入在该窗口内随机时刻到达,它的等待时间分布是可以解析写出来的------我用 slice-budget.mjs 做了 20 万次采样的模拟,和解析式对到小数点后两位:
| g(片长) | o(间隙) | 解析均值 | 模拟均值 | 解析 P98 | 模拟 P98 | 最坏 |
|---|---|---|---|---|---|---|
| 1215 | 0.15 | 607.43 | 607.59 | 1190.7 | 1190.83 | 1215 |
| 100 | 0.15 | 49.93 | 50.01 | 98 | 97.91 | 100 |
| 50 | 0.15 | 24.93 | 24.95 | 49 | 48.99 | 50 |
| 5 | 0.15 | 2.43 | 2.43 | 4.9 | 4.9 | 5 |
| 5 | 4.0 | 1.39 | 1.39 | 4.82 | 4.82 | 5 |
| 1 | 0.15 | 0.43 | 0.43 | 0.98 | 0.98 | 1 |
两个式子撑起全篇:
erlang
平均输入延迟 = g² / (2(g + o)) ------ 到达落在片内则等片尾,落在间隙则立刻可跑
P98 输入延迟 = 0.98g − 0.02o ------ 把"延迟 > d"的概率解到 2%
请看第三行和第五行的对比:同一个 g=5ms,把间隙从 0.15ms 拉到 4ms,平均输入延迟从 2.43ms 降到 1.39ms(0.57 倍),而 P98 从 4.90ms 只变到 4.82ms(0.98 倍)。
这一行是本文第一个大坑,而且是我自己踩进去的:初版脚本里我断言"间隙越大,平均输入延迟越大",跑出来是 0.57 倍------方向完全相反。原因很简单:间隙期间主线程空闲,输入可以立刻派发;间隙越多越长,"立刻可跑"的窗口就越多。这条件错误断言后来变成了本篇最强的一条结论:
加长间隙能压低平均输入延迟,但抬不动 P98。而 INP 是尾指标。所以这条路对 INP 的收益为零,只是白花墙钟。
所以"切片"这个动作的全部作用可以浓缩成一句:它不减少工作,它把"最坏那一次阻塞"从 W 压缩到 g。 而这次压缩的价格是墙钟:T = W + (n−1)·o,其中 n = ⌈W/g⌉。
难点二:g 与 o 分工明确------g 管尾部,o 只管墙钟
既然 P98 输入延迟 ≈ 0.98g,那"改善尾部"只有一个旋钮:把 g 调小 。o 不参与这件事,它只决定你把 g 调小要付多少钱。这张表把两种原语的差价摆在同一个坐标系里(slice-budget.mjs,第三部分):
| 原语 | o | 从 100ms 切到 50ms 多花 | 从 50ms 切到 5ms 多花 | 每省 1ms 延迟的价格 |
|---|---|---|---|---|
scheduler.postTask / MessageChannel |
0.15 | +1.8ms | +32.7ms | 0.74 ms 墙钟 / ms 延迟 |
scheduler.yield() |
0.10 | +1.2ms | +21.8ms | 0.49 |
setTimeout(fn, 0) |
4.00 | +48ms | +872ms | 19.77 |
requestAnimationFrame 分帧 |
16.67 | +0ms | +2833.3ms | 64.25 |
同一件事(把最坏阻塞从 50ms 压到 5ms),setTimeout 要付 872ms,scheduler.postTask 只要 32.7ms------26.7 倍 。差距不在算法,在 setTimeout 的规范细节:嵌套 5 层之后,最小延迟被钳到 4ms(HTML 规范的 timer clamping)。一个朴素的 while (hasWork) { doChunk(); await sleep(0) } 循环,占空比会被这个 clamp 打到 55.7%:
| g | 片数 | postTask 墙钟 | 占空比 | timeout 墙钟 | 占空比 |
|---|---|---|---|---|---|
| 50 | 25 | 1218.6 | 99.7% | 1311 | 92.7% |
| 20 | 61 | 1224.0 | 99.3% | 1455 | 83.5% |
| 5 | 243 | 1251.3 | 97.1% | 2183 | 55.7% |
| 1 | 1215 | 1397.1 | 87.0% | 6071 | 20.0% |
g=1ms 那一行值得盯三秒:用 setTimeout 把 1215ms 的活切成 1ms 一片,实际要跑 6071ms(+399.7%);用 scheduler.postTask 是 1397.1ms(+15%)。 三倍以上的差距,全部来自"你选了哪个函数来让出主线程"。
难点三:INP 不是"最差那一次",它是一条计数预算
这一节是本文最重要的一次重构,因为它改变了优化的排序。
INP 的定义是"高百分位"------按 p98 取。那么 p98 落在第几差?inp-anatomy.mjs 把两种分位口径并排列出来:
| 交互数 N | p98 名次(nearest-rank) | 从最差数第几名 | p98 名次(插值) |
|---|---|---|---|
| 20 | 20 | 1(就是最差) | 20 |
| 30 | 30 | 1 | 30 |
| 50 | 49 | 2 | 50 |
| 100 | 98 | 3 | 99 |
| 200 | 196 | 5 | 197 |
| 500 | 490 | 11 | 491 |
两个口径在 N≈50 处开始分歧(0.98 × 50 = 48.999...,ceil 得 49,所以"最差"的边界恰好在 N=49)。但无论哪个口径,N 从 50 长到 200,p98 只在第 2~5 差之间移动。这不是"最差那一次"。
于是可以做一次等价改写。设会话里有 N 次交互,p98 名次是 ⌈0.98N⌉,那么允许超过阈值的交互次数是:
yaml
N − ⌈0.98N⌉ = ⌊0.02·N⌋
20 → 0 50 → 1 100 → 2 200 → 4 500 → 10 1000 → 20
也就是说:
「INP ≤ 200ms」的真实含义是「超过 200ms 的交互不超过 ⌊2%·N⌋ 次」。它是一个计数预算,不是一个时长预算。
这句话的推论非常刺人。会话甲:99 次 ≤149ms + 1 次 5000ms 灾难 → p98 = 148ms → good 。会话乙:97 次 60ms + 3 次 250ms 小慢 → p98 = 250ms → poor 。甲的最差交互是乙的 20 倍,指标却从 poor 变 good。
所以:
- "先修最慢的那一次"是错的排序。 一个 5 秒卡顿只吃掉 1 个名额,三次 250ms 的小慢会把名额吃光。该优先修的,是**"刚越过 200ms 且数量最多"的那一批**------它们最便宜,也最占名额。
- INP 对单次灾难几乎静音。 这正是它必须和 LoAF(Long Animation Frames)/长任务监控一起看的原因,否则你会漏掉用户真正暴怒的那一刻(Day34 会把这套监控做完整)。
再看"修"和"删"的区别,两者在 p98 里不是一回事:
| 修掉最差的 k 次(降到 120ms,N 不变) | P98 | Δ | 等价于原来的第几差 |
|---|---|---|---|
| 0 | 506.3 | --- | --- |
| 1 | 332.1 | −174.2 | 原第 4 差 |
| 2 | 252.7 | −79.5 | 原第 5 差 |
| 3 | 246.4 | −6.2 | 原第 6 差 |
| 4 | 229.2 | −17.2 | 原第 7 差 |
4 个重交互全部修到 120ms,INP 仍停在 229.2ms(poor) ------因为第 3 差换成了中交互的尾部。而如果是删掉 最差那一次(N 从 100 变 99),P98 从 506.3ms 变成 506.3ms,精确不变。少一次采样和少一段耗时,在这把尺子上是两件事。
还有一件事得先说:这份 100 次交互的会话,均值 121.4ms、p50 105.4ms、p75 120.3ms、p90 163.2ms,只有 p98 是 506.3ms。均值比 good 阈值还低 78.6ms。所以"我们平均只有 121ms"这句话,关于 INP 什么也说明不了。
难点四:交互延迟是三段,而"主线程很忙"只是第一段
INP 计量的是一次交互从输入到下次绘制 。它由三段相加(inp-anatomy.mjs,第五部分):
交互延迟 = 输入延迟(主线程排队) + 处理时间(你的 handler) + 呈现延迟(到下一帧画完)
代入本篇的重水合页面:1190.7 + 145 + 124.3 = 1460ms ,三段占比 81.6% : 9.9% : 8.5%。这时你去优化 handler 或重渲染,天花板很低:
| 修法 | Δ输入延迟 | Δ处理 | Δ呈现 | ΔINP | 修后 |
|---|---|---|---|---|---|
| A 启动工作切片 g:1215→50ms | −1141.7 | 0 | 0 | −1141.7 | 318.3 |
| B handler 变便宜 145→45ms | 0 | −100 | 0 | −100 | 1360 |
| C 重渲染范围 9000→2000 节点 | 0 | 0 | −84 | −84 | 1376 |
| D 仅把 DOM 总量减到 3000(未减重渲染范围) | 0 | 0 | 0 | 0 | 1460 |
三项叠加:1460 → 134.3ms (good),且可加性成立(−1325.7 = 三项之和)。只做最容易的那一项 C,INP 只改善 5.8%。 这正是"我优化了渲染,指标没动"的算术解释。
而三类页面的主导相位完全不同,答案也就完全不同:
| 页面类型 | 输入延迟 | 处理 | 呈现 | 合计 | 主导相位 | 先修什么 |
|---|---|---|---|---|---|---|
| 内容站(SSR + 少量交互) | 19.6 | 30 | 124.3 | 173.9 | 呈现 | 缩重渲染范围 |
| 交易站(表格 + 大列表交互) | 49 | 180 | 64.3 | 293.3 | 处理 | 拆 handler、移出同步路径 |
| 重水合 SPA(全量水合) | 1190.7 | 145 | 124.3 | 1460 | 输入延迟 | 切启动任务 |
先分段,再选刀。 拿着"切长任务"这把刀去修内容站,切完还是 173.9ms 的账,因为那里根本没有长任务。
难点五:同一把刀,切"别人的活"有用,切"自己的 handler"有害
这是本篇最容易做错的一步。切片技术本身没错,错在切的位置。
inp-anatomy.mjs 第七部分把一段 120ms 的 handler 切成 k 片(片间 yield 0.15ms):
| handler 切成 k 片 | 处理时间 | 交互延迟 | 相对不切 |
|---|---|---|---|
| 1(不切) | 120 | 293.3 | --- |
| 2 | 120.2 | 293.5 | +0.1ms |
| 4 | 120.5 | 293.8 | +0.5ms |
| 16 | 122.3 | 295.6 | +2.3ms |
切 16 片的净效果是变慢 2.3ms 。原因是度量的定义:INP 统计"这次交互从输入到绘制完成",你在 handler 里让出的时间,仍然算在这次交互的账上------你只是把等待从"别人"身上挪到了"自己"身上。
而同一种技术用在启动水合上(它不在任何被测交互的 handler 里):输入延迟 1190.7ms → 49ms。同一把刀,两个方向。
判断标准只有一条:这段工作是否在被测交互的 handler 里。不在 → 切,收益全归输入延迟。在 → 不要切,收益为负;要减的是它的工作量本身,或者把它挪出同步路径。
顺带说清一个反面的用法:在 handler 里切片并非完全没有意义------它能让后续的输入插进来。但代价是这次交互自己的额度。如果这次交互本来就在 200ms 以下,而页面里还有大量让人等 400ms 的活,那是值得的;如果这次交互自己已经超了,先减工作量。
难点六:呈现延迟的地板是"一帧",INP 压不到 0
呈现延迟 = 从 handler 结束到"这一帧画完"。它有一个硬地板:你得等到下一个绘制时机 。P98 口径下,帧量化本身就贡献 0.98 × 16.67 = 16.33ms,再加上绘制本身。
| 重渲染节点 | 绘制 | 呈现 P98 | INP 地板(输入延迟取 0、handler 取 8ms) |
|---|---|---|---|
| 0 | 0 | 16.3 | 24.3 |
| 83 | 1 | 17.3 | 25.3 |
| 333 | 4 | 20.3 | 28.3 |
| 1667 | 20 | 36.3 | 44.3 |
| 9000 | 108 | 124.3 | 132.3 |
即使一个节点都不重渲染、handler 只要 8ms,INP 的地板仍是 24.3ms。这条地板不是工程问题,是"帧"这个单位的定义决定的。
而它的斜率 才是可优化项:绘制成本 ≈ 重渲染节点数 × 0.012ms/节点。9000 节点重渲染 → 108ms,这已经占了 INP 预算的 54%。所以"减 DOM / 缩重渲染范围 / 用 content-visibility 跳过视口外内容"不是风格问题,它们直接改的是第三段。
难点七:INP 的门槛换算成"最长任务"是 126.2ms,不是 50ms
把三段表达式代进 good 阈值(本篇参数:handler 40ms、呈现 P98 36.3ms):
ini
INP = 0.98g + 40 + 36.3 ≤ 200 → g ≤ 126.2ms
scheduler-ledger.mjs 第六部分和 slice-budget.mjs 第五部分各自独立算出了同一个数:126.2。
于是有两个尺度必须分开:
| 问题 | 门槛 | 100ms 的任务 |
|---|---|---|
| "这是 Long Task 吗" | > 50ms | 是 |
| "它会让 INP 超标吗" | > 约 126ms | 不会 |
slice-budget.mjs 第七部分把这条对比直接画出来了:g=126 时 P98 交互延迟 199.8ms(good) ,g=200 时 272.3ms(poor) ,而这两档都是长任务。
为 INP 优化时,把预算花在"从 100ms 切到 50ms"上是一笔坏买卖 :那一段的 INP 收益只有 49ms,而这笔墙钟通常更该花在 handler 或重渲染范围上。只有当你真的需要"零长任务"(比如为了 TBT、或者为了不打断进行中的动画与输入)时,才值得为 50ms 以下付钱。
难点八:原语的成本是阶跃的,而且选择原语对 INP 往往不重要
g* = o/(M−1) 是加性原语的闭式解(M 为墙钟预算倍数):同样的预算,能切多细完全由 o 决定。
| 预算倍数 | scheduler.yield() |
postTask |
setTimeout |
rAF 分帧 |
|---|---|---|---|---|
| ×1.05 | 2 | 3 | 76 | 16 |
| ×1.10 | 1 | 1.5 | 39.2 | 15.2 |
| ×1.30 | 0.4 | 0.5 | 13.3 | 13 |
rAF 不在这个闭式里:它的成本是帧数模型 ------帧数 × 16.67ms,因为干活只能发生在帧内。所以它的成本是阶跃的:
| g | 帧数 | 墙钟 | 相对 W |
|---|---|---|---|
| 60 | 73 | 1216.7 | +0.1% |
| 16.7(= 一帧) | 73 | 1216.7 | +0.1% |
| 8 | 152 | 2533.3 | +108.5% |
| 5 | 243 | 4050.0 | +233.3% |
| 1 | 1215 | 20250.0 | +1566.7% |
rAF 在"一片正好塞进一帧"时几乎免费(+0.14%);一旦片长小于一帧,就按帧付费。 g=1ms 的 rAF 切片会把 1215ms 的活干成 20250ms(16.7 倍)------因为 1215 个片就是 1215 帧,而帧是 16.67ms 一班的。
但真正该记住的是这张矩阵(三个目标 × 三档预算,scheduler-ledger.mjs 第六部分):
| 目标 | +5% | +10% | +30% |
|---|---|---|---|
| INP good(交互 ≤200ms) | 5/5 | 5/5 | 5/5 |
| 无 >50ms 长任务 | 4/5 | 5/5 | 5/5 |
| 无 >一帧(16.7ms)的任务 | 4/5 | 4/5 | 5/5 |
注意第一行:只要预算 +5%,五种原语全都足以把 g 压到 126ms 以下 (最粗的 setTimeout 也有 76ms)。所以:
为 INP 优化时,原语的选择几乎不重要------重要的是"你有没有把 g 降下来"。只有当你追求"零长任务"或"帧内任务"时,原语才成为瓶颈。
这也解释了为什么"换成 scheduler.postTask"经常看不到效果:多数团队的 INP 问题不在 0.15 vs 4ms 的 o,而在 g 还是 1215。
难点九:优先级买的是"排队长度",买不到"当前这个任务"
最后一个坑,也是团队里最常见的一处误用。scheduler.postTask 有三档优先级(user-blocking / user-visible / background),离散输入天然高于后台任务。收益有多大?scheduler-ledger.mjs 第三部分:40 个 20ms 的后台任务(共 800ms)排着,点击在随机时刻到达。
| 调度方式 | 输入延迟均值 | 相对 FIFO |
|---|---|---|
| FIFO(无优先级) | 400 | 1× |
| 离散输入 > 后台(正确定级) | 10 | 40× 更快 |
收益是 40 倍 ,而且它等于"排队的任务数",与"每个任务多长"无关。优先级买的是排队的长度,不是任务的成本。
但它有一个硬限制:任务不可抢占 。如果当前正在跑的是一个 200ms 的同步任务,点击的输入延迟仍然是 200ms------优先级只决定"下一个跑谁",不决定"当前这个跑多久"。
优先级救不了长任务,只有 yield 能。
而"优先级通胀"(把所有活都标成 user-blocking)的代价是线性的:
| 通胀率 | 同级剩余任务 | 输入延迟 | 相对正确定级 |
|---|---|---|---|
| 0% | 0 | 10 | 1× |
| 25% | 5 | 110 | 11× |
| 50% | 10 | 210 | 21× |
| 75% | 15 | 310 | 31× |
| 100% | 20 | 410 | 41× |
每多标 25% 的活,线性多付 100ms;全标等于没标(410ms ≈ FIFO 的 400ms)。 而"全标 user-blocking"恰恰是最常见的一种写法,因为没人愿意自己的任务是低优先级的。优先级的价值来自稀缺,人人都有份就等于没有。
顺带记一下反面代价------饥饿。如果 user-blocking 的占用率是 ρ,后台任务的完成时间被拉伸 1/(1−ρ):
| ρ | 后台完成时间 | 拉伸 |
|---|---|---|
| 0.5 | 2430 | 2× |
| 0.7 | 4050 | 3.3× |
| 0.9 | 12150 | 10× |
| 0.95 | 24300 | 20× |
这一栏非线性 :ρ 从 0.7 涨到 0.95,拉伸从 3.3× 变 20×。所以 user-blocking 的用量必须有预算------ρ 一旦超过约 0.8,后台活(预取、上报、缓存预热、埋点)就基本等于被删了。而这个后果通常要两周后才在报表上显示出来。
二、完整解法
上面九个难点可以归结成一句话:你要决定的是"这块活和用户的那一次点击谁先跑",而这个决定由两个数和一个位置构成------切片粒度 g、yield 成本 o,以及"这段活是否在被测交互的 handler 里"。
三份零依赖脚本各管一段:设计期用 slice-budget.mjs 算"我该切多细、要付多少墙钟";评审期用 inp-anatomy.mjs 拆"我的 INP 归哪一段、修哪一段最划算";实现期用 scheduler-ledger.mjs 定"用哪个原语、标哪一档优先级"。它们共用同一套单位成本(handler 40ms、呈现 0.012ms/节点、帧 16.67ms),所以同一张页面的数字在三份账之间能互相印证------slice-budget.mjs 与 scheduler-ledger.mjs 各自独立解出的 g ≤ 126.2ms 就是一次交叉校验。
2.1 工具一:把"切多细"变成一个可算的时间预算
slice-budget.mjs 的骨架是三条恒等式和一个闭式解。有了它们,"要不要切片、切多细"就不再是经验判断:
ini
P98 输入延迟 = 0.98g − 0.02o
墙钟 T = W + (⌈W/g⌉ − 1)·o
占空比 W / T
以及一个反解:给定墙钟预算倍数 M,加性原语能切到的最细粒度是
scss
g* = o / (M − 1) ------ 与 W 无关
这个闭式解本身就是一条结论:"能切多细"只由你选的 yield 原语决定,与你有多大的工作量无关。 同样 +30% 的预算,scheduler.yield() 能切到 0.4ms,setTimeout 只能 13.3ms。
脚本还做了一件 Day28 做过的事:27 组参数扫描 (handler 10/40/100ms × 呈现 5/20/60ms × 工作量 300/1215/4000ms),把四条保序结论钉在所有参数取值 上------三条恒等式恒成立、g 的 good 上限公式恒成立、g 减半则 P98 输入延迟减半、墙钟恒等式与 W 无关。结论只在参数取任何值时都成立,才值得拿去做架构决策。 扫描还顺带给出一个残酷的边界:当 handler + 呈现已经吃掉 176.3ms 时,g 的余量只剩 24.15ms------这时候再怎么切都救不了,必须回去减 handler 和重渲染范围。
js
#!/usr/bin/env node
'use strict';
/*
* slice-budget.mjs ------ 主线程时间切片的预算账本
*
* 零依赖、确定性。回答四个问题:
* 1. 一段 W 毫秒的主线程工作,切成 g 毫秒一片之后,最坏输入延迟是多少?墙钟多花多少?
* 2. 输入延迟里,切片粒度 g 与 yield 成本 o 各管哪一半?(答案不是各管一半)
* 3. 给定一个墙钟预算(比如 +30%),不同的 yield 原语分别能把 g 切到多细?
* 4. rAF 切片为什么在细粒度下会把 1.2 秒的活干成 20 秒?
*
* 断言失败即 exit 1。
*/
// ============ 模型参数(与正文「运行环境」逐条对齐)============
const CFG = {
FRAME: 1000 / 60, // ms 一帧(60Hz)
PAINT: 20, // ms 呈现(样式 + 布局 + 绘制 + 合成)一次交互的改动
HANDLER: 40, // ms 一次交互 handler 的净处理时间
SAMPLES: 200000, // 输入到达时刻的采样数(均匀分布)
};
// 主线程工作:沿用 Day28「窄谷」那一笔账------水合 1215.0ms
const W = 1215;
// 交互延迟的三段:输入延迟 + 处理时间 + 呈现延迟
const D_PRESENT = 0.98 * CFG.FRAME + CFG.PAINT; // 16.33 + 20 = 36.33(P98 帧量化 + 绘制)
let NS = 0;
const ok = (cond, msg) => { NS++; if (!cond) throw new Error('断言失败: ' + msg); };
const r1 = (x) => Math.round(x * 10) / 10;
const r2 = (x) => Math.round(x * 100) / 100;
const pct = (a, b) => r1((a / b - 1) * 100);
// ============ 三个解析式(本文的骨架)============
// 切片数:n = ceil(W/g)
const slices = (g) => Math.ceil(W / g);
// 墙钟:T = W + (n-1)·o(最后一次不需要 yield)。参数化版本供扫描复用。
const wallOf = (w, g, o) => w + (Math.ceil(w / g) - 1) * o;
const wall = (g, o) => wallOf(W, g, o);
// 平均输入延迟:一个周期长 g+o,到达落在"片内"的概率 g/(g+o),
// 落入片内时距片尾均匀分布(均值 g/2),落入间隙时主线程空闲(延迟 0)。
const meanDelay = (g, o) => (g * g) / (2 * (g + o));
// P98 输入延迟:解 P(延迟 > d) = (g-d)/(g+o) = 0.02
const p98Delay = (g, o) => 0.98 * g - 0.02 * o;
// 交互延迟(P98 口径)= 输入延迟 + 处理 + 呈现
const inpOf = (g, o) => p98Delay(g, o) + CFG.HANDLER + D_PRESENT;
// 占空比:真正干活的比例
const duty = (g, o) => W / wall(g, o);
// ============ 模拟:验证上面两个分布公式 ============
// 确定性 LCG(不用 Math.random,保证可复现)
function lcg(seed) {
let s = seed >>> 0;
return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
}
function simulateDelay(g, o, rand) {
const cycle = g + o;
const n = CFG.SAMPLES;
const samples = new Array(n);
let sum = 0;
for (let i = 0; i < n; i++) {
const x = rand() * cycle; // 到达时刻在一个周期内的相位
const d = x < g ? g - x : 0; // 片内到达 → 等片尾;间隙内到达 → 立即
samples[i] = d;
sum += d;
}
samples.sort((a, b) => a - b);
const q = (p) => samples[Math.min(n - 1, Math.ceil(p * n) - 1)]; // nearest-rank
return { mean: sum / n, p50: q(0.50), p98: q(0.98), max: samples[n - 1] };
}
console.log('='.repeat(78));
console.log('第一部分:两个分布公式的模拟校验(W=%dms, 200000 次采样)', W);
console.log('='.repeat(78));
console.log('g o 解析mean 模拟mean 解析p98 模拟p98 解析max = g');
console.log('-'.repeat(78));
const rand = lcg(20261017);
const checkCases = [
[1215, 0.15], [100, 0.15], [50, 0.15], [20, 0.15], [5, 0.15], [1, 0.15],
[50, 4.0], [20, 4.0], [5, 4.0], [1, 4.0],
];
for (const [g, o] of checkCases) {
const s = simulateDelay(g, o, rand);
const am = meanDelay(g, o), ap = p98Delay(g, o);
console.log(
String(g).padEnd(6), String(o).padEnd(6),
r2(am).toString().padEnd(9), r2(s.mean).toString().padEnd(10),
r2(ap).toString().padEnd(9), r2(s.p98).toString().padEnd(10),
r2(g).toString().padEnd(8), r2(s.max));
ok(Math.abs(s.mean - am) / Math.max(am, 1e-9) < 0.02 || am < 0.01,
`模拟均值 ${r2(s.mean)} 应≈解析 ${r2(am)}(g=${g},o=${o})`);
ok(Math.abs(s.p98 - ap) <= Math.max(0.02 * g, 0.02),
`模拟 P98 ${r2(s.p98)} 应≈解析 ${r2(ap)}(g=${g},o=${o})`);
ok(s.max <= g + 1e-9, `最坏延迟不应超过片长(g=${g})`);
}
// 关键的定性结论:o 只影响均值,几乎不影响 P98
// (自测修正:我原本断言"o 变大 → 均值变大",实跑是变小------间隙本身是空闲窗口。
// 这条错误断言恰好产出了难点二:加长间隙压低均值、抬不动 P98,而 INP 是尾指标。)
const meanRatio = meanDelay(5, 4) / meanDelay(5, 0.15);
const p98Ratio = p98Delay(5, 4) / p98Delay(5, 0.15);
ok(meanRatio < 0.7, `o 从 0.15 涨到 4.0,均值输入延迟应显著下降(实测 ${r2(meanRatio)}×)`);
ok(p98Ratio < 1.02 && p98Ratio > 0.98, `o 不应改变 P98 输入延迟(实测 ${r2(p98Ratio)}×)`);
console.log('\n→ 同一个 g=5ms:o=4.0 的平均输入延迟只有 o=0.15 的 %s 倍(间隙算空闲窗口),' +
'但 P98 几乎不动(%s×)。加长间隙买不到尾部的改善。', r2(meanRatio), r2(p98Ratio));
// ============ 三种 yield 原语 ============
// 加性代价模型:T = W + (n-1)·o(最后一次不用 yield)
const addWall = (o) => (g) => W + (slices(g) - 1) * o;
// rAF 的墙钟不是加性的:干活只能发生在帧内,一片占一帧。
// 帧数 = ceil(W / min(g, FRAME)),墙钟 = 帧数 × FRAME(帧率天花板)
const rafWall = (g) => Math.ceil(W / Math.min(g, CFG.FRAME)) * CFG.FRAME;
const PRIMS = [
{ key: 'postTask', name: 'scheduler.postTask / MessageChannel', o: 0.15, note: '任务级让出,无 clamp', wall: addWall(0.15) },
{ key: 'yield', name: 'scheduler.yield()', o: 0.10, note: 'continuation 插队,代价最低', wall: addWall(0.10) },
{ key: 'timeout', name: 'setTimeout(fn, 0)', o: 4.0, note: '嵌套 5 层后 ≥4ms clamp', wall: addWall(4.0) },
{ key: 'raf', name: 'requestAnimationFrame 分帧', o: CFG.FRAME, note: '帧对齐:一片占一帧', wall: rafWall },
];
console.log('\n' + '='.repeat(78));
console.log('第二部分:切片粒度 g × yield 原语 → 墙钟代价与 P98 交互延迟');
console.log('='.repeat(78));
console.log('g(ms) 原语 片数 墙钟(ms) 占空比 墙钟涨幅 P98输入延迟 P98交互延迟 判定');
console.log('-'.repeat(78));
const GOOD = 200; // INP good 阈值
const GRID = [1215, 200, 126, 100, 50, 20, 5, 1];
const rows = [];
for (const g of GRID) {
for (const p of PRIMS) {
const n = slices(g), T = p.wall(g), du = W / T;
const d98 = p98Delay(g, p.o), L = inpOf(g, p.o);
rows.push({ g, prim: p.key, n, T, du, d98, L });
if (p.key === 'timeout' || p.key === 'postTask' || g === 1215) {
console.log(
String(g).padEnd(7), p.key.padEnd(11),
String(n).padEnd(6), r1(T).toString().padEnd(9),
(r1(du * 100) + '%').padEnd(8),
('+' + pct(T, W) + '%').padEnd(9),
r1(d98).toString().padEnd(12), r1(L).toString().padEnd(12),
L <= GOOD ? 'good' : 'poor');
}
}
}
// 单调性断言:g 越小,P98 输入延迟单调不增;墙钟单调不减
for (const p of PRIMS) {
const rs = GRID.map((g) => ({ g, d: p98Delay(g, p.o), T: p.wall(g) }));
for (let i = 1; i < rs.length; i++) {
ok(rs[i].d <= rs[i - 1].d + 1e-9, `${p.key}: g 变小时 P98 延迟不应增加`);
ok(rs[i].T >= rs[i - 1].T - 1e-9, `${p.key}: g 变小时墙钟不应减少`);
}
}
// 恒等式:T = W + (n-1)·o
for (const r of rows) {
const p = PRIMS.find((x) => x.key === r.prim);
ok(Math.abs(r.T - p.wall(r.g)) < 1e-9, '墙钟与所选原语的代价模型一致');
}
// ============ 交换率:想少阻塞 1ms,要付多少墙钟?============
console.log('\n' + '='.repeat(78));
console.log('第三部分:g 与 o 的交换率(把 P98 输入延迟从 g 压到 g/2 的价钱)');
console.log('='.repeat(78));
console.log('原语 o 从 100ms 切到 50ms 多花 从 50ms 切到 5ms 多花 每省 1ms 延迟的价格');
console.log('-'.repeat(78));
for (const p of PRIMS) {
const c1 = p.wall(50) - p.wall(100);
const c2 = p.wall(5) - p.wall(50);
const price = c2 / (p98Delay(50, p.o) - p98Delay(5, p.o));
console.log(p.key.padEnd(11), String(p.o).padEnd(6),
('+' + r1(c1) + 'ms').padEnd(24), ('+' + r1(c2) + 'ms').padEnd(24),
r2(price) + ' ms 墙钟/ms 延迟');
ok(c1 >= -1e-9 && c2 >= -1e-9, `${p.key}: 细粒度切片的墙钟不应更少`);
}
// 闭式:把 g 减半,多花的墙钟 ≈ W·o/g(片数翻倍)
{
const g = 50, o = 0.15;
const approx = (W / (g / 2) - W / g) * o;
const exact = wall(g / 2, o) - wall(g, o);
ok(Math.abs(approx - exact) < 1, `交换率闭式 W·o/g 应≈精确差(${r1(approx)} vs ${r1(exact)})`);
}
// ============ 给定预算,原语决定你能切到多细 ============
// T ≤ M·W → W + (W/g)·o ≤ M·W → g ≥ o/(M-1)
const gStar = (o, M) => o / (M - 1);
console.log('\n' + '='.repeat(78));
console.log('第四部分:给定墙钟预算,各原语能切到的最细粒度 g* = o/(预算倍数-1)');
console.log('='.repeat(78));
const BUDGETS = [1.05, 1.10, 1.30, 1.50, 2.00, 3.00];
console.log('预算倍数 ' + PRIMS.map((p) => p.key.padEnd(12)).join(''));
console.log('-'.repeat(78));
for (const M of BUDGETS) {
const cells = PRIMS.map((p) => {
// 数值求解:在 0.05ms 网格上找满足预算的最粗 g(等价于"预算允许的最细粒度")
let g = 0.05;
for (; g <= 400; g = Math.round((g + 0.05) * 100) / 100) {
if (p.wall(g) <= M * W + 1e-9) break;
}
ok(p.wall(g) <= M * W + 1e-9, `M=${M}, ${p.key}: 找不到满足预算的 g`);
ok(p.wall(Math.max(0.05, g - 0.05)) > M * W - 1e-9 || g === 0.05,
`M=${M}, ${p.key}: 求出的 g 应是网格上的最粗可行值`);
p.gstar = p.gstar || {};
p.gstar[M] = g;
return r1(g).toString().padEnd(12);
});
console.log('×' + M.toFixed(2).padEnd(8) + cells.join(''));
}
// 加性模型有闭式 g* = o/(M-1);rAF 是帧数模型,闭式为 g* = FRAME/M。两条都要验。
{
const P = (k) => PRIMS.find((x) => x.key === k);
ok(Math.abs(gStar(4.0, 1.3) - 4 / 0.3) < 1e-9, 'setTimeout 的 g* 闭式应为 4/0.3 = 13.33ms');
ok(Math.abs(P('timeout').gstar[1.3] - 4 / 0.3) < 0.15,
`数值解应≈闭式(timeout: ${r2(P('timeout').gstar[1.3])} vs 13.33)`);
ok(Math.abs(P('postTask').gstar[1.3] - 0.15 / 0.3) < 0.12,
`数值解应≈闭式(postTask: ${r2(P('postTask').gstar[1.3])} vs 0.5)`);
ok(Math.abs(P('raf').gstar[1.3] - CFG.FRAME / 1.3) < 0.2,
`rAF 的 g* 闭式应为 FRAME/M = ${r2(CFG.FRAME / 1.3)}ms(实测 ${r2(P('raf').gstar[1.3])})`);
const spread = P('raf').gstar[1.3] / P('postTask').gstar[1.3];
console.log('\n→ 同一个 +30%% 墙钟预算:rAF 只能切到 %sms,setTimeout %sms,postTask %sms,scheduler.yield %sms ------ 最粗与最细差 %s 倍。',
r1(P('raf').gstar[1.3]), r1(P('timeout').gstar[1.3]), r2(P('postTask').gstar[1.3]), r2(P('yield').gstar[1.3]), r1(spread));
console.log('→ 加性原语的闭式:g* = o/(M−1)(与 W 无关);rAF 的闭式:g* = FRAME/M(与 o 无关,因为 o 恒等于一帧)。');
ok(P('yield').gstar[1.3] < P('postTask').gstar[1.3], 'yield 的 o 更小,应能切得更细');
}
// ============ P98 交互延迟的 good 门槛:g 最大能到多少 ============
const gMax = (GOOD - CFG.HANDLER - D_PRESENT + 0.02 * 0.15) / 0.98;
console.log('\n' + '='.repeat(78));
console.log('第五部分:INP 的 good 门槛换算成"最长任务"预算');
console.log('='.repeat(78));
console.log('交互延迟三段:输入延迟 + 处理 %dms + 呈现 %sms = 地板 %sms',
CFG.HANDLER, r2(D_PRESENT), r1(CFG.HANDLER + D_PRESENT));
console.log('INP good ≤ %dms → 0.98·g ≤ %sms → g ≤ %sms',
GOOD, r1(GOOD - CFG.HANDLER - D_PRESENT), r1(gMax));
{
// 用整表反查:g=134 应 good,g=200 应 poor(timeout 原语)
const at134 = inpOf(126, 0.15), at200 = inpOf(200, 0.15);
ok(at134 <= GOOD, `g=126 的 P98 交互延迟 ${r1(at134)} 应 ≤ 200`);
ok(at200 > GOOD, `g=200 的 P98 交互延迟 ${r1(at200)} 应 > 200`);
ok(gMax > 120 && gMax < 130, `g 的 good 上限应落在 (120,130),实测 ${r1(gMax)}`);
console.log('→ g=126 → 交互延迟 %sms(good);g=200 → %sms(poor)。' +
'长任务的 50ms 口径与 INP 的 %sms 口径不是同一把尺子。', r1(at134), r1(at200), r1(gMax));
}
// ============ rAF 切片的帧率天花板 ============
console.log('\n' + '='.repeat(78));
console.log('第六部分:rAF 分帧的帧率天花板(每片一等一整帧)');
console.log('='.repeat(78));
console.log('g(ms) 片数 帧数 帧率墙钟(ms) 相对 W 涨幅 P98输入延迟');
console.log('-'.repeat(78));
for (const g of [60, 30, 16.67, 8, 5, 1]) {
const n = slices(g);
const frames = Math.ceil(W / Math.min(g, CFG.FRAME));
const T = rafWall(g);
console.log(String(r1(g)).padEnd(7), String(n).padEnd(6),
String(frames).padEnd(6), r1(T).toString().padEnd(13),
('+' + pct(T, W) + '%').padEnd(12), r1(p98Delay(g, CFG.FRAME)));
ok(T >= W - 1e-9, 'rAF 的墙钟不应低于 W(帧内工作,间隙纯等待)');
ok(Math.abs(T - frames * CFG.FRAME) < 1e-9, 'rAF 墙钟恒等于 帧数 × FRAME');
}
ok(Math.abs(rafWall(1) - W * CFG.FRAME) < 1e-6, `g=1 时 rAF 墙钟应为 ${r1(W * CFG.FRAME)}ms`);
ok(rafWall(5) > 3 * W, 'g=5 时 rAF 墙钟应是 W 的 3 倍以上');
// g ≥ FRAME 之后帧数不再下降:帧率是硬地板
ok(rafWall(CFG.FRAME) === rafWall(100), 'g ≥ 一帧以后,rAF 墙钟不再改善');
{
const worst = rafWall(1);
console.log('\n→ g=1ms 的 rAF 切片:%d 帧 × %sms = %sms,是 W 的 %s 倍。' +
'分帧的墙钟下限是"帧数 × 一帧",不是"W + 开销"。',
Math.round(W), r1(CFG.FRAME), r1(worst), r1(worst / W));
ok(worst / W > 16, `g=1 的 rAF 墙钟应是 W 的 16 倍以上(实测 ${r1(worst / W)}×)`);
}
// ============ 一张决策表 ============
console.log('\n' + '='.repeat(78));
console.log('第七部分:四个量一起看(原语 postTask, o=0.15ms)');
console.log('='.repeat(78));
console.log('g(ms) 片数 墙钟(ms) 涨幅 P98输入延迟 P98交互延迟 判定 还有长任务吗');
console.log('-'.repeat(78));
for (const g of [1215, 200, 126, 100, 50, 20, 5, 1]) {
const T = wall(g, 0.15), L = inpOf(g, 0.15);
const longTask = g > 50;
console.log(String(g).padEnd(7), String(slices(g)).padEnd(6),
r1(T).toString().padEnd(10), ('+' + pct(T, W) + '%').padEnd(9),
r1(p98Delay(g, 0.15)).toString().padEnd(12),
r1(L).toString().padEnd(12),
(L <= GOOD ? 'good' : 'poor').padEnd(7),
longTask ? '有(>50ms)' : '无');
}
console.log('\n注:长任务按"单片本身是否 >50ms"判定(handler 会叠加在片上另算);INP 按 P98 交互延迟是否超 200ms。');
// ============ 参数扫描:保序结论是否只在我的这组参数下成立 ============
console.log('\n' + '='.repeat(78));
console.log('第八部分:27 组参数扫描(handler × 呈现 × 工作量)下的保序结论');
console.log('='.repeat(78));
{
const HANDLERS = [10, 40, 100];
const PRESENTS = [5, 20, 60];
const WORKS = [300, 1215, 4000];
const lat = (g, o, h, p, w) => (0.98 * g - 0.02 * o) + h + (0.98 * CFG.FRAME + p);
let combos = 0;
let gMaxLo = Infinity, gMaxHi = -Infinity;
const NS_BEFORE = NS;
for (const h of HANDLERS) {
for (const p of PRESENTS) {
for (const w of WORKS) {
combos++;
// 结论一:g 的 good 上限 = (200 − h − 呈现P98)/0.98,与 W 无关
const gm = (GOOD - h - (0.98 * CFG.FRAME + p)) / 0.98;
gMaxLo = Math.min(gMaxLo, gm); gMaxHi = Math.max(gMaxHi, gm);
ok(Math.abs(lat(gm, 0.15, h, p, w) - GOOD) < 0.02,
`组合(h=${h},p=${p},w=${w}): g 取 $good 上限时延迟应恰为 200(实测 ${r2(lat(gm, 0.15, h, p, w))})`);
// 结论二:g 减半 → P98 输入延迟减半(精确式 2·P98(g/2) = P98(g) − 0.02·o;与 h/p/w 都无关)
ok(Math.abs(2 * p98Delay(gm / 2, 0.15) - (p98Delay(gm, 0.15) - 0.02 * 0.15)) < 1e-9,
'g 减半应使 P98 输入延迟减半(差 0.02·o)');
ok(Math.abs(p98Delay(gm / 2, 0.15) / p98Delay(gm, 0.15) - 0.5) < 0.01,
'g 减半时 P98 输入延迟应在 0.5 倍的 1% 以内');
// 结论三:墙钟恒等式对任意 W 成立,且在 W=1215 时与模块级 wall() 完全一致
ok(Math.abs(wallOf(w, gm, 0.15) - (w + (Math.ceil(w / gm) - 1) * 0.15)) < 1e-9,
`墙钟恒等式在扫描中应恒成立(w=${w})`);
ok(Math.abs(wallOf(1215, gm, 0.15) - wall(gm, 0.15)) < 1e-9,
'参数化墙钟在 W=1215 时应与模块级函数一致');
ok(wallOf(w, gm, 0.15) >= w - 1e-9, `扫描中墙钟不应少于工作量(w=${w})`);
// 结论四:h + 呈现越大,good 上限越小(单调)
if (h === 100 && p === 60) ok(gm > 0 && gm < 30,
`handler+呈现吃掉 176.3ms 时,g 的余量应不足 30ms(实测 ${r2(gm)}ms)`);
}
}
}
console.log('扫描组合数 %d,扫描内断言 %d 条;g 的 good 上限范围 [%sms, %sms]',
combos, NS - NS_BEFORE, r2(gMaxLo), r2(gMaxHi));
console.log('→ 三条恒等式与 g 的 good 上限公式在全部 %d 种参数组合下都成立。', combos);
console.log('→ 当 handler + 呈现已经吃掉 176.3ms 时,g 的余量只剩 %sms ------ ' +
'此时再怎么切也救不了,必须回去减 handler 与重渲染范围。', r2(gMaxLo));
ok(combos === 27, '扫描组合数应为 27');
}
console.log(`\n全部断言通过(共 ${NS} 条)。`);
text
==============================================================================
第一部分:两个分布公式的模拟校验(W=1215ms, 200000 次采样)
==============================================================================
g o 解析mean 模拟mean 解析p98 模拟p98 解析max = g
------------------------------------------------------------------------------
1215 0.15 607.43 607.59 1190.7 1190.83 1215 1214.99
100 0.15 49.93 50.01 98 97.91 100 100
50 0.15 24.93 24.95 49 48.99 50 50
20 0.15 9.93 9.93 19.6 19.61 20 20
5 0.15 2.43 2.43 4.9 4.9 5 5
1 0.15 0.43 0.43 0.98 0.98 1 1
50 4 23.15 23.15 48.92 48.91 50 50
20 4 8.33 8.33 19.52 19.52 20 20
5 4 1.39 1.39 4.82 4.82 5 5
1 4 0.1 0.1 0.9 0.9 1 1
→ 同一个 g=5ms:o=4.0 的平均输入延迟只有 o=0.15 的 0.57 倍(间隙算空闲窗口),但 P98 几乎不动(0.98×)。加长间隙买不到尾部的改善。
==============================================================================
第二部分:切片粒度 g × yield 原语 → 墙钟代价与 P98 交互延迟
==============================================================================
g(ms) 原语 片数 墙钟(ms) 占空比 墙钟涨幅 P98输入延迟 P98交互延迟 判定
------------------------------------------------------------------------------
1215 postTask 1 1215 100% +0% 1190.7 1267 poor
1215 yield 1 1215 100% +0% 1190.7 1267 poor
1215 timeout 1 1215 100% +0% 1190.6 1267 poor
1215 raf 1 1216.7 99.9% +0.1% 1190.4 1266.7 poor
200 postTask 7 1215.9 99.9% +0.1% 196 272.3 poor
200 timeout 7 1239 98.1% +2% 195.9 272.3 poor
126 postTask 10 1216.4 99.9% +0.1% 123.5 199.8 good
126 timeout 10 1251 97.1% +3% 123.4 199.7 good
100 postTask 13 1216.8 99.9% +0.1% 98 174.3 good
100 timeout 13 1263 96.2% +4% 97.9 174.3 good
50 postTask 25 1218.6 99.7% +0.3% 49 125.3 good
50 timeout 25 1311 92.7% +7.9% 48.9 125.3 good
20 postTask 61 1224 99.3% +0.7% 19.6 95.9 good
20 timeout 61 1455 83.5% +19.8% 19.5 95.9 good
5 postTask 243 1251.3 97.1% +3% 4.9 81.2 good
5 timeout 243 2183 55.7% +79.7% 4.8 81.2 good
1 postTask 1215 1397.1 87% +15% 1 77.3 good
1 timeout 1215 6071 20% +399.7% 0.9 77.2 good
==============================================================================
第三部分:g 与 o 的交换率(把 P98 输入延迟从 g 压到 g/2 的价钱)
==============================================================================
原语 o 从 100ms 切到 50ms 多花 从 50ms 切到 5ms 多花 每省 1ms 延迟的价格
------------------------------------------------------------------------------
postTask 0.15 +1.8ms +32.7ms 0.74 ms 墙钟/ms 延迟
yield 0.1 +1.2ms +21.8ms 0.49 ms 墙钟/ms 延迟
timeout 4 +48ms +872ms 19.77 ms 墙钟/ms 延迟
raf 16.666666666666668 +0ms +2833.3ms 64.25 ms 墙钟/ms 延迟
==============================================================================
第四部分:给定墙钟预算,各原语能切到的最细粒度 g* = o/(预算倍数-1)
==============================================================================
预算倍数 postTask yield timeout raf
------------------------------------------------------------------------------
×1.05 3 2 76 16
×1.10 1.5 1 39.2 15.2
×1.30 0.5 0.4 13.3 13
×1.50 0.3 0.2 8 11.2
×2.00 0.2 0.1 4 8.4
×3.00 0.1 0.1 2 5.6
→ 同一个 +30% 墙钟预算:rAF 只能切到 13ms,setTimeout 13.3ms,postTask 0.5ms,scheduler.yield 0.35ms ------ 最粗与最细差 25.9 倍。
→ 加性原语的闭式:g* = o/(M−1)(与 W 无关);rAF 的闭式:g* = FRAME/M(与 o 无关,因为 o 恒等于一帧)。
==============================================================================
第五部分:INP 的 good 门槛换算成"最长任务"预算
==============================================================================
交互延迟三段:输入延迟 + 处理 40ms + 呈现 36.33ms = 地板 76.3ms
INP good ≤ 200ms → 0.98·g ≤ 123.7ms → g ≤ 126.2ms
→ g=126 → 交互延迟 199.8ms(good);g=200 → 272.3ms(poor)。长任务的 50ms 口径与 INP 的 126.2ms 口径不是同一把尺子。
==============================================================================
第六部分:rAF 分帧的帧率天花板(每片一等一整帧)
==============================================================================
g(ms) 片数 帧数 帧率墙钟(ms) 相对 W 涨幅 P98输入延迟
------------------------------------------------------------------------------
60 21 73 1216.7 +0.1% 58.5
30 41 73 1216.7 +0.1% 29.1
16.7 73 73 1216.7 +0.1% 16
8 152 152 2533.3 +108.5% 7.5
5 243 243 4050 +233.3% 4.6
1 1215 1215 20250 +1566.7% 0.6
→ g=1ms 的 rAF 切片:1215 帧 × 16.7ms = 20250ms,是 W 的 16.7 倍。分帧的墙钟下限是"帧数 × 一帧",不是"W + 开销"。
==============================================================================
第七部分:四个量一起看(原语 postTask, o=0.15ms)
==============================================================================
g(ms) 片数 墙钟(ms) 涨幅 P98输入延迟 P98交互延迟 判定 还有长任务吗
------------------------------------------------------------------------------
1215 1 1215 +0% 1190.7 1267 poor 有(>50ms)
200 7 1215.9 +0.1% 196 272.3 poor 有(>50ms)
126 10 1216.4 +0.1% 123.5 199.8 good 有(>50ms)
100 13 1216.8 +0.1% 98 174.3 good 有(>50ms)
50 25 1218.6 +0.3% 49 125.3 good 无
20 61 1224 +0.7% 19.6 95.9 good 无
5 243 1251.3 +3% 4.9 81.2 good 无
1 1215 1397.1 +15% 1 77.3 good 无
注:长任务按"单片本身是否 >50ms"判定(handler 会叠加在片上另算);INP 按 P98 交互延迟是否超 200ms。
==============================================================================
第八部分:27 组参数扫描(handler × 呈现 × 工作量)下的保序结论
==============================================================================
扫描组合数 27,扫描内断言 165 条;g 的 good 上限范围 [24.15ms, 172.11ms]
→ 三条恒等式与 g 的 good 上限公式在全部 27 种参数组合下都成立。
→ 当 handler + 呈现已经吃掉 176.3ms 时,g 的余量只剩 24.15ms ------ 此时再怎么切也救不了,必须回去减 handler 与重渲染范围。
全部断言通过(共 363 条)。
2.2 工具二:把 INP 拆成三段、再拆成一个计数预算
inp-anatomy.mjs 有两个用途,第二个才是重点。
第一个是分段 :给定页面的 handler 成本与重渲染节点数,算出 输入延迟 : 处理 : 呈现 的比例,并给出四项修法各自的 ΔINP。它的价值在于可加性------三项修法的 Δ 之和恰好等于叠加后的 Δ(脚本有断言),所以你可以把预算按段分配,而不必靠直觉。
第二个是把 p98 翻译成计数预算 :N − ⌈0.98N⌉ = ⌊0.02N⌋。这条换算把"INP 超标"从一句感受变成一个可执行的验收条件:一次访问里有 N 次交互时,超过 200ms 的最多只能有 ⌊2%·N⌋ 次。 N=100 时是 2 次。
脚本里我特意保留了两种分位口径(nearest-rank 与线性插值)并排列出,因为它们在 N≈50 处会分歧------0.98 × 50 = 48.999...,ceil 得 49,"最差那一次"的边界恰好在 N=49,不是 50。这类细节不值得写进验收文档,但值得知道:引用 p98 时,"第几差"这个说法只在 N 给定之后才有意义。
js
#!/usr/bin/env node
'use strict';
/*
* inp-anatomy.mjs ------ INP 的三段解剖与分位数账
*
* 零依赖、确定性。回答五个问题:
* 1. 一次访问里有 N 个交互时,INP 落在"第几差"的那一次上?修掉最差的那一次,INP 会动吗?
* 2. 交互延迟 = 输入延迟 + 处理时间 + 呈现延迟,三段各由什么决定?
* 3. 同一个"切片"技术,为什么对水合有效、对 handler 有害?
* 4. 呈现延迟的地板是多少?为什么 INP 压不到 0?
* 5. 三类页面(内容/交易/重水合 SPA)的主导相位不同,修法优先级怎么排?
*
* 断言失败即 exit 1。
*/
const CFG = {
FRAME: 1000 / 60, // ms
PAINT_PER_NODE: 0.012, // ms/节点(与 Day28 同一套单位成本)
HANDLER_BASE: 145, // ms 一次交互 handler 的净处理时间(重渲染一个列表)
HANDLER_MIN: 8, // ms 一个"已经很便宜"的 handler
YIELD_COST: 0.15, // ms 一次 yield 的代价(postTask / MessageChannel 量级)
NODES: 9000, // 页面 DOM 节点(Day28 同一模型)
CHANGED: 9000, // 一次交互引发的重渲染节点数(整页重渲染)→ 108.0ms 绘制
BOOT_W: 1215, // ms 主线程启动工作(Day28 的水合账)
GOOD: 200, // INP good 阈值
POOR: 500, // INP poor 阈值
N_SESSION: 100, // 一次访问里的交互数(重交互页面)
};
let NS = 0;
const ok = (cond, msg) => { NS++; if (!cond) throw new Error('断言失败: ' + msg); };
const r1 = (x) => Math.round(x * 10) / 10;
const r2 = (x) => Math.round(x * 100) / 100;
const pctOf = (a, b) => r1((a / b - 1) * 100);
// ============ 两种分位数口径 ============
// ① nearest-rank:p98 = 第 ceil(0.98N) 名(1-based)
const nearestRank = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.ceil(p * sorted.length) - 1)];
// ② 线性插值:index = (N-1)·p
function interpolated(sorted, p) {
const idx = (sorted.length - 1) * p;
const lo = Math.floor(idx), hi = Math.ceil(idx);
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
}
console.log('='.repeat(80));
console.log('第一部分:INP 落在"第几差"的交互上?修掉最差那一次,指标会动吗?');
console.log('='.repeat(80));
console.log('N(交互数) p98 名次(nearest-rank) 从最差数第几名 p98 名次(插值) INP=最差?');
console.log('-'.repeat(80));
for (const N of [5, 10, 20, 30, 50, 60, 80, 100, 150, 200, 500]) {
const rank = Math.ceil(0.98 * N); // 1-based,越大越差
const fromWorst = N - rank + 1; // 1 = 就是最差那一次
const ip = Math.ceil((N - 1) * 0.98) + 1;
console.log(String(N).padEnd(10), String(rank).padEnd(23),
String(fromWorst).padEnd(16), String(ip).padEnd(15),
fromWorst === 1 ? '是' : '否');
ok(rank >= 1 && rank <= N, `名次应在 [1,N](N=${N})`);
}
// 结论断言(自测修正:0.98×50 = 48.999...,ceil 得 49,所以"最差"的边界在 N=49 而不是 50)
ok(Math.ceil(0.98 * 49) === 49, 'N=49 时 p98 名次应恰为最差那一次');
ok(Math.ceil(0.98 * 50) === 49, 'N=50 时 nearest-rank 落在第 49 名(第 2 差)');
ok(Math.ceil(0.98 * 100) === 98, 'N=100 时 p98 名次应为第 98 名(即第 3 差)');
ok(Math.ceil(0.98 * 20) === 20, 'N=20 时 p98 就是最差那一次');
// 两种口径在 N≈50 处分歧:nearest-rank 给第 2 差,插值给最差
ok(Math.ceil((50 - 1) * 0.98) + 1 === 50, '插值口径在 N=50 时落在最差那一次');
ok(0.98 * 50 < 50, '0.98×50 略小于 50(浮点下 ceil 得 49)');
// ============ 一次真实会话的交互样本 ============
function lcg(seed) {
let s = seed >>> 0;
return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = lcg(20261017);
const INTERACTIONS = [];
// 88% 轻交互(55~130ms)、8% 中交互(150~260ms)、4% 重交互(300~800ms)
for (let i = 0; i < 88; i++) INTERACTIONS.push({ kind: 'light', ms: 55 + rand() * 75 });
for (let i = 0; i < 8; i++) INTERACTIONS.push({ kind: 'medium', ms: 150 + rand() * 110 });
for (let i = 0; i < 4; i++) INTERACTIONS.push({ kind: 'heavy', ms: 300 + rand() * 500 });
INTERACTIONS.sort((a, b) => a.ms - b.ms);
const values = (list) => list.map((x) => x.ms);
const inpNearest = (list) => nearestRank(values(list).slice().sort((a, b) => a - b), 0.98);
const inpInterp = (list) => interpolated(values(list).slice().sort((a, b) => a - b), 0.98);
const meanOf = (list) => values(list).reduce((a, b) => a + b, 0) / list.length;
console.log('\n' + '='.repeat(80));
console.log('第二部分:一次 %d 个交互的会话,均值看起来很健康', CFG.N_SESSION);
console.log('='.repeat(80));
const v = values(INTERACTIONS);
{
const mean = meanOf(INTERACTIONS);
const p50 = nearestRank(v, 0.50), p75 = nearestRank(v, 0.75), p90 = nearestRank(v, 0.90);
const p98n = inpNearest(INTERACTIONS), p98i = inpInterp(INTERACTIONS), max = v[v.length - 1];
console.log('均值 %sms p50 %sms p75 %sms p90 %sms p98 %sms(nearest) / %sms(插值) 最差 %sms',
r1(mean), r1(p50), r1(p75), r1(p90), r1(p98n), r1(p98i), r1(max));
console.log('判定(%dms 阈值): 均值 %s / p75 %s / p98 %s',
CFG.GOOD, mean <= CFG.GOOD ? 'good' : 'poor', p75 <= CFG.GOOD ? 'good' : 'poor',
p98n <= CFG.GOOD ? 'good' : 'poor');
ok(mean < CFG.GOOD, `均值应低于 good 阈值(实测 ${r1(mean)})`);
ok(p98n > CFG.POOR, `P98 应进入 poor 区(实测 ${r1(p98n)})`);
ok(p98n / mean > 3, `P98 与均值的差距应超过 3 倍(实测 ${r1(p98n / mean)}×)`);
ok(p98n >= p90 && p90 >= p75 && p75 >= p50, '分位数必须单调不减');
console.log('→ 同一份数据:均值 %sms(比 good 阈值还低 %sms),P98 %sms(poor)。' +
'INP 不看均值,所以"我们平均只有 %sms"这句话说明不了任何事。',
r1(mean), r1(CFG.GOOD - mean), r1(p98n), r1(mean));
}
console.log('\n' + '='.repeat(80));
console.log('第三部分:修最差的那一次,指标动吗?(N=100,p98 = 第 3 差)');
console.log('='.repeat(80));
console.log('修掉的"最差 k 次" P98(nearest) Δ指标 P98(插值) Δ指标 等价于原来的第几差');
console.log('-'.repeat(80));
{
const sorted0 = values(INTERACTIONS).slice().sort((a, b) => a - b);
const rankOf = (sorted, p, x) => sorted.filter((y) => y <= x + 1e-9).length; // x 在新表里的名次
let prevN = inpNearest(INTERACTIONS), prevI = inpInterp(INTERACTIONS);
for (let k = 0; k <= 4; k++) {
// "修复":把最差的 k 次降到 120ms(N 不变)
const fixed = INTERACTIONS.slice(0, INTERACTIONS.length - k)
.concat(Array.from({ length: k }, () => ({ kind: 'fixed', ms: 120 })));
const n = inpNearest(fixed), i = inpInterp(fixed);
const origRank = rankOf(sorted0, 0.98, n);
console.log(String(k).padEnd(19), r1(n).toString().padEnd(13),
(k === 0 ? '---' : (r1(n - prevN) >= 0 ? '+' : '') + r1(n - prevN)).padEnd(9),
r1(i).toString().padEnd(11),
(k === 0 ? '---' : (r1(i - prevI) >= 0 ? '+' : '') + r1(i - prevI)).padEnd(8),
k === 0 ? '---' : `原第 ${sorted0.length - origRank + 1} 差`);
if (k > 0) ok(n <= prevN + 1e-9, `修复最差 ${k} 次,P98 不应变差`);
if (k === 1) ok(Math.abs(n - sorted0[sorted0.length - 4]) < 1e-9,
'修复最差 1 次后,P98 应恰好等于原来的第 4 差');
prevN = n; prevI = i;
}
const fixedAll = INTERACTIONS.slice(0, 96).concat(Array.from({ length: 4 }, () => ({ kind: 'fixed', ms: 120 })));
ok(inpNearest(fixedAll) > CFG.GOOD,
`4 个重交互全修到 120ms 后,P98 仍应 > good 阈值(实测 ${r1(inpNearest(fixedAll))})`);
console.log('\n→ 修复最差 1 次:P98 从 %sms 滑到 %sms(= 原第 4 差)。' +
'它不是"不变",而是"换一个人顶上"------你把第 1 名踢下去了,第 4 名站到了第 3 名的位置上。',
r1(inpNearest(INTERACTIONS)), r1(inpNearest(INTERACTIONS.slice(0, 99).concat([{ kind: 'fixed', ms: 120 }]))));
console.log('→ 4 个重交互全部修到 120ms:P98 变成 %sms,依然 poor------因为第 3 差换成了中交互的尾部。',
r1(inpNearest(fixedAll)));
}
{
// "删除"(N 减少)与"修复"(N 不变)是两件事:删掉最差 1 次,nearest-rank 下 P98 精确不变
const dropped = INTERACTIONS.slice(0, INTERACTIONS.length - 1);
const before = inpNearest(INTERACTIONS), after = inpNearest(dropped);
ok(Math.abs(before - after) < 1e-9, `删掉最差 1 次,P98 应精确不变(${r1(after)} vs ${r1(before)})`);
console.log('→ 但如果是删掉最差那一次(N 从 100 变 99):P98 从 %sms 变成 %sms(精确不变)。' +
'"少了一次采样"和"少了一段耗时"在 p98 里不是同一件事。', r1(before), r1(after));
}
console.log('\n' + '='.repeat(80));
console.log('第四部分:把 p98 换成一个"计数预算"------这是本篇最重要的一次重构');
console.log('='.repeat(80));
console.log('N(交互数) p98 名次 允许超过 200ms 的交互数 = N - ceil(0.98N) floor(0.02N)');
console.log('-'.repeat(80));
for (const N of [20, 30, 50, 60, 100, 150, 200, 500, 1000]) {
const rank = Math.ceil(0.98 * N);
const budget = N - rank;
console.log(String(N).padEnd(10), String(rank).padEnd(11),
String(budget).padEnd(45), Math.floor(0.02 * N));
ok(budget === Math.floor(0.02 * N), `配额应等于 floor(0.02N)(N=${N})`);
ok(budget >= 0, '配额不应为负');
}
ok(Math.ceil(0.98 * 100) === 98, 'N=100 时名次为 98');
ok(100 - Math.ceil(0.98 * 100) === 2, 'N=100 时允许 2 次超过阈值');
console.log('\n→ "INP ≤ 200ms" 的真实含义是"超过 200ms 的交互不超过 floor(2%·N) 次"。它是一个计数预算,' +
'不是一个时长预算。 N=100 的会话里,你有 2 个名额。');
{
// 两个反直觉的会话:同样的"最差交互",完全不同的判定
const mk = (arr) => arr.map((ms) => ({ kind: 'x', ms })).sort((a, b) => a.ms - b.ms);
// 会话甲:99 次 ≤150ms + 1 次 5000ms 灾难
const A = mk([...Array.from({ length: 99 }, (_, i) => 60 + (i % 90)), 5000]);
// 会话乙:97 次 60ms + 3 次 250ms 小慢
const B = mk([...Array.from({ length: 97 }, () => 60), 250, 250, 250]);
const ia = inpNearest(A), ib = inpNearest(B);
console.log('会话甲:99 次 ≤149ms + 1 次 5000ms → P98 = %sms → %s', r1(ia), ia <= CFG.GOOD ? 'good' : 'poor');
console.log('会话乙:97 次 60ms + 3 次 250ms → P98 = %sms → %s', r1(ib), ib <= CFG.GOOD ? 'good' : 'poor');
ok(ia <= CFG.GOOD, `单次 5000ms 灾难但只占 1 个名额时应判 good(实测 ${r1(ia)})`);
ok(ib > CFG.GOOD, `3 次 250ms 占满名额时应判 poor(实测 ${r1(ib)})`);
ok(ia < ib, '甲的最差交互(5000ms)远大于乙(250ms),但甲的指标更好');
console.log('\n→ 甲的最差交互是乙的 20 倍,指标却从 poor 变 good。' +
'因为 INP 数的是"越过阈值的次数",不是"越过了多少"。');
console.log('→ 这直接推翻"先修最慢的那一次":一个 5 秒的卡顿只吃掉 1 个名额,' +
'而三次 250ms 的小慢会把名额吃光。该优先修的,是"刚超过 200ms 且数量最多"的那一批。');
console.log('→ 反过来说,单次灾难体验(5000ms)在 INP 里几乎静音------所以 INP 必须与 LoAF/长任务监控一起看,' +
'否则你会漏掉用户真正暴怒的那一刻。');
}
// ============ 三段解剖 ============
const inputDelay = (g, o = CFG.YIELD_COST) => 0.98 * g - 0.02 * o; // 切片粒度决定(见 slice-budget.mjs)
const presentDelay = (nodes) => 0.98 * CFG.FRAME + nodes * CFG.PAINT_PER_NODE; // P98 口径:帧量化 + 绘制
const latency = ({ g = CFG.BOOT_W, handler = CFG.HANDLER_BASE, nodes = CFG.CHANGED }) =>
inputDelay(g) + handler + presentDelay(nodes);
console.log('\n' + '='.repeat(80));
console.log('第五部分:交互延迟的三段(P98 口径)');
console.log('='.repeat(80));
const BASE = { g: CFG.BOOT_W, handler: CFG.HANDLER_BASE, nodes: CFG.CHANGED };
{
const d = inputDelay(BASE.g), p = BASE.handler, s = presentDelay(BASE.nodes);
const L = d + p + s;
console.log('输入延迟 %sms(切片 g=%dms) + 处理时间 %sms + 呈现延迟 %sms(一帧×0.98 + %d×%sms) = %sms',
r1(d), BASE.g, r1(p), r1(s), BASE.nodes, CFG.PAINT_PER_NODE, r1(L));
console.log('三段占比:%s%% : %s%% : %s%%',
r1(d / L * 100), r1(p / L * 100), r1(s / L * 100));
ok(Math.abs(d + p + s - L) < 1e-9, '三段之和应等于交互延迟');
ok(d / L > 0.7, `阻塞型页面的输入延迟应占七成以上(实测 ${r1(d / L * 100)}%)`);
ok(s >= CFG.FRAME, `呈现延迟应不低于一帧(实测 ${r1(s)} vs ${r1(CFG.FRAME)})`);
}
console.log('\n' + '='.repeat(80));
console.log('第六部分:四项修法各自动了哪一段,ΔINP 是多少');
console.log('='.repeat(80));
const FIXES = [
{ key: 'A', name: '启动工作切片 g:1215→50ms', apply: (x) => ({ ...x, g: 50 }) },
{ key: 'B', name: 'handler 变便宜 145→45ms', apply: (x) => ({ ...x, handler: 45 }) },
{ key: 'C', name: '重渲染范围 9000→2000 节点(只画可视区)', apply: (x) => ({ ...x, nodes: 2000 }) },
{ key: 'D', name: '仅把 DOM 总量减到 3000 节点(未减重渲染范围)', apply: (x) => x },
];
console.log('修法 Δ输入延迟 Δ处理 Δ呈现 ΔINP 修后判定');
console.log('-'.repeat(80));
const L0 = latency(BASE);
for (const f of FIXES) {
const y = f.apply(BASE);
const dd = inputDelay(y.g) - inputDelay(BASE.g);
const dp = y.handler - BASE.handler;
const ds = presentDelay(y.nodes) - presentDelay(BASE.nodes);
const dL = latency(y) - L0;
console.log(f.name.padEnd(44),
r1(dd).toString().padEnd(10), r1(dp).toString().padEnd(7), r1(ds).toString().padEnd(7),
r1(dL).toString().padEnd(8), r1(latency(y)) + (latency(y) <= CFG.GOOD ? ' good' : ' poor'));
ok(Math.abs(dd + dp + ds - dL) < 1e-9, `${f.key}: ΔINP 应等于三段变化之和`);
}
// 相加性:四项(D 无效果)叠加 → 总和
{
const all = { g: 50, handler: 45, nodes: 2000 };
const dL = latency(all) - L0;
const sumFix = ['A', 'B', 'C'].reduce((acc, k) => {
const f = FIXES.find((x) => x.key === k);
return acc + (latency(f.apply(BASE)) - L0);
}, 0);
ok(Math.abs(dL - sumFix) < 1e-9, `三项修法叠加的 ΔINP 应等于各自 Δ 之和(${r1(dL)} vs ${r1(sumFix)})`);
console.log('\n三项叠加:%sms → %sms(Δ %sms),判定 %s。且 %sms = %s 三项之和(可加性成立)。',
r1(L0), r1(latency(all)), r1(dL), latency(all) <= CFG.GOOD ? 'good' : 'poor', r1(dL), r1(sumFix));
// 只做 C(渲染)在阻塞型页面上几乎看不到效果
const onlyC = latency(FIXES[2].apply(BASE));
ok(Math.abs(pctOf(onlyC, L0)) < 10, `阻塞型页面只减重渲染范围,INP 变化应小于 10%(实测 ${pctOf(onlyC, L0)}%)`);
console.log('只做 C(减重渲染范围):INP 只改善 %s%% ------ 而它是最容易做的那一项。' +
'先看主导相位,再选修法。', r1(-pctOf(onlyC, L0)));
}
// ============ handler 切片的反效果 ============
console.log('\n' + '='.repeat(80));
console.log('第七部分:同一把刀,切"别人的活"有用,切"自己的 handler"有害');
console.log('='.repeat(80));
console.log('handler 切成 k 片 处理时间(ms) 交互延迟(ms) 相对不切 说明');
console.log('-'.repeat(80));
{
const H = 120, o = CFG.YIELD_COST;
const base = inputDelay(50) + H + presentDelay(CFG.CHANGED);
for (const k of [1, 2, 4, 8, 16]) {
const proc = H + (k - 1) * o; // 片间 yield 全部计入本次交互
const L = inputDelay(50) + proc + presentDelay(CFG.CHANGED);
console.log(String(k).padEnd(18), r1(proc).toString().padEnd(13),
r1(L).toString().padEnd(13), ('+' + r1(L - base) + 'ms').padEnd(10),
k === 1 ? '不切' : `多付 ${k - 1} 次 yield`);
if (k > 1) ok(L > base - 1e-9, `切 ${k} 片不应降低本次交互的延迟`);
}
const k16 = H + 15 * o;
ok(k16 > H, '切 16 片的处理时间应大于 H');
console.log('\n→ 把 120ms 的 handler 切 16 片:处理时间 %sms → %sms(+%sms)。' +
'因为 INP 统计的是"这次交互从输入到绘制完成",切片只是把等待从"别人"挪到"自己"身上。',
r1(H), r1(k16), r1(k16 - H));
console.log('→ 但同一个技术用于启动水合(不在被测交互里):输入延迟 1190.7ms → %sms。同一把刀,两个方向。',
r1(inputDelay(50)));
}
// ============ 呈现延迟的地板 ============
console.log('\n' + '='.repeat(80));
console.log('第八部分:呈现延迟的地板(为什么 INP 有个压不下去的底)');
console.log('='.repeat(80));
console.log('重渲染节点 绘制(ms) 呈现P98(ms) INP 地板 = 0 + 8 + 呈现P98');
console.log('-'.repeat(80));
for (const nodes of [0, 83, 333, 1667, 9000]) {
const s = presentDelay(nodes);
const floor = 0 + CFG.HANDLER_MIN + s;
console.log(String(nodes).padEnd(12), r1(nodes * CFG.PAINT_PER_NODE).toString().padEnd(10),
r1(s).toString().padEnd(13), r1(floor));
ok(s >= 0.98 * CFG.FRAME - 1e-9, '呈现延迟 P98 不应低于一帧(帧量化不可省)');
}
{
const zero = presentDelay(0);
ok(Math.abs(zero - 0.98 * CFG.FRAME) < 1e-9, '零节点时呈现延迟 P98 恰为一帧 × 0.98');
console.log('\n→ 即使一个节点都不重渲染、handler 只要 8ms,INP 地板仍是 %sms(一帧×0.98 + 8ms)。' +
'帧量化是硬的:你的交互不可能在一帧内被"看到"。', r1(CFG.HANDLER_MIN + zero));
}
// ============ 三类页面的主导相位 ============
console.log('\n' + '='.repeat(80));
console.log('第九部分:三类页面的主导相位不同 → 修法优先级不同');
console.log('='.repeat(80));
const ARCH = [
{ name: '内容站(服务端渲染 + 少量交互)', x: { g: 20, handler: 30, nodes: 9000 } },
{ name: '交易站(表格 + 大列表交互)', x: { g: 50, handler: 180, nodes: 4000 } },
{ name: '重水合 SPA(全量水合)', x: { g: 1215, handler: 145, nodes: 9000 } },
];
console.log('页面类型 输入延迟 处理 呈现 合计 主导相位 先修什么');
console.log('-'.repeat(80));
for (const a of ARCH) {
const d = inputDelay(a.x.g), p = a.x.handler, s = presentDelay(a.x.nodes);
const L = d + p + s;
const segs = [{ k: '输入延迟', v: d }, { k: '处理时间', v: p }, { k: '呈现延迟', v: s }].sort((m, n) => n.v - m.v);
const lead = segs[0].k;
const advice = lead === '输入延迟' ? '切启动任务' : lead === '处理时间' ? '拆 handler 逻辑/移出同步路径' : '减重渲染范围';
console.log(a.name.padEnd(29), r1(d).toString().padEnd(10), r1(p).toString().padEnd(7),
r1(s).toString().padEnd(7), r1(L).toString().padEnd(7), lead.padEnd(10), advice);
ok(Math.abs(d + p + s - L) < 1e-9, '三段之和恒等于延迟');
if (lead === '输入延迟') ok(d > L * 0.4, `${a.name}: 输入延迟主导时占比应 > 40%`);
}
console.log('\n→ 同样是"INP 超标",三张页面的答案完全不同:内容站修呈现、交易站修 handler、' +
'重水合 SPA 修输入延迟。先分段,再选刀。');
console.log(`\n全部断言通过(共 ${NS} 条)。`);
text
================================================================================
第一部分:INP 落在"第几差"的交互上?修掉最差那一次,指标会动吗?
================================================================================
N(交互数) p98 名次(nearest-rank) 从最差数第几名 p98 名次(插值) INP=最差?
--------------------------------------------------------------------------------
5 5 1 5 是
10 10 1 10 是
20 20 1 20 是
30 30 1 30 是
50 49 2 50 否
60 59 2 59 否
80 79 2 79 否
100 98 3 99 否
150 147 4 148 否
200 196 5 197 否
500 490 11 491 否
================================================================================
第二部分:一次 100 个交互的会话,均值看起来很健康
================================================================================
均值 121.4ms p50 105.4ms p75 120.3ms p90 163.2ms p98 506.3ms(nearest) / 506.8ms(插值) 最差 662.3ms
判定(200ms 阈值): 均值 good / p75 good / p98 poor
→ 同一份数据:均值 121.4ms(比 good 阈值还低 78.6ms),P98 506.3ms(poor)。INP 不看均值,所以"我们平均只有 121.4ms"这句话说明不了任何事。
================================================================================
第三部分:修最差的那一次,指标动吗?(N=100,p98 = 第 3 差)
================================================================================
修掉的"最差 k 次" P98(nearest) Δ指标 P98(插值) Δ指标 等价于原来的第几差
--------------------------------------------------------------------------------
0 506.3 --- 506.8 --- ---
1 332.1 -174.2 335.6 -171.2 原第 4 差
2 252.7 -79.5 254.2 -81.4 原第 5 差
3 246.4 -6.2 246.5 -7.7 原第 6 差
4 229.2 -17.2 229.6 -17 原第 7 差
→ 修复最差 1 次:P98 从 506.3ms 滑到 332.1ms(= 原第 4 差)。它不是"不变",而是"换一个人顶上"------你把第 1 名踢下去了,第 4 名站到了第 3 名的位置上。
→ 4 个重交互全部修到 120ms:P98 变成 229.2ms,依然 poor------因为第 3 差换成了中交互的尾部。
→ 但如果是删掉最差那一次(N 从 100 变 99):P98 从 506.3ms 变成 506.3ms(精确不变)。"少了一次采样"和"少了一段耗时"在 p98 里不是同一件事。
================================================================================
第四部分:把 p98 换成一个"计数预算"------这是本篇最重要的一次重构
================================================================================
N(交互数) p98 名次 允许超过 200ms 的交互数 = N - ceil(0.98N) floor(0.02N)
--------------------------------------------------------------------------------
20 20 0 0
30 30 0 0
50 49 1 1
60 59 1 1
100 98 2 2
150 147 3 3
200 196 4 4
500 490 10 10
1000 980 20 20
→ "INP ≤ 200ms" 的真实含义是"超过 200ms 的交互不超过 floor(2%·N) 次"。它是一个计数预算,不是一个时长预算。 N=100 的会话里,你有 2 个名额。
会话甲:99 次 ≤149ms + 1 次 5000ms → P98 = 148ms → good
会话乙:97 次 60ms + 3 次 250ms → P98 = 250ms → poor
→ 甲的最差交互是乙的 20 倍,指标却从 poor 变 good。因为 INP 数的是"越过阈值的次数",不是"越过了多少"。
→ 这直接推翻"先修最慢的那一次":一个 5 秒的卡顿只吃掉 1 个名额,而三次 250ms 的小慢会把名额吃光。该优先修的,是"刚超过 200ms 且数量最多"的那一批。
→ 反过来说,单次灾难体验(5000ms)在 INP 里几乎静音------所以 INP 必须与 LoAF/长任务监控一起看,否则你会漏掉用户真正暴怒的那一刻。
================================================================================
第五部分:交互延迟的三段(P98 口径)
================================================================================
输入延迟 1190.7ms(切片 g=1215ms) + 处理时间 145ms + 呈现延迟 124.3ms(一帧×0.98 + 9000×0.012ms) = 1460ms
三段占比:81.6% : 9.9% : 8.5%
================================================================================
第六部分:四项修法各自动了哪一段,ΔINP 是多少
================================================================================
修法 Δ输入延迟 Δ处理 Δ呈现 ΔINP 修后判定
--------------------------------------------------------------------------------
启动工作切片 g:1215→50ms -1141.7 0 0 -1141.7 318.3 poor
handler 变便宜 145→45ms 0 -100 0 -100 1360 poor
重渲染范围 9000→2000 节点(只画可视区) 0 0 -84 -84 1376 poor
仅把 DOM 总量减到 3000 节点(未减重渲染范围) 0 0 0 0 1460 poor
三项叠加:1460ms → 134.3ms(Δ -1325.7ms),判定 good。且 -1325.7ms = -1325.7 三项之和(可加性成立)。
只做 C(减重渲染范围):INP 只改善 5.8% ------ 而它是最容易做的那一项。先看主导相位,再选修法。
================================================================================
第七部分:同一把刀,切"别人的活"有用,切"自己的 handler"有害
================================================================================
handler 切成 k 片 处理时间(ms) 交互延迟(ms) 相对不切 说明
--------------------------------------------------------------------------------
1 120 293.3 +0ms 不切
2 120.2 293.5 +0.1ms 多付 1 次 yield
4 120.5 293.8 +0.5ms 多付 3 次 yield
8 121.1 294.4 +1.1ms 多付 7 次 yield
16 122.3 295.6 +2.3ms 多付 15 次 yield
→ 把 120ms 的 handler 切 16 片:处理时间 120ms → 122.3ms(+2.3ms)。因为 INP 统计的是"这次交互从输入到绘制完成",切片只是把等待从"别人"挪到"自己"身上。
→ 但同一个技术用于启动水合(不在被测交互里):输入延迟 1190.7ms → 49ms。同一把刀,两个方向。
================================================================================
第八部分:呈现延迟的地板(为什么 INP 有个压不下去的底)
================================================================================
重渲染节点 绘制(ms) 呈现P98(ms) INP 地板 = 0 + 8 + 呈现P98
--------------------------------------------------------------------------------
0 0 16.3 24.3
83 1 17.3 25.3
333 4 20.3 28.3
1667 20 36.3 44.3
9000 108 124.3 132.3
→ 即使一个节点都不重渲染、handler 只要 8ms,INP 地板仍是 24.3ms(一帧×0.98 + 8ms)。帧量化是硬的:你的交互不可能在一帧内被"看到"。
================================================================================
第九部分:三类页面的主导相位不同 → 修法优先级不同
================================================================================
页面类型 输入延迟 处理 呈现 合计 主导相位 先修什么
--------------------------------------------------------------------------------
内容站(服务端渲染 + 少量交互) 19.6 30 124.3 173.9 呈现延迟 减重渲染范围
交易站(表格 + 大列表交互) 49 180 64.3 293.3 处理时间 拆 handler 逻辑/移出同步路径
重水合 SPA(全量水合) 1190.7 145 124.3 1460 输入延迟 切启动任务
→ 同样是"INP 超标",三张页面的答案完全不同:内容站修呈现、交易站修 handler、重水合 SPA 修输入延迟。先分段,再选刀。
全部断言通过(共 75 条)。
2.3 工具三:把"让出主线程"这件事记成一本原语账
scheduler-ledger.mjs 回答的是实现层的三个问题:用哪个函数让出、标哪一档优先级、user-blocking 能标多少。
原语那一栏的差距全部来自平台细节:setTimeout 嵌套 5 层后被钳到 4ms(HTML 规范的 timer clamping),rAF 被帧对齐锁死(一片占一帧),scheduler.yield() 的 continuation 会以更高优先级恢复,所以一串 yield 不会被别的任务按 FIFO 插队------这一条在 ρ=0.8 时值 5 倍链长(1215ms 的活会被拖到 6075ms)。
优先级那一栏的结论更值得抄在墙上:优先级买的是排队长度(40 倍),买不到当前任务的剩余时间(不可抢占) ;而通胀是线性 的(每 25% 多付 100ms,全标 410ms ≈ FIFO 的 400ms),饥饿是非线性的(ρ 0.7→0.95,拉伸 3.3×→20×)。
最后一节把它和 INP 的预算接起来:三个目标 × 三档预算的可行性矩阵。这张矩阵纠正了我自己的一个偏见------我原以为"INP 优化必须换现代原语",实跑的结果是 +5% 预算下五种原语全都能把 g 压到 126ms 以下。原语的选择只有在追求"零长任务 / 帧内任务"时才成为瓶颈。
js
#!/usr/bin/env node
'use strict';
/*
* scheduler-ledger.mjs ------ 调度器账本:原语、优先级、饥饿
*
* 零依赖、确定性。回答四个问题:
* 1. 六种"让出主线程"的原语,代价差多少?同一个墙钟预算下能切多细?
* 2. 一串 yield 会不会被别的任务插队(continuation 优先级到底值多少)?
* 3. 离散输入排在 800ms 的后台任务后面,优先级能把它提前多少?
* 4. 优先级"通胀"(把所有活都标成 user-blocking)的代价是不是线性的?
*
* 断言失败即 exit 1。
*/
const CFG = {
FRAME: 1000 / 60, // ms
CLAMP: 4, // ms setTimeout 嵌套 5 层后的下限
TASK: 20, // ms 一个后台任务的长度
TASKS: 40, // 后台任务数 → 800ms 的总工作
WORK: 20 * 40, // ms
GOOD: 200, // ms INP good
HANDLER: 40, // ms 一次交互的 handler
PAINT: 20, // ms 一次交互的呈现(帧量化均值另计)
};
let NS = 0;
const ok = (cond, msg) => { NS++; if (!cond) throw new Error('断言失败: ' + msg); };
const r1 = (x) => Math.round(x * 10) / 10;
const r2 = (x) => Math.round(x * 100) / 100;
const pctOf = (a, b) => r1((a / b - 1) * 100);
// ============ 第一部分:六种原语 ============
// gap:本次让出到下次执行之间的"不可干活"时间(ms)
// 说明:gap 期间主线程并非总是空闲可派发输入,这里按"最乐观"处理(间隙算空闲),
// 因此下面的墙钟是下限,输入延迟是上限。
const PRIMS = [
{ key: 'scheduler.yield()', gap: 0.10, rank: 0, note: 'continuation 插队恢复,链式不被打断' },
{ key: 'postTask(user-blocking)', gap: 0.15, rank: 1, note: '任务级让出,输入可插在下一个任务前' },
{ key: 'postTask(user-visible)', gap: 0.15, rank: 2, note: '同上,但排在离散输入之后' },
{ key: 'postTask(background)', gap: 0.15, rank: 3, note: '同上,最低优先级,可能被饿' },
{ key: 'MessageChannel', gap: 0.20, rank: 4, note: '无 clamp;但优先级不可声明(同级 FIFO)' },
{ key: 'setTimeout(fn, 0)', gap: CFG.CLAMP, rank: 5, note: '嵌套 5 层后 ≥4ms clamp' },
{ key: 'requestAnimationFrame', gap: CFG.FRAME, rank: 6, note: '帧对齐:一片只能等一帧' },
{ key: 'requestIdleCallback', gap: null, rank: 7, note: '只在空闲跑,有输入/动画时不跑' },
];
console.log('='.repeat(82));
console.log('第一部分:八种"让出主线程"的原语(gap 越大,墙钟越贵)');
console.log('='.repeat(82));
console.log('原语 gap(ms) 1 秒内可切片数 1.2 秒工作的墙钟 占空比');
console.log('-'.repeat(82));
const W = 1215;
for (const p of PRIMS) {
if (p.gap === null) {
console.log(p.key.padEnd(28), '空闲才跑'.padEnd(9), '---'.padEnd(17), '不可预算(取决于空闲量)', '---');
continue;
}
const perSec = 1000 / (5 + p.gap); // 以 5ms 工作片为例
const n = Math.ceil(W / 5);
const T = W + (n - 1) * p.gap;
console.log(p.key.padEnd(28), String(p.gap).padEnd(9),
r1(perSec).toString().padEnd(17), r1(T).toString().padEnd(19),
r1(W / T * 100) + '%');
ok(T >= W - 1e-9, '墙钟不应少于纯工作量');
ok(perSec > 0, '每秒片数应为正');
}
{
// 同一个 +30% 预算下的最细切片粒度(加性模型 g* = gap/(M-1))
const M = 1.3;
const additive = PRIMS.filter((p) => p.gap !== null && p.key !== 'requestAnimationFrame');
console.log('\n+30%% 墙钟预算下的最细切片 g* = gap/(M−1):');
for (const p of additive) {
const g = p.gap / (M - 1);
console.log(' %s → g* = %sms', p.key.padEnd(28), r2(g));
ok(g > 0, 'g* 应为正');
}
const y = PRIMS.find((p) => p.key === 'scheduler.yield()').gap / (M - 1);
const t = PRIMS.find((p) => p.key === 'setTimeout(fn, 0)').gap / (M - 1);
ok(t / y > 30, `setTimeout 的 g* 应是 scheduler.yield 的 30 倍以上(实测 ${r1(t / y)}×)`);
console.log(' → setTimeout 的 g* 比 scheduler.yield 粗 %s 倍。clamp 就是这个倍数的全部来源。', r1(t / y));
}
// ============ 第二部分:continuation 优先级值多少 ============
// 链式 yield:W 的工作切成 n 片。若 continuation 不被插队,链长 = W + (n-1)·gap。
// 若被同优先级任务 FIFO 插队,链长被拉长 1/(1−ρ)(ρ = 其他工作对主线程的占用率)。
console.log('\n' + '='.repeat(82));
console.log('第二部分:一串 yield 会被插队吗(ρ = 其他任务对主线程的占用率)');
console.log('='.repeat(82));
console.log('占用率 ρ 无 continuation 优先级的链长 有 continuation 优先级的链长 差');
console.log('-'.repeat(82));
const chain0 = W; // 忽略 yield 自身开销,只看插队效应
for (const rho of [0.0, 0.2, 0.4, 0.6, 0.8, 0.9]) {
const fifo = chain0 / (1 - rho);
const cont = chain0; // continuation 插队 → 不被拉长
console.log(String(rho).padEnd(10),
r1(fifo).toString().padEnd(27), r1(cont).toString().padEnd(29),
rho === 0 ? '0' : ('+' + r1(fifo - cont) + 'ms'));
ok(fifo >= cont - 1e-9, 'FIFO 链长不应短于 continuation 链长');
}
{
const rho = 0.8;
ok(Math.abs(W / (1 - rho) - 5 * W) < 1e-9, 'ρ=0.8 时 FIFO 链长应为 5 倍(=W/0.2)');
console.log('\n→ ρ=0.8 时:没有 continuation 优先级,%dms 的活要拖到 %sms(%s 倍);' +
'有它,链长仍是 %dms。这就是 scheduler.yield() 与 MessageChannel 的全部差别------不是 gap 的 0.1ms,' +
'而是"链会不会被别的任务按 FIFO 插队"。', W, r1(W / (1 - rho)), r1(1 / (1 - rho)), W);
}
// ============ 第三部分:离散输入与优先级 ============
// 队列:40 个 20ms 的后台任务(800ms)。点击在随机时刻到达。
// FIFO:要等完当前任务 + 前面排队的任务 → 均值 = 剩余工作的一半
// 优先级(输入为 user-blocking):只需等完"当前正在执行"的那一个任务 → 均值 = TASK/2
console.log('\n' + '='.repeat(82));
console.log('第三部分:40 个 20ms 的后台任务排着,点击在随机时刻到达');
console.log('='.repeat(82));
const fifoDelay = CFG.WORK / 2; // 400
const prioDelay = CFG.TASK / 2; // 10
console.log('调度方式 输入延迟均值(ms) 相对 FIFO');
console.log('-'.repeat(82));
console.log('FIFO(无优先级)'.padEnd(31), r1(fifoDelay).toString().padEnd(19), '1×');
console.log('离散输入 > 后台(正确定级)'.padEnd(31), r1(prioDelay).toString().padEnd(19),
r1(fifoDelay / prioDelay) + '× 更快');
ok(Math.abs(fifoDelay - 400) < 1e-9, 'FIFO 均值应为 400ms');
ok(Math.abs(prioDelay - 10) < 1e-9, '优先级均值应为 10ms');
ok(fifoDelay / prioDelay === 40, '收益应恰为 40 倍(= 任务数)');
console.log('\n→ 优先级把输入延迟从 %sms 压到 %sms(%s 倍)。而这个收益与"任务多长"无关,' +
'只与"排队了几个任务"有关:优先级买的是"排队的长度",不是"任务的成本"。',
r1(fifoDelay), r1(prioDelay), r1(fifoDelay / prioDelay));
// 但是:任务不可抢占 → 当前任务内的同步计算,任何优先级都救不了
{
const longTask = 200; // 一个 200ms 的同步任务
const withPrio = longTask; // 优先级不能让当前任务提前结束
console.log('→ 但任务不可抢占:如果正在跑的是一个 200ms 的同步任务,' +
'点击的输入延迟仍是 %sms ------ 优先级只决定"下一个跑谁",不决定"当前这个跑多久"。' +
'所以优先级救不了长任务,只有 yield 能。', withPrio);
ok(withPrio === longTask, '不可抢占意味着优先级不影响当前任务剩余时间');
}
console.log('\n' + '='.repeat(82));
console.log('第四部分:优先级通胀------把 25%/50%/100% 的后台活标成 user-blocking');
console.log('='.repeat(82));
// 通胀率 r:剩余任务中有 r 的比例与输入同级,按 FIFO 排在输入前面
// 输入延迟 = 0.5·TASK(当前任务剩余) + r · (剩余任务数) · TASK
console.log('通胀率 同级剩余任务(均值) 输入延迟(ms) 相对正确定级');
console.log('-'.repeat(82));
for (const r of [0, 0.25, 0.5, 0.75, 1.0]) {
const sameClass = r * (CFG.TASKS / 2); // 到达时平均还剩一半任务
const delay = CFG.TASK / 2 + sameClass * CFG.TASK;
console.log((r * 100 + '%').padEnd(9), r1(sameClass).toString().padEnd(21),
r1(delay).toString().padEnd(15), r1(delay / prioDelay) + '×');
ok(delay >= prioDelay - 1e-9, '通胀不应降低延迟');
}
{
const d100 = CFG.TASK / 2 + 1.0 * (CFG.TASKS / 2) * CFG.TASK;
ok(Math.abs(d100 - 410) < 1e-9, `全通胀时延迟应为 410ms(10 + 400),实测 ${r1(d100)}`);
ok(Math.abs(d100 - fifoDelay) <= 10 + 1e-9, '全通胀时延迟应回到 FIFO 水平(±当前任务剩余)');
// 线性性:通胀率每涨 25%,延迟涨 r·剩余·TASK = 0.25*20*20 = 100ms
const step = 0.25 * (CFG.TASKS / 2) * CFG.TASK;
ok(Math.abs(step - 100) < 1e-9, '每 25% 通胀应增加 100ms');
console.log('\n→ 通胀 0/25/50/100%%:输入延迟 %s / %s / %s / %sms。' +
'每多标 25%% 的活,线性多付 %sms。全标等于没标(%sms ≈ FIFO 的 %sms)。',
r1(prioDelay), r1(prioDelay + step), r1(prioDelay + 2 * step), r1(d100), r1(step), r1(d100), r1(fifoDelay));
console.log('→ 而"全标 user-blocking"在团队里是最常见的一种写法------因为没人愿意自己的任务是低优先级的。' +
'优先级的价值来自稀缺,人人都有份就等于没有。');
}
// ============ 第五部分:饥饿 ============
console.log('\n' + '='.repeat(82));
console.log('第五部分:background 任务的饥饿(完成时间 = W/(1−ρ))');
console.log('='.repeat(82));
console.log('占用率 ρ 后台任务完成时间(ms) 拉伸倍数 剩余可用的"缝隙"');
console.log('-'.repeat(82));
for (const rho of [0.0, 0.3, 0.5, 0.7, 0.9, 0.95]) {
const done = W / (1 - rho);
console.log(String(rho).padEnd(10), r1(done).toString().padEnd(23),
r1(done / W) + '×'.padEnd(9), r1((1 - rho) * 100) + '%');
ok(done >= W - 1e-9, '拉伸不应小于 1');
}
{
ok(Math.abs(W / 0.05 - 20 * W) < 1e-9, 'ρ=0.95 时拉伸应为 20 倍');
ok(W / 0.1 === 10 * W, 'ρ=0.9 时拉伸应为 10 倍');
console.log('\n→ ρ 从 0.7 涨到 0.95:拉伸从 %s× 变成 %s×(%s 倍)。' +
'这一栏是非线性的:越接近满载,后台任务越接近永不完成。',
r1(1 / 0.3), r1(1 / 0.05), r1((1 / 0.05) / (1 / 0.3)));
console.log('→ 所以 user-blocking 的用量必须有预算:它不是"越多越好",' +
'而是"ρ 一旦超过约 0.8,后台活就等于被删了"。');
}
// ============ 第六部分:三份账合起来的一张决策表 ============
console.log('\n' + '='.repeat(82));
console.log('第六部分:三个目标 × 两档墙钟预算 → 哪些原语真的能做到');
console.log('='.repeat(82));
// 各原语在给定预算倍数下的最细 g(数值求解,0.05ms 网格)
const addWall = (o) => (g) => W + (Math.ceil(W / g) - 1) * o;
const rafWall = (g) => Math.ceil(W / Math.min(g, CFG.FRAME)) * CFG.FRAME;
const CANDS = [
{ key: 'scheduler.yield()', wall: addWall(0.10) },
{ key: 'postTask', wall: addWall(0.15) },
{ key: 'MessageChannel', wall: addWall(0.20) },
{ key: 'setTimeout(0)', wall: addWall(CFG.CLAMP) },
{ key: 'rAF 分帧', wall: rafWall },
];
const finest = (c, M) => {
for (let g = 0.05; g <= 400; g = Math.round((g + 0.05) * 100) / 100) {
if (c.wall(g) <= M * W + 1e-9) return g;
}
return Infinity;
};
// g 的 good 门槛:INP = 0.98·g + handler + (0.98·FRAME + paint) ≤ GOOD
const OTHER = CFG.HANDLER + 0.98 * CFG.FRAME + CFG.PAINT;
const gMax = (CFG.GOOD - OTHER) / 0.98;
ok(gMax > 120 && gMax < 130, `good 门槛允许的 g 应落在 (120,130),实测 ${r1(gMax)}`);
console.log('其余两段:handler %dms + 呈现 P98 %sms = %sms → g 的上限 = %sms',
CFG.HANDLER, r1(0.98 * CFG.FRAME + CFG.PAINT), r1(OTHER), r1(gMax));
// 三个目标(都是对 g 的上限)
const GOALS = [
{ key: 'INP good(交互 ≤200ms)', g: gMax },
{ key: '无 >50ms 长任务', g: 50 },
{ key: '无 >一帧(16.7ms)的任务', g: CFG.FRAME },
];
console.log('目标 预算+5% 预算+10% 预算+30% 结论');
console.log('-'.repeat(82));
for (const goal of GOALS) {
const cells = [];
const reach = [];
for (const M of [1.05, 1.10, 1.30]) {
const able = CANDS.filter((c) => finest(c, M) <= goal.g + 1e-9).map((c) => c.key);
cells.push((able.length ? String(able.length) + '/' + CANDS.length : '0/' + CANDS.length).padEnd(8));
reach.push(able);
}
const worst = CANDS.filter((c) => !reach[2].includes(c.key)).map((c) => c.key);
console.log(goal.key.padEnd(31), cells.join(' '), worst.length ? `+30% 仍做不到: ${worst.join(',')}` : '全部原语可达');
ok(reach[2].length >= reach[1].length && reach[1].length >= reach[0].length, '预算越宽,可达原语数不应减少');
}
// 逐原语:+5% 与 +30% 下能做到多细
console.log('\n原语 +5% 最细 g +30% 最细 g +5% 时能否无长任务 +5% 时能否无一帧任务');
console.log('-'.repeat(82));
for (const c of CANDS) {
const g5 = finest(c, 1.05), g30 = finest(c, 1.30);
console.log(c.key.padEnd(19), r1(g5).toString().padEnd(12), r1(g30).toString().padEnd(13),
(g5 <= 50 ? '能' : '不能').padEnd(20), g5 <= CFG.FRAME ? '能' : '不能');
ok(g30 <= g5 + 1e-9, '预算更宽时应能切得更细');
}
{
const P = (k) => CANDS.find((c) => c.key === k);
// 关键:rAF 在"片 ≈ 一帧"时几乎免费,一旦小于一帧就按帧付费
const rafCost = (g) => (rafWall(g) / W - 1) * 100;
ok(rafCost(CFG.FRAME) < 1, `rAF 在 g=一帧时的墙钟涨幅应 <1%(实测 ${r2(rafCost(CFG.FRAME))}%)`);
ok(rafCost(8) > 100, `rAF 在 g=8ms 时的墙钟涨幅应 >100%(实测 ${r1(rafCost(8))}%)`);
console.log('\n→ rAF 在 g=一帧时墙钟涨幅只有 %s%%(一片正好塞进一帧);g=8ms 时涨到 %s%%。' +
'rAF 切片的成本是阶跃的:一帧之内免费,跨过一帧按帧付费。',
r2(rafCost(CFG.FRAME)), r1(rafCost(8)));
// setTimeout 在 +5% 预算下做不到"无长任务",+10% 才做到
ok(finest(P('setTimeout(0)'), 1.05) > 50, 'setTimeout 在 +5% 预算下应做不到 <50ms 片');
ok(finest(P('setTimeout(0)'), 1.10) <= 50, 'setTimeout 在 +10% 预算下应能做到 <50ms 片');
ok(finest(P('postTask'), 1.05) <= 50, 'postTask 在 +5% 预算下就该做到 <50ms 片');
const hard = GOALS[2];
const onlyPost = CANDS.filter((c) => finest(c, 1.05) <= hard.g + 1e-9).map((c) => c.key);
console.log('→ 要把片压到"一帧以内",+5%% 预算下只有 %s 能做到------这才是真正需要换原语的场合。',
onlyPost.join(' / '));
console.log('→ 回到 INP:g ≤ %sms 就够,而 +5%% 预算下 %s 都能做到(最粗的 %s 也有 %sms)。' +
'为 INP 优化时,原语的选择几乎不重要;为"零长任务"优化时,原语才成为瓶颈。',
r1(gMax), CANDS.length + ' 种原语', 'setTimeout', r1(finest(P('setTimeout(0)'), 1.05)));
}
console.log(`\n全部断言通过(共 ${NS} 条)。`);
text
==================================================================================
第一部分:八种"让出主线程"的原语(gap 越大,墙钟越贵)
==================================================================================
原语 gap(ms) 1 秒内可切片数 1.2 秒工作的墙钟 占空比
----------------------------------------------------------------------------------
scheduler.yield() 0.1 196.1 1239.2 98%
postTask(user-blocking) 0.15 194.2 1251.3 97.1%
postTask(user-visible) 0.15 194.2 1251.3 97.1%
postTask(background) 0.15 194.2 1251.3 97.1%
MessageChannel 0.2 192.3 1263.4 96.2%
setTimeout(fn, 0) 4 111.1 2183 55.7%
requestAnimationFrame 16.666666666666668 46.2 5248.3 23.2%
requestIdleCallback 空闲才跑 --- 不可预算(取决于空闲量) ---
+30%% 墙钟预算下的最细切片 g* = gap/(M−1):
scheduler.yield() → g* = 0.33ms
postTask(user-blocking) → g* = 0.5ms
postTask(user-visible) → g* = 0.5ms
postTask(background) → g* = 0.5ms
MessageChannel → g* = 0.67ms
setTimeout(fn, 0) → g* = 13.33ms
→ setTimeout 的 g* 比 scheduler.yield 粗 40 倍。clamp 就是这个倍数的全部来源。
==================================================================================
第二部分:一串 yield 会被插队吗(ρ = 其他任务对主线程的占用率)
==================================================================================
占用率 ρ 无 continuation 优先级的链长 有 continuation 优先级的链长 差
----------------------------------------------------------------------------------
0 1215 1215 0
0.2 1518.8 1215 +303.8ms
0.4 2025 1215 +810ms
0.6 3037.5 1215 +1822.5ms
0.8 6075 1215 +4860ms
0.9 12150 1215 +10935ms
→ ρ=0.8 时:没有 continuation 优先级,1215ms 的活要拖到 6075ms(5 倍);有它,链长仍是 1215ms。这就是 scheduler.yield() 与 MessageChannel 的全部差别------不是 gap 的 0.1ms,而是"链会不会被别的任务按 FIFO 插队"。
==================================================================================
第三部分:40 个 20ms 的后台任务排着,点击在随机时刻到达
==================================================================================
调度方式 输入延迟均值(ms) 相对 FIFO
----------------------------------------------------------------------------------
FIFO(无优先级) 400 1×
离散输入 > 后台(正确定级) 10 40× 更快
→ 优先级把输入延迟从 400ms 压到 10ms(40 倍)。而这个收益与"任务多长"无关,只与"排队了几个任务"有关:优先级买的是"排队的长度",不是"任务的成本"。
→ 但任务不可抢占:如果正在跑的是一个 200ms 的同步任务,点击的输入延迟仍是 200ms ------ 优先级只决定"下一个跑谁",不决定"当前这个跑多久"。所以优先级救不了长任务,只有 yield 能。
==================================================================================
第四部分:优先级通胀------把 25%/50%/100% 的后台活标成 user-blocking
==================================================================================
通胀率 同级剩余任务(均值) 输入延迟(ms) 相对正确定级
----------------------------------------------------------------------------------
0% 0 10 1×
25% 5 110 11×
50% 10 210 21×
75% 15 310 31×
100% 20 410 41×
→ 通胀 0/25/50/100%:输入延迟 10 / 110 / 210 / 410ms。每多标 25% 的活,线性多付 100ms。全标等于没标(410ms ≈ FIFO 的 400ms)。
→ 而"全标 user-blocking"在团队里是最常见的一种写法------因为没人愿意自己的任务是低优先级的。优先级的价值来自稀缺,人人都有份就等于没有。
==================================================================================
第五部分:background 任务的饥饿(完成时间 = W/(1−ρ))
==================================================================================
占用率 ρ 后台任务完成时间(ms) 拉伸倍数 剩余可用的"缝隙"
----------------------------------------------------------------------------------
0 1215 1× 100%
0.3 1735.7 1.4× 70%
0.5 2430 2× 50%
0.7 4050 3.3× 30%
0.9 12150 10× 10%
0.95 24300 20× 5%
→ ρ 从 0.7 涨到 0.95:拉伸从 3.3× 变成 20×(6 倍)。这一栏是非线性的:越接近满载,后台任务越接近永不完成。
→ 所以 user-blocking 的用量必须有预算:它不是"越多越好",而是"ρ 一旦超过约 0.8,后台活就等于被删了"。
==================================================================================
第六部分:三个目标 × 两档墙钟预算 → 哪些原语真的能做到
==================================================================================
其余两段:handler 40ms + 呈现 P98 36.3ms = 76.3ms → g 的上限 = 126.2ms
目标 预算+5% 预算+10% 预算+30% 结论
----------------------------------------------------------------------------------
INP good(交互 ≤200ms) 5/5 5/5 5/5 全部原语可达
无 >50ms 长任务 4/5 5/5 5/5 全部原语可达
无 >一帧(16.7ms)的任务 4/5 4/5 5/5 全部原语可达
原语 +5% 最细 g +30% 最细 g +5% 时能否无长任务 +5% 时能否无一帧任务
----------------------------------------------------------------------------------
scheduler.yield() 2 0.4 能 能
postTask 3 0.5 能 能
MessageChannel 4 0.7 能 能
setTimeout(0) 76 13.3 不能 不能
rAF 分帧 16 13 能 能
→ rAF 在 g=一帧时墙钟涨幅只有 0.14%(一片正好塞进一帧);g=8ms 时涨到 108.5%。rAF 切片的成本是阶跃的:一帧之内免费,跨过一帧按帧付费。
→ 要把片压到"一帧以内",+5% 预算下只有 scheduler.yield() / postTask / MessageChannel / rAF 分帧 能做到------这才是真正需要换原语的场合。
→ 回到 INP:g ≤ 126.2ms 就够,而 +5% 预算下 5 种原语 都能做到(最粗的 setTimeout 也有 76ms)。为 INP 优化时,原语的选择几乎不重要;为"零长任务"优化时,原语才成为瓶颈。
全部断言通过(共 62 条)。
2.4 落地接线:浏览器侧的三段代码
模型跑出来的数只有落到真实代码上才有用。浏览器侧需要三样东西:一个优先用 continuation 友好原语 的让出函数、一个按毫秒预算 切片的循环、一段把 INP 拆成三段的埋点。
js
// ① 让出主线程:优先选 continuation 友好的原语,永远不要用 setTimeout
const yieldToMain = () => {
if (globalThis.scheduler?.yield) return scheduler.yield(); // continuation 插队恢复
if (globalThis.scheduler?.postTask) {
return scheduler.postTask(() => {}, { priority: 'user-visible' }); // 降级:任务级让出
}
return new Promise((resolve) => { // 再降级:无 clamp
const ch = new MessageChannel();
ch.port1.onmessage = () => resolve();
ch.port2.postMessage(0);
});
};
// ② 切片循环:按"毫秒预算"切,不按"条目数"切
async function sliceWork(items, handle, budgetMs = 5) {
let sliceStart = performance.now();
for (const item of items) {
handle(item);
if (performance.now() - sliceStart >= budgetMs) {
await yieldToMain(); // 只用于"别人的活":启动水合、预取、缓存预热
sliceStart = performance.now(); // 不要在被测交互的 handler 里用它(见难点五)
}
}
}
// ③ 量三段:Event Timing 给出的正是 INP 的分解,用它替换本文的模型
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (!e.interactionId) continue; // 只统计真正的交互
report({
name: e.name,
inputDelay: e.processingStart - e.startTime, // 第一段:主线程排队
processing: e.processingEnd - e.processingStart, // 第二段:你的 handler
presentation: e.startTime + e.duration - e.processingEnd, // 第三段:到下一帧画完
total: e.duration,
});
}
}).observe({ type: 'event', durationThreshold: 16, buffered: true });
这三段的字段划分与 web.dev 上 INP 的"input delay / processing time / presentation delay"分解一致,durationThreshold: 16 是官方脚本用的取值(只关心可能进尾部的交互)。拿到这三个数之后,本文的三段表就可以整张表换成你自己站点的真实分布 ------那时 inp-anatomy.mjs 的角色就从"预测"变成"回归测试"。
2.5 改动清单:按四栏归位
把上面所有结论压成一张表。每一项改动都只动一栏,且必须写明它换了什么------这是本文唯一要求的记账纪律:
| 栏位 | 改动 | 实测效果 | 它换来了什么 / 换走了什么 |
|---|---|---|---|
| 输入延迟(g) | 启动工作 1215ms 切成 50ms 片 | P98 输入延迟 1190.7 → 49 (−1141.7),墙钟 1215 → 1218.6(+0.3%) | 几乎没有代价,这是性价比最高的一刀 |
| 切到 126ms 片 | P98 输入延迟 → 123.5,墙钟 +0.1% | INP 已达 good,其余预算不必花 | |
| 切到 1ms 片 | P98 输入延迟 → 1,墙钟 1397.1(+15%) | 尾部收益 48ms,价格 15% 墙钟------通常不划算 | |
| 输入延迟(o) | setTimeout → postTask(g=5) |
墙钟 2183 → 1251.3(−42.7%),P98 延迟不变 | 换原语不改尾部,只改墙钟 |
setTimeout(g=1) |
墙钟 6071(+399.7%) | clamp 把占空比打到 20% | |
rAF 分帧(g=一帧) |
墙钟 +0.14%,P98 延迟 16.3 | 最便宜的"消灭长任务",但片不能小于一帧 | |
rAF 分帧(g=1) |
墙钟 20250(+1566.7%) | 帧率天花板:片数 = 帧数 | |
| 处理时间 | handler 145 → 45ms | ΔINP −100 | 独立于其他两段,直接相减 |
| 把 120ms 的 handler 切 16 片 | ΔINP +2.3(负收益) | 只会把等待挪到自己身上 | |
| 呈现延迟 | 重渲染 9000 → 2000 节点 | ΔINP −84 | 在阻塞型页面上只值 5.8%,但在内容站是主导项 |
| 只减 DOM 总量、不减重渲染范围 | ΔINP 0 | "减 DOM"如果没减到重渲染范围,等于没做 | |
| 优先级 / 饥饿 | 离散输入正确定级 | 400 → 10(40×) | 买的是排队长度,不是任务成本 |
25% 的活误标 user-blocking |
输入延迟 10 → 110 | 线性涨价,每 25% 一笔 100ms | |
| ρ 从 0.7 涨到 0.95 | 后台任务拉伸 3.3× → 20× | 非线性:满载时后台活等于被删 | |
| 指标口径 | 由"均值"改为"p98 + 计数预算" | 均值 121.4 vs p98 506.3 | 换成唯一能对上评分卡的读法 |
2.6 十三条纪律
- 先说清你在优化哪一段。 输入延迟、处理时间、呈现延迟------三段各有各的价钱。混在一句"提升流畅度"里的改动无法评审。
- 先算一遍你的 g。
P98 输入延迟 ≈ 0.98g − 0.02o。如果 g 还是 1215ms,那么任何 handler 优化的天花板都是 1460 → 1360(本文实测)。 - 不要为 INP 把 g 切到 50ms 以下。 good 门槛只要 126.2ms。100ms 的任务仍是 Long Task,但它不再让 INP 超标;继续往下切的收益只有 49ms,而价格的量级是 15% 墙钟。
- 不要在 handler 里 yield。 16 片换来 +2.3ms 的净亏。要减的是工作量本身,或者把它挪出同步路径。
- 选原语看 o,不看"新不新"。
o从 4ms 降到 0.15ms 值 26.7 倍 的墙钟差价;而o从 0.15 降到 0.10 只值 50%------两者不是一个量级的决策。 - 不要用
rAF做细粒度切片。 g=1ms 时 1215ms 的活要干 20250ms。它的正确用法是"一片正好一帧"。 - 优先级只买排队长度。 40 个任务排队时值 40 倍;但任务不可抢占,一个 200ms 的同步任务仍然是 200ms。优先级救不了长任务,只有 yield 能。
user-blocking要设预算。 每多标 25% 的活,线性多付 100ms;全标等于没标(410ms ≈ FIFO 的 400ms)。ρ 一旦超过约 0.8,后台活就等于被删了(拉伸 5 倍以上)。- 把 p98 翻译成计数。 「INP ≤ 200ms」= 「超过 200ms 的交互不超过 ⌊2%·N⌋ 次」。N=100 时你有 2 个名额。
- 别先修最慢的那一次。 一个 5000ms 的卡顿只吃 1 个名额;该优先修的是刚越过 200ms 且数量最多的那一批。
- 但也要盯住那一次灾难。 INP 对单次灾难几乎静音(5000ms × 1 → good),所以它必须与 LoAF / 长任务监控一起看(Day34 展开)。
- 用 Event Timing 的三段实测替换本文的模型。
processingStart/processingEnd/duration就是三段的边界;拿到真实分布后,脚本的角色从"预测"变成"回归测试"。 - 每天问自己那句话 ------本篇的版本是:「这次改动,我改的是输入延迟、处理时间、呈现延迟,还是越过阈值的次数?它把哪一项换成了哪一项?」 答得出,才是决策;答不出,就是风险。
三、应用场景
场景一:长任务都切了,INP 没动
这是本篇最典型的案例。团队把启动水合的 1215ms 切成 50ms 片,输入延迟从 1190.7ms 掉到 49ms,报表上 INP 一动不动。
诊断路径(按顺序,别跳步):
- 用 Event Timing 把 INP 拆成三段,看哪一段占主导 。本文的三类页面给了一个先验:内容站 19.6 : 30 : 124.3 (呈现主导),交易站 49 : 180 : 64.3(处理主导),重水合 SPA 1190.7 : 145 : 124.3(输入延迟主导)。
- 如果主导的是处理时间,切片完全无效------处理时间是"你这次点击自己要干多少活",与主线程有多忙无关。这时要做的是减少工作量(虚拟列表、把计算挪出 handler、乐观更新先画后算)。
- 如果主导的是呈现延迟 ,要减的是重渲染范围 ,不是 DOM 总量。本文实测:只减总量不减范围,ΔINP = 0 ;范围从 9000 缩到 2000,ΔINP = −84。
- 如果主导的确实是输入延迟,再确认一件事:你切的是不是自己的 handler。 如果这次交互的 handler 本身被切成 16 片,你会拿到 +2.3ms 的净亏。
注意别踩的坑: "我已经切片了"这句话不包含任何信息。必须说清"切的是哪一段活、片长多少、用的是哪个原语"------三个都说了,才有 0.98g 这个可算的预测值。
场景二:一次 5 秒卡顿被用户投诉,但 INP 报表是绿的
因为 INP 是计数预算 ,一次灾难只吃掉 1 个名额。本文的会话甲:99 次 ≤149ms + 1 次 5000ms → p98 = 148ms → good。而用户真实感受到的正是那 5 秒。
诊断:
- 不要用 INP 单指标做验收。把它与 LoAF(Long Animation Frames) 或长任务计数并列------前者回答"典型体验",后者回答"最坏体验"。
- 在监控里同时报两个数:
p98 交互延迟(评分卡)与>1000ms 的交互次数/千次访问(灾难账)。本文的模型能给出一条硬结论:这两件事在 p98 里是脱钩的,靠一个指标无法同时看住。 - 修 5 秒卡顿通常不是"切片"能解决的(那是单次同步计算或一次巨量 DOM 操作),而是"别让这种事发生"------把工作迁到 Worker(Day32)、把渲染改成增量。
场景三:INP 报表红了,但用户说不卡
会话乙:97 次 60ms + 3 次 250ms → p98 = 250ms → poor。用户的主观感受可能是"还行",但三个刚过阈值的交互把名额吃光了。
诊断: 这恰恰是最容易修 的一类。目标是"把超过 200ms 的交互数压到名额以内",而不是"消灭最慢的那一次"。三次 250ms 各自只需降 51ms;而一次 5000ms 降 4800ms 对指标毫无帮助。先按"越过 200ms 的交互列表"排序,从数量最多的那一簇下手。
场景四:我们把所有任务都标了 user-blocking
实测:正确定级时输入延迟 10ms;25% 通胀 → 110ms;50% → 210ms;100% → 410ms ≈ FIFO 的 400ms。
诊断: 在代码里 grep 一下 user-blocking 的出现次数,除以任务总数------那就是你的通胀率,乘 100ms 就是白付的延迟。
修法: ①离散输入天然是 user-blocking,不需要你标;②只有"下一次绘制之前必须完成"的活才标 user-blocking(比如输入回显),其余默认 user-visible;③预取、上报、缓存预热、埋点一律 background,并接受它们可能被饿------这正是它们该有的待遇。
场景五:用了 rAF 分帧,1.2 秒的活干了 4 秒
因为 rAF 的成本是帧数模型 :帧数 × 16.67ms。g=5ms 时 243 片 = 243 帧 = 4050ms(+233.3%) ;g=1ms 时 1215 帧 = 20250ms(16.7 倍)。
诊断: 数一下你的片数与帧数是否相等。rAF 切片只有在"一片正好塞进一帧"时才是免费的(g=一帧 → +0.14%)。
修法: ①如果只是要"消灭长任务",用 g ≈ 16.7ms 的 rAF 分帧,几乎零成本;②如果要把 g 压到 16.7ms 以下,必须换 postTask / MessageChannel / scheduler.yield();③rAF 的真正用途是"跟着帧走"(动画、滚动效果),不是"切 CPU 活"。
场景六:在 handler 里加了 yield,反而更慢
实测:120ms 的 handler 切 2/4/8/16 片,交互延迟 +0.1 / +0.5 / +1.1 / +2.3ms。
诊断: yield 在 handler 里不会把时间"还"给这次交互,它只是把 CPU 时间换成了"等待"------而 INP 两者都算。唯一的收益是后续的输入能插进来。
修法: 分两种情况:①如果这次交互本来 < 200ms,而页面里还有别的长任务在饿着用户,那么在 handler 里让一次是有意义的(牺牲几毫秒,换别人的几百毫秒);②如果这次交互自己已经超标,先减工作量:拆分渲染、虚拟化列表、把非关键计算推迟到 requestIdleCallback 或用 postTask(background) 之后再算。顺序永远是"先减工作量,再谈让出"。
场景七:预取、上报、缓存预热总是完不成
因为饥饿是非线性 的:后台完成时间 = W/(1−ρ)。ρ=0.5 时 2 倍,0.7 时 3.3 倍,0.9 时 10 倍,0.95 时 20 倍。
诊断: 先量 ρ(主线程被 user-blocking + user-visible 占掉的比例)。如果 ρ > 0.8,后台任务的"完不成"不是 bug,是算术。
修法: ①给 user-blocking 设预算(比如 ≤ 40% 的主线程时间);②后台活按 requestIdleCallback 的 deadline 分批,并接受它可能跨多个空闲期;③真正不依赖 DOM 的活(上报、加解密、解析)迁到 Worker------那是 Day32 的主题,也是唯一能突破"单线程 ρ"的手段。
场景八:优化了重渲染,INP 只动了 5.8%
因为主导相位是输入延迟(1460ms 里的 1190.7ms,占 81.6%)。此时重渲染范围从 9000 缩到 2000 节点,只值 −84ms ,相对改善 5.8%。
诊断: 先算三段比例,再决定预算。在阻塞型页面上,第三段的全部空间只有 124.3ms------即使你把呈现延迟优化到 0,INP 仍然超标(1335.7ms)。
修法: 按段的大小排序投预算,不要按"难易度"排。这也是为什么"性能优化"经常变成"做容易做的那件事"------它有个更好听的名字叫低垂果实,而在错误的段落里,低垂果实是酸的。
场景九:我们用了 async/await 切片,没用
await Promise.resolve() 只让出微任务队列 ,不交还主线程控制权------微任务之间不会渲染、不会派发输入 。所以 for (const x of arr) { work(x); await Promise.resolve(); } 对输入延迟的改善是 0。
诊断: 看你的"让出"是否跨越了任务边界 (macrotask)。MessageChannel / postTask / scheduler.yield() / setTimeout 都是任务边界;await(非 I/O)不是。
修法: 把 await Promise.resolve() 换成本文 2.4 的 yieldToMain()。这一条改动通常能把"我们明明切片了"的谜团一次解开------而且它不花任何墙钟以外的代价。
四、总结
- 运行时性能不是"把活干得更快",是"决定这块活和用户那一次点击谁先跑"。 主线程只有一个,你只能让工作让位,或者让用户等;而"让位"的价格表由 g 与 o 两个数决定。
- 切片不减少工作,它把"最坏那一次阻塞"从 W 压到 g。 同一次点击:g 从 1215 降到 50,P98 输入延迟 1190.7 → 49ms ;g 降到 5,→ 4.9ms。而墙钟只从 1215 涨到 1218.6 / 1251.3(+0.3% / +3%)。
- g 管尾部,o 只管墙钟。
P98 输入延迟 = 0.98g − 0.02o:o 从 0.15 拉到 4.0,P98 只变 0.98 倍,平均延迟反而降到 0.57 倍------加长间隙能压均值、抬不动尾部,而 INP 是尾指标(这一条是我被自己的断言纠正后得到的)。 g* = o/(M−1):能切多细只由原语决定,与工作量无关。 同样 +30% 预算:scheduler.yield()0.4ms、postTask0.5ms、setTimeout13.3ms 、rAF13ms------最粗与最细差 26 倍以上,全部来自 4ms 的 timer clamp 与帧对齐。- INP 不是"最差那一次"。 N=100 时 p98 落在第 3 差 ;修掉最差 1 次,指标从 506.3 滑到 332.1(= 原第 4 差),4 个重交互全修到 120ms 仍停在 229.2ms。
- INP 是一个计数预算。 「≤200ms」= 「超过 200ms 的交互 ≤ ⌊2%·N⌋ 次」。99 次 ≤149ms + 1 次 5000ms 是 good(148ms);97 次 60ms + 3 次 250ms 是 poor(250ms)------甲的最差交互是乙的 20 倍,指标却更好。
- "先修最慢那一次"是错的排序。 该优先修"刚越过 200ms 且数量最多"的那一批;而单次灾难在 INP 里几乎静音,所以必须配 LoAF / 长任务监控一起看。
- 交互延迟是三段。 本文重水合页面:1190.7 + 145 + 124.3 = 1460ms (81.6% : 9.9% : 8.5%)。三项修法可加(−1141.7、−100、−84 → 叠加 134.3ms),但只做最容易的那一项只值 5.8%。
- 主导相位因页面而异。 内容站修呈现(124.3ms 主导)、交易站修 handler(180ms 主导)、重水合 SPA 修输入延迟(1190.7ms 主导、占 81.6%)。先分段,再选刀。
- 同一把刀两个方向。 切"别人的活"(启动水合)把输入延迟从 1190.7 压到 49;切"自己的 handler"(16 片)净亏 +2.3ms。判断标准只有一个:这段工作是否在被测交互的 handler 里。
- 呈现延迟有硬地板。 P98 下一帧量化就是 16.33ms ,零节点 + 8ms handler 的 INP 地板是 24.3ms;而它的斜率可控------绘制 ≈ 节点数 × 0.012ms,9000 节点就是 108ms,占掉 good 预算的 54%。
- INP 的门槛是 g ≤ 126.2ms,不是 50ms。 100ms 的任务仍是 Long Task 但 INP 达标;为 INP 从 100ms 切到 50ms 只值 49ms,这笔钱通常该花在 handler 上。追求"零长任务"是另一个目标,别把它混进 INP 的验收。
- 原语的选择对 INP 往往不重要。 +5% 预算下五种原语全都能 把 g 压到 126ms 以下(最粗的
setTimeout也有 76ms);原语只有在追求"零长任务 / 帧内任务"时才成为瓶颈。多数团队换原语看不到效果,是因为 g 还是 1215。 - 优先级买排队长度(40 倍),买不到当前任务(不可抢占)。 通胀是线性的------每 25% 多付 100ms,全标 410ms ≈ FIFO 的 400ms;饥饿是非线性的------ρ 从 0.7 到 0.95,后台任务拉伸 3.3× → 20×。
- 三件工具 + 一条纪律。 设计期用
slice-budget.mjs算 g 与预算(27 组参数扫描下 4 条保序结论恒成立)、评审期用inp-anatomy.mjs拆三段与计数预算、实现期用scheduler-ledger.mjs定原语与优先级;纪律是那句每天都要问的话------"这次改动,我改的是输入延迟、处理时间、呈现延迟,还是越过阈值的次数?它把哪一项换成了哪一项?"
下一篇 Day30 讲bundle 优化 :那 230KB gzip 的 JS 是怎么变成 700KB 的、tree-shaking 在哪一步失效、code-split 的切点该按路由还是按交互、以及为什么"减小 bundle"与"减少执行时间"在多数页面上不是同一件事。那是把本篇的"执行时间"往前推到"字节与图"的一篇。
运行环境
三份脚本都在 Node.js 16+ 下零依赖运行,各自独立可跑,断言失败即 exit 1:
bash
node slice-budget.mjs # 363 条断言(含 27 组参数扫描,扫描内 165 条)
node inp-anatomy.mjs # 75 条断言
node scheduler-ledger.mjs # 62 条断言
合计 500 条不变量断言,本文引用的所有数字均为上述脚本的实跑输出。
浏览器侧代码的校验范围 :2.4 那三段代码无法在 Node 里完整运行,我用桩件(PerformanceObserver 与 scheduler 的替代实现)校验了 6 条行为------降级链的选择顺序(scheduler.yield → postTask(user-visible) → MessageChannel,且不回落 setTimeout )、切片边界(budget=0 时每条目必让出 4/4、budget 极大时 0 次让出)、以及埋点参数(type: 'event'、durationThreshold: 16、buffered: true)。真实浏览器行为仍需在目标环境核对 ------尤其 scheduler.yield() 的存在性与 Event Timing 的字段量化。
共用模型参数 (三份脚本一致):一帧 16.67ms(60Hz);每节点绘制 0.012ms;交互 handler 基线 145ms(重渲染一个列表)、下限 8ms;yield 成本 0.15ms(postTask / MessageChannel 量级);页面 DOM 9000 节点;启动主线程工作 1215ms(沿用 Day28 的水合账,W = 1215);后台任务 40 个 × 20ms;setTimeout clamp 4ms;INP good 200ms / poor 500ms。
三份脚本的关键公式 :平均输入延迟 = g²/(2(g+o))、P98 输入延迟 = 0.98g − 0.02o、墙钟 T = W + (⌈W/g⌉−1)·o、加性原语的 g* = o/(M−1)、rAF 的 帧数 = ⌈W/min(g,一帧)⌉、呈现 P98 = 0.98×16.67 + 节点数×0.012、计数预算 N − ⌈0.98N⌉ = ⌊0.02N⌋、饥饿拉伸 1/(1−ρ)。
关于单位成本的来源(重要,请勿跳过) :这些单价是按中端设备校准的量级常数,不是任何一份可引用的公开测量 。我检索过:web.dev、Chrome DevTools 文档、CrUX 与 Lighthouse 都没有 公布"一次交互的 handler 多少毫秒 / 每个 DOM 节点绘制多少毫秒 / 一次 yield 多少毫秒"这类数字。公开的只有口径与阈值 :INP 的 good / needs-improvement / poor 分界是 200 / 500ms 且以 p75 统计;Long Task 的门槛是 50ms;FID 已于 2024-09-09 停止支持、由 INP 替代。所以请把本文的毫秒数当成同一坐标系内的相对量 :它们的用途是回答"切到 50ms 要付多少墙钟""三段里谁是主导""计数预算是几个名额",而不是"你的机器上会花 1460ms"。为对抗这一点,slice-budget.mjs 做了 27 组参数扫描,把四条保序结论钉在所有参数取值上。
各脚本的额外简化与已知的乐观之处:
slice-budget.mjs:① 把"输入到达"建模为在一个(g+o)周期内均匀分布 ,真实到达是泊松的,且有突发性;② 间隙被当作"主线程空闲、可立即派发输入"(对输入延迟是乐观 估计,对墙钟是悲观 估计------真实浏览器在间隙里还要做样式、布局、绘制);③ 只模拟一次导航的一整段工作,不含"工作被拆到多个互不相关的阶段"(启动、路由切换、后台刷新);④P98用 nearest-rank 口径(⌈0.98N⌉),与 web-vitals 的实际实现可能有 1 名的差异;⑤ rAF 用"帧数 × 一帧"的模型,未模拟浏览器在掉帧时的实际调度与rAF回调被合并;⑥ 未考虑AbortSignal中断、未考虑scheduler.postTask的delay与signal。inp-anatomy.mjs:① 会话样本(88% 轻 / 8% 中 / 4% 重)是构造 出来的,不是任何真实站点的分布------它的作用是把"均值好看、p98 难看"这件事变成可复算的算术,不是估计你的站点有几次慢交互;② 分位数给了两种口径并附少量断言,但没有断言两种口径给出同一名次 (它们在 N≈50 处分歧,这正是脚本要展示的事实);③ 三段模型里,输入延迟借用了slice-budget.mjs的公式(切片粒度驱动的排队),未建模"输入事件在 long task 中途到达并被合并/重放" (Day28 的点击账测过这一现象,本篇不重复);④ 呈现延迟只按"重渲染节点数 × 单价"计,未区分样式重算、布局、合成的不同单价,也未建模content-visibility、合成层提升、will-change的影响;⑤ handler 切片的代价只计 yield 间隙,未计"切片可能让浏览器插入一次中间绘制"带来的额外呈现 ,所以+2.3ms是保守估计(真实情况通常更差);⑥ "修复最差 k 次"是把值替换成 120ms 的构造操作,不含修复本身的工程成本。scheduler-ledger.mjs:① 优先级模型假设"离散输入 >user-blocking任务 >user-visible>background"的严格序,真实调度器还有TaskController的动态优先级、RenderBlocking与BestEffort的细节;② "任务不可抢占"是当前实现的事实,但浏览器可以对渲染帧做优先级调整,且scheduler.yield()的 continuation 会插队------这两点在模型里分别体现为"输入延迟 = 当前任务剩余"与"链长不被拉长";③ 通胀模型假设误标的任务与输入同级并严格 FIFO,未建模"同级内部仍按到达顺序与批次"的更细行为;④ 饥饿用1/(1−ρ)(确定性排队近似),真实分布会有更长的尾部;⑤ 原语的 gap 是量级常数 :0.10/0.15/0.20/4/16.67是模型参数,尤其scheduler.yield()的 0.10ms 与MessageChannel的 0.20ms 差异,不应被引用为实测结论,它们的唯一作用是展示"加性原语之间差 2 倍"与"与 clamp 差 26 倍"这两件事。
版本与语义约定(与"模型不确定性"分开标注------这几条是当前实现的事实,落地前应逐条核对目标环境):
- INP 的口径 :Core Web Vitals 的 good 阈值是 INP ≤ 200ms、LCP ≤ 2.5s、CLS ≤ 0.1 (均按 p75 统计);INP 的 needs-improvement 区间是 200--500ms,> 500ms 为 poor 。FID 已于 2024-09-09 停止支持,由 INP 替代。INP 的"高百分位"在规范与实现里是 p98 量级 ,且当一次访问的交互次数很少时,它退化成"最差的那一次"------本文的 §一难点三 给出了两种分位口径下的名次表,结论("落在第 2~5 差,不是最差")对两种口径都成立,但具体名次请以你所用测量库的实现为准。
- 交互延迟的三段分解 :
inputDelay = processingStart − startTime、processing = processingEnd − processingStart、presentation = startTime + duration − processingEnd,出自 Event Timing API;这是 web.dev 的 INP 归因脚本(web-vitals的 attribution 构建)使用的划分。interactionId用于把一次交互内的多个事件(如pointerdown/pointerup/click)归为一组,performance.interactionCount给出计数。 scheduler.postTask:三档优先级user-blocking/user-visible/background;任务不可抢占 ,优先级只影响"下一个执行谁"。scheduler.yield()让出后以更高优先级恢复(continuation),因此一串 yield 不会被同/低优先级任务按 FIFO 插队;它在 Chrome 129 (2024-09)起可用,其他引擎的落地状态请在目标环境核对。这两个 API 的存在性必须做特性检测 ------本文 2.4 的yieldToMain()就是三级降级链。setTimeout的 clamp :HTML 规范规定嵌套层级超过 5 层后,最小延迟为 4ms ("timer clamping")。这是"为什么不能用setTimeout(fn, 0)做细粒度切片"的全部依据。MessageChannel的postMessage走宏任务且没有 这个 clamp,但它无法声明优先级,也无法获得 continuation 优先级。requestAnimationFrame:回调在帧的渲染步骤之前执行,一片占一帧 ;requestIdleCallback只在空闲期执行、单次 deadline 约定为 50ms 上限、且在有输入或动画时可能完全不执行(用它做"必须在 N 毫秒内完成"的活是错配)。isInputPending()(Chrome 系)能让切片循环主动查询"是否有待处理的输入",从而在"有输入"时立刻让出------本文的模型没有建模它,它的效果等价于把o在关键时刻降到 0,方向上与难点一的结论一致(能压均值,对 P98 无帮助,因为 P98 的等待发生在"已经在一个 task 里"的时候)。- 微任务不让出主线程 :
await Promise.resolve()(或await任何已 resolve 的值)只清空微任务队列,不会 交还控制权给渲染与输入。这是场景九的全部依据;await fetch(...)这类真实 I/O 会让出,因为它跨了任务边界。 - LoAF(Long Animation Frames) :
PerformanceObserver可观察long-animation-frame条目,给出超过 50ms 的帧及其脚本归因(scripts数组),是"场景二"里补住"单次灾难在 INP 里静音"这个缺口的工具。它是 INP 的补充,不是替代。 - 上述版本性事实来自 web.dev / MDN / Chrome for Developers 与 W3C 的 Event Timing 规范,会随版本变化;本文的模型与断言不依赖这些行为,它们只影响"哪些 API 能直接对应本文的哪一种策略"。
自测暴露并修复的 6 个真实问题(保留在此,因为它们都属于"模型写错会让结论反向"的类型,且现在每条都有对应断言守着):
- 把"间隙越长,平均输入延迟越大"写成了断言 (
slice-budget.mjs):实跑是 0.57 倍------方向反了。原因是间隙期间主线程空闲,输入可以立刻派发。这条失败的断言直接产出了难点一的核心结论:加长间隙压均值、抬不动 P98,而 INP 是尾指标。 现在断言的是"o 变大时均值应显著变小(<0.7×)且 P98 几乎不变(0.98×)"。 - 把"修掉最差那一次,INP 不变"当成普遍规律 (
inp-anatomy.mjs):这句只在"删除 一次采样"(N 从 100 变 99)时成立;"修复一次交互"(N 不变、值变小)会让第 4 差顶到第 3 差的位置,INP 从 506.3 掉到 332.1。两个操作在 p98 里是两件事,现在拆成两张表并各配断言。 0.98 × 50 = 49.0被当成50(inp-anatomy.mjs):初版断言"N=50 时 p98 就是最差那一次",实跑ceil(48.999...) = 49,边界在 N=49。顺带发现两种分位口径(nearest-rank 与插值)恰好在 N≈50 处分歧------这反而成了"引用 p98 必须带 N"这条提醒的来源。- 把 rAF 当成加性原语(o=16.67ms) (
slice-budget.mjs):初版算出 g=60 时墙钟涨幅 −71.2% (比不切还快),荒谬。rAF 的真实成本是"帧数 × 一帧",g ≥ 一帧时墙钟恒为⌈W/16.67⌉×16.67,与 g 无关。修正后才得到"rAF 成本是阶跃的"这个正确结论(一片一帧时 +0.14%,g=1ms 时 +1566.7%)。 - 两份脚本算出的
g ≤ good上限差了 8ms (slice-budget.mjs134.4 vsscheduler-ledger.mjs126.2):因为前者用"帧量化均值FRAME/2"、后者用"P98 帧量化0.98×FRAME"。同一篇文章里同一件事必须只有一个数 ,现在统一为 P98 口径,两份脚本各自独立收敛到 126.2ms------这条自洽性反而成了交叉校验。 - "长任务"那一列把 handler 也加进了片长 (
slice-budget.mjs):初版按g + HANDLER > 50判定,导致 g=50 也被标成"有长任务"(50+40=90)。长任务的定义是单个任务 超过 50ms,handler 是叠加在片上的另一段,应该分开说。改成按g > 50判定后,表格才能正确表达"g=126 已是 good、但仍是长任务"这个关键分裂。
关于"为什么不给结论装一个漂亮的名字" :本文的结论几乎都是"某一段变成了另一段"的交换,没有一个能收进一句话的口号。这恰恰是运行时性能的真相------它没有"开与关",只有"先跑谁" 。如果一定要留一句话,就留那句纪律:这次改动,我改的是输入延迟、处理时间、呈现延迟,还是越过阈值的次数?