一、技术难点
Day29 的结尾把活儿分得很清楚:那一篇管"一个线程内部的调度",拆包留给 Day30(那是"字节怎么到浏览器")。这一篇就是回答它。
但"bundle 优化"这个名字本身就是第一个陷阱。它把一件三目标联合优化的事,说成了一个"把数字变小"的动作。先给结论:
bundle 优化不是"让 bundle 变小",而是在三个互相拉扯的预算里找平衡:初始字节、请求形态、失效面。 这三个预算里,前两个写在同一份构建配置里,第三个写成文件名里的一对方括号------而第三个几乎所有团队都不看,却拥有最大的杠杆。
本文要拆掉的三个"常识",都是我先把它们当真理写进脚本、然后被自己的断言推翻了:
- 「切得越细,首屏越快」 ------错。切分不改变初始字节 。同样 960KB 的入口,切成 16 片首屏 +60.27ms ,切到 32 片再 +61.16ms ,gzip 后体积从 317.38KB 涨到 346.22KB 、再到 360.67KB 。真正把首屏从 2770.6ms 压到 1278.21ms 的,是"把另外 1230KB 移出首屏"这个排除 动作,不是"把 960KB 切开"这个整形 动作(
chunk-budget.mjsPART 1)。 - 「做了路由切分就有收益」 ------不一定。如果共享依赖没被提升成独立片,而是被抄进了每个路由片,那么三次导航的累计传输入 671.9KB ,比不切分的单包 657KB 还多 2.3% ,耗时只省了 1.9%(2718.72ms vs 2770.6ms)。切分带来的收益,100% 来自"共享模块被提升",0% 来自"路由被切开"这个动作本身(PART 2)。
- 「bundle 优化看的是一次访问」 ------漏掉了最重要的一半。把用户 20 次回访的成本加起来:只做路由切分、文件名用
[fullhash]的方案总账是 24310.32ms,比干脆不切分的单包 23876.62ms 更差 。而把[fullhash]改成[contenthash]、把 module id 锁成确定性之后,同一份代码的总账是 8981.65ms(PART 4)。
本篇所有数字都来自三份零依赖 Node 脚本的实跑输出,合计 66 条不变量断言 ,断言失败即 exit 1。和 Day25~Day29 一样,它们是确定性简化模型 ,价值是把不同策略放进同一个坐标系里比较,而不是预测某台机器上的绝对毫秒数。参数与每份脚本的乐观之处在文末「运行环境」逐条写清楚了。
边界也说清:本篇讲字节怎么被组织、被请求、被缓存 。图片与字体 留给 Day31,Worker/OffscreenCanvas 留给 Day32,WASM 留给 Day33,性能监控与 RUM 留给 Day34,**构建工具本身(vite/webpack/turbopack 的架构差异)**留给 Day35------那一篇讲的是"谁来打包",这一篇讲的是"打包的结果该长什么样"。混着讲的结果通常是两边都说不透。
难点一:切分不改变初始字节,它改变请求形态
先把最干净的一张表摆出来。一个 2190KB(minified)的应用,首屏只需要其中的 960KB:
| 方案 | 初始KB | gzipKB | 层级 | h2 首屏ms | h1 首屏ms |
|---|---|---|---|---|---|
| A 单包 | 2190 | 657 | L0:1片 | 2770.6 | 2770.6 |
| B 路由切分·入口路由 lazy | 960 | 317.38 | L0:3片 + L1:1片 | 1330.11 | 1330.11 |
| C 路由切分 + modulepreload 入口 | 960 | 317.38 | L0:4片 | 1278.21 | 1278.21 |
| D 入口 960KB 切成 16 片 | 960 | 346.22 | L0:16片 | 1338.48 | 1448.48 |
| E 入口 960KB 切成 32 片 | 960 | 360.67 | L0:32片 | 1399.64 | 1674.64 |
把增量拆开看,三个动作的价格一目了然:
| 对比 | 差值 ms |
|---|---|
| A → C 把 1230KB 移出首屏(排除) | −1492.39 |
| B → C 给入口路由加 modulepreload(消瀑布) | −51.90 |
| C → D 把同样的 960KB 切成 16 片(整形) | +60.27 |
| D → E 再切细一档到 32 片 | +61.16 |
"排除"省 1492ms,"整形"赔 121ms。 这就是为什么"把 bundle 变小"这个说法是危险的:它把注意力全放在了整形上。
为什么切细一定赔?三笔账,每一笔都能算:
- 压缩率的损失 。gzip 的压缩比随输入变小而变差(我自己在模型里用的是一条随体积衰减的曲线):960KB 整体压到 0.301 ,60KB 的片压到 0.361 ,30KB 的片压到 0.376 。同样的 960KB,4 片时 gzip 合计 317.38KB ,32 片时 360.67KB ------多传 43.29KB,+13.6%。
- 请求固定开销。h2 复用了连接,但首部处理、流调度、缓存查找仍然按"每个请求"计费。片数从 4 涨到 32,多出 28 个请求。
- 编译上下文开销 。每个 chunk 是一份独立的脚本,V8 要为它单独建一次解析/编译上下文。这一笔很小,但它是加性的。
然后是最容易忽略的一笔:HTTP/1.1 的队列 。同一张表最后一列,D 方案(16 片)在 h1 上要排队 2 轮,代价 +110ms ;E 方案(32 片)要排 5 轮,代价 +275ms ------19.6%。所以在 h2 上"切细的代价约 5%"这个结论,一旦落到不支持并发的链路上会放大四倍。这也是为什么"我们已经在 h2 上了"这句话必须被验证,而不是被假设。
难点二:路由切分的收益全部来自"共享提升",不是来自"路由切开"
把"切分"和"提升"分开验证,是这一节的全部内容。同一个应用,用户依次访问 dash → order → report 三个路由:
| 方案 | 3 路由累计KB | 累计 gzipKB | 耗时ms |
|---|---|---|---|
| A 单包 | 2190 | 657 | 2770.6 |
| B 路由切分 + 共享依赖提升 | 1600 | 525.19 | 2084.45 |
| C 路由切分(无共享提升) | 2120 | 671.9 | 2718.72 |
逐跳看得更清楚:
| 方案 | order 一跳 | report 一跳 |
|---|---|---|
| B 提升后 | 109.96 KB | 97.84 KB |
| C 未提升 | 186.66 KB | 175.05 KB |
C 方案就是那个经典的、几乎每个项目都踩过一次的写法:
js
// 反例:每个路由 chunk 都把自己用到的共享模块抄一份
const routes = {
dash: () => import('./pages/dash'),
order: () => import('./pages/order'),
report: () => import('./pages/report'),
};
// pages/order 里 import { Table } from '@/components/table'
// -> Table 打进了 route-order,也打进了 route-dash、route-report
Table、Form、DateUtil 这类模块被复制进了 3 个路由片。首屏看起来一模一样(960KB),但每一次跨路由导航都在重新下载那 260KB 。三次导航下来,累计传输入 671.9KB > 657KB ------比不切分还贵。
所以判断"要不要切"的正确口径,不是"我切了几个路由",而是:
有没有任何一个模块,同时出现在了 ≥2 个会被同一个用户访问到的 chunk 里?
有,就先做提升,再谈粒度。这个口径是可以CI 化的 (见 2.1 的 budget-gate.mjs 里那一项 duplication),而且它比"bundle 总大小"这个指标灵敏得多------因为共享模块被复制的时候,总大小几乎不变,只有导航成本在变。
顺带一个推论:splitChunks.minChunks、cacheGroups 这些配置为什么存在?它们存在的唯一理由就是"把被多个 chunk 用到的模块提上来"。如果你的配置里没有一条规则在表达"谁被重复了",那你的切分大概率只是在整形。
难点三:第三个预算------失效面,没人看,杠杆最大
现在把时间轴从"一次访问"拉到"二十个版本 × 二十次访问"。
一个 960KB 的首屏、192 个模块(每个 5KB)。模拟 20 个发布,每个版本平均改动 11.4 个模块,其中 70% 聚集在一个热点目录。扫描粒度从 1 片到"一模块一片":
| 粒度 | 片均 | 首屏ms | 失效片数 | 失效KB | 失效gzipKB | 回访ms | 总账V=5 | 总账V=20 |
|---|---|---|---|---|---|---|---|---|
| 1 片 | 960KB/片 | 1246.08 | 1 | 960 | 288.85 | 1191.08 | 6010.4 | 23876.62 |
| 2 片 | 480KB/片 | 1260.74 | 1.9 | 912 | 288.14 | 1149.71 | 5859.57 | 23105.2 |
| 3 片 | 320KB/片 | 1270.6 | 2.55 | 816 | 264.99 | 1049.59 | 5468.97 | 21212.85 |
| 4 片 | 240KB/片 | 1278.5 | 2.8 | 672 | 222.42 | 893.14 | 4851.06 | 18248.16 |
| 6 片 | 160KB/片 | 1291.46 | 3.85 | 616 | 209.31 | 835.05 | 4631.66 | 17157.42 |
| 8 片 | 120KB/片 | 1302.46 | 4.2 | 504 | 174.4 | 711.92 | 4150.15 | 14829.01 |
| 12 片 | 80KB/片 | 1321.62 | 5.4 | 432 | 153.29 | 633.83 | 3856.96 | 13364.48 |
| 16 片 | 60KB/片 | 1338.82 | 5.95 | 357 | 128.91 | 550.58 | 3541.13 | 11799.8 |
| 24 片 | 40KB/片 | 1370.38 | 7.1 | 284 | 105.05 | 469.44 | 3248.14 | 10289.75 |
| 32 片 | 30KB/片 | 1399.98 | 7.9 | 237 | 89.15 | 416.77 | 3067.05 | 9318.55 |
| 48 片 | 20KB/片 | 1456.34 | 8.85 | 177 | 68.14 | 348.96 | 2852.18 | 8086.56 |
| 64 片 | 15KB/片 | 1510.74 | 10 | 150 | 58.68 | 318.44 | 2784.51 | 7561.16 |
| 96 片 | 10KB/片 | 1616.7 | 10.4 | 104 | 41.6 | 265.68 | 2679.42 | 6664.62 |
| 192 片 | 5KB/片 | 1925.86 | 11.4 | 57 | 23.66 | 211.38 | 2771.36 | 5942.01 |
三条结论都在这张表里:
第一,首屏几乎是平的。 从 1 片到 192 片,首屏从 1246.08ms 涨到 1925.86ms------绝对值看着不小(+679.78ms,+54.6% ),但要注意这是极端对比 :1 片到 16 片只涨了 92.74ms(+7.4%),16 片到 64 片涨了 171.92ms。在现实会用到的区间(4~32 片)里,"拆细拖慢首屏"的代价不到 10%。
第二,回访成本几乎是直线下降的。 从 1191.08ms/次降到 211.38ms/次------−82.3% 。因为失效字节从 960KB 掉到 57KB,而失效字节是"用户每次回访都要重新传输 + 重新解析"的那部分。失效片数从 1 涨到 11.4,正好等于"每个版本平均改动的模块数"------当你切到"一模块一片"时,失效面就等于改动面,这是一个数学上的下界,不可能再优化了。
第三,最优点取决于"用户会来几次"。
- V=1(只来一次):最优 1 片。 因为一次性用户根本不付失效的账,他只为首次加载付费。
- V=5:最优 96 片。
- V=20:最优 192 片(即"一模块一片")。
这不是模型的花活,而是这个问题的真实结构:
bundle 优化的最优粒度 = f(用户回访次数 V × 版本节奏 k)。 它不是代码的属性,是你的用户和你的发布流程的属性。
一个每天发 5 次版、DAU 里 80% 是回访用户的产品,和一个每季度发一版、绝大多数访客只来一次的活动页,最优拆包策略是相反的。这是"最佳实践"清单永远给不出答案的原因:它们不知道 V 和 k。
边际收益也在衰减,可以逐档看:
| 粒度变化 | 19 次回访省 | 首屏多付 | 净收益 |
|---|---|---|---|
| 3→4 片 | 2972.59 | 7.9 | 2964.69 |
| 4→6 片 | 1103.7 | 12.96 | 1090.74 |
| 16→24 片 | 1541.61 | 31.56 | 1510.05 |
| 48→64 片 | 579.79 | 54.4 | 525.39 |
| 96→192 片 | 1031.77 | 309.16 | 722.61 |
第一档的净收益是最后一档的 4.1 倍 。所以在工程上,"切到 16~32 片、并把失效面控制在一个可解释的范围"通常就是性价比的拐点------不是因为模型说 192 片不划算,而是从 32 片往上,你每多切一片,都要多付一份构建产物、CDN 缓存条目、发布流程和排障心智,这些成本不在毫秒里。
难点四:hash 和 id 的杠杆,大于粒度
现在看同一份代码、四种"文件名策略"。判定口径:用户 20 次回访的总账(首屏一次 + 19 次"重新下载 + 重新解析失效部分",未失效部分按命中 V8 code cache 的 0.15 倍计)。
| 文件名策略 | 每版失效KB | 失效gzipKB | 每次回访ms | 总账 V=20 |
|---|---|---|---|---|
single + [fullhash] |
960 | 288.85 | 1191.08 | 23876.62 |
route + [fullhash] |
960 | 317.38 | 1213.91 | 24310.32 |
route + [contenthash] + id 不稳定 |
780 | 256.68 | 1012.35 | 20480.65 |
route + [contenthash] + deterministic id |
236 | 78.17 | 407.13 | 8981.65 |
两个钉子:
钉子一:[fullhash] 会让"切分"变成负收益。 第二行 24310.32ms 比第一行的单包 23876.62ms 还差 。因为 [fullhash](也就是 webpack 5 里 [hash] 的新名字)是整次构建 的哈希:改一个字符,所有 chunk 的文件名全变,全部失效。于是你付了"首屏多 4 个请求"的代价,却一分缓存收益都没拿到。这是端到端最亏的一种配置:把它做对了是 8981.65,做错了是 24310.32,差 2.7 倍,而代码一个字都没改。
钉子二:module id 的稳定性,单独一项的杠杆就有 2.3 倍。 第三行只是"id 不稳定",失效面就从 236KB 涨到 780KB(8981.65 → 20480.65)。原因是:当模块的 id 由数字序号(moduleIds: 'natural' / 'named' 的非确定性变体)或时间戳、路径排序决定时,新增/删除任何一个模块,都会让它后面所有模块的 id 平移,于是引用它们的 chunk 内容全变、hash 全变。
这两条加起来说明一件事:
在"切多细"这个问题上纠结之前,先确认"切了之后能不能真的省下传输"。 文件名和 id 的稳定性,是切分收益的前置条件,不是它的补充项。
难点五:摇树的开关不在打包器,在 package.json
摇树(tree shaking)这件事最容易被误解的地方是:大家以为它是打包器的能力问题,实际上是模块作者有没有给出"可以删"的证明。
我用 200 行实现了一遍"按导出可达性做消除"的引擎(treeshake-model.mjs PART A:工作队列 + 名字解析 + 模块结算),然后拿同一个图标库(120 个图标、每个 1.6KB)跑四种写法,业务只用其中 1 个:
| 写法 | 打进包的模块数 | 保留字节 | 相对深导入 |
|---|---|---|---|
深导入 icons/Icon7(绕过桶) |
3 个 | 4.2 KB | 1x |
桶导入 + sideEffects: false |
4 个 | 4.3 KB | 1.024x |
桶导入 + 未声明 sideEffects |
123 个 | 194.7 KB | 46.357x |
桶导入 + export default { ... } 命名空间对象 |
123 个 | 195.1 KB | 46.452x |
一个 package.json 字段,190.4KB。 按本文 1ms/KB 的解析单价,首屏多付 190.4ms------而代码里那行 import 一个字都没变。
四个机制值得写清楚:
- 桶文件不是罪,未声明才是。 同样的
import { Icon7 } from 'icons',声明了sideEffects: false之后保留 4.3KB (只比深导入多 0.1KB 的转发代码)。所以"禁止桶文件"这条军规是错的,正确的军规是"桶文件的包必须声明sideEffects"。 export *在"未声明"时是灾难。 打包器无法证明被转发的 120 个模块没有顶层副作用,也无法确定名字到底解析到哪一个,于是只能全部求值------194.7KB。export default { ... }会把摇树关掉。 命名空间的成员是运行期属性访问,静态分析看不到你只用了Icon7,于是 195.1KB。这是"为了 import 方便"付出最贵代价的一种写法。- 动态
import()的键必须是静态可枚举的。import('./icons/' + name)或者switch里对未知值的分支,都会让打包器保留所有分支(webpack 的 "Critical dependency: the request of a dependency is an expression" 警告就是在说这件事)。
而 sideEffects: false 的另一面,是它可以删掉有用的代码 。treeshake-model.mjs 的 PART C 是一段可执行证明:
sideEffects 声明 |
顶层副作用 | 运行结果 |
|---|---|---|
| 正确(默认 unknown) | 保留 | ok |
错误声明 false |
被消除 | return broken |
注意结果是 broken 而不是抛错 ------这是这类 bug 最阴险的形态:polyfill 模块顶层的 globalThis.__SUPPORTS_BIGINT = ... 被当成无用代码删了,而依赖它的 checkBigInt() 不报错,只是静默地返回错误结果。
所以:sideEffects: false 不是性能开关,是事实声明。 它省下的是字节,赔上的可能是所有"注册类"代码------polyfill、全局样式注入、装饰器注册、customElements.define、dayjs.extend 这一类"导入即生效"的模块。写这个字段之前必须逐个确认:这个包里的每个模块,顶层是不是真的没有可观察行为?
难点六:发现层级 = RTT,这是唯一一条"线性于层级"的成本
回到 PART 1 的第二行:B 方案(入口路由 lazy)比 C 方案(入口路由 modulepreload)慢 51.9ms 。同一批字节、同样的片数,唯一的区别是这一片是在入口 chunk 的静态 import 列表里被声明,还是等入口 chunk 解析完、执行到 import() 那一行才被发现。
模型里这一笔就是"多一个 RTT",而 RTT 是 55ms。这个数量级值得单独强调,因为它是唯一一条会随层级线性叠加的成本:
- 静态
import会被打包器压平成"入口 chunk 的 import 列表",所有静态片都在第 0 层 被发现,浏览器并行发起------层级深度为 1。 - 动态
import()的片在第 1 层才能被发现,因为入口必须先下载、解析、执行到那一行。 - 如果
route-order里又有import('./order/detail'),detail就在第 2 层。 - 如果
detail里再有一个按需加载的图表库,就是第 3 层。
三层嵌套 = 三个 RTT = 165ms ,而且这 165ms 是纯等 ,任何压缩、任何 CDN 都救不回来。这就是为什么 modulepreload / webpackPreload / vite 的 build.modulePreload 这些"提前声明下一层"的机制,价值不在"更早开始下载",而在把层级压平回第 0 层。
一个可以立刻用的判断:
js
// 入口路由:不要 lazy,要静态引入 + 让打包器 preload
// ❌ 首屏多一个 RTT
const Dash = () => import('./pages/dash');
// ✅ 首屏路由进第 0 层;其余路由继续 lazy
import Dash from './pages/dash';
"所有路由都必须 lazy"是一条被误传的军规:入口路由是首屏的一部分,把它 lazy 出去,等于主动把首屏关键路径拉长一个 RTT。正确做法是"入口路由静态 import,其余路由 lazy,并在路由切换时预取相邻路由"。
二、完整解法
上面六个难点可以压缩成一句执行顺序,顺序不能换:
先排除,再提升,再锁死失效面,最后才调粒度。
理由:粒度是唯一一个"调错了收益为负"的旋钮,而它排在最前面时,你既没有基线也没有护栏。下面五步,每一步都有可运行的东西。
2.1 第一步:把"三张表"变成 CI 的一个命令
绝大多数团队量的是"bundle 总大小"。这个数字有三个问题:它把永远用不到的 chunk 算进首屏;它看不见请求数 ;它对失效面完全无感。
所以第一件事是把度量口径换成四个数。day30-assets/budget-gate.mjs 就是干这个的------零依赖,读一份 chunk 清单,输出四项判定:
bash
$ node budget-gate.mjs stats.sample.json
budget-gate: entry = main,初始闭包 = 5 片
main, route-dash, shared, vendor, runtime
[PASS] 初始字节 974KB (上限 1000KB)
[PASS] 初始 gzip 324KB (上限 330KB)
[PASS] 初始请求数 5片 (上限 6片)
[PASS] 单模块失效面 340KB (上限 360KB)
[PASS] 业务模块重复 0个 (上限 0个)
[PASS] 重复体积 0KB (上限 0KB)
失效面最大模块: src/pages/order/index.tsx -> [route-order]
结果: 全部通过
换成那份"朴素路由切分"的样本,同一个脚本会立刻红掉三项:
bash
$ node budget-gate.mjs stats.bad.json
[PASS] 初始字节 974KB (上限 1000KB)
[PASS] 初始 gzip 317KB (上限 330KB)
[PASS] 初始请求数 4片 (上限 6片)
[FAIL] 单模块失效面 1628KB (上限 360KB) 超 +1268KB
[FAIL] 业务模块重复 3个 (上限 0个) 超 +3个
[FAIL] 重复体积 436KB (上限 0KB) 超 +436KB
被复制到多个 chunk 的业务模块:
- src/components/table.tsx x3 (+240KB) [route-dash, route-order, route-report]
- src/components/form.tsx x3 (+180KB) [route-dash, route-order, route-report]
- src/utils/date.ts x3 (+16KB) [route-dash, route-order, route-report]
注意第二份的初始字节和 gzip 都是 PASS ------它和第一份的首屏字节几乎一样(974KB vs 974KB)。如果只看 bundle size,你会认为两者等价。 但它的失效面是 1628KB,重复 436KB。这正是"总大小"这个指标最误导人的地方:共享模块被复制时,总体积几乎不动,只有导航成本和失效面在变。
四个口径各自的算法(都在脚本里):
js
// 1) 初始闭包:从 entry 出发,沿【静态】import 可达的所有 chunk
function initialClosure(entryName) {
const seen = new Set(); const stack = [entryName];
while (stack.length) {
const name = stack.pop();
if (seen.has(name) || !chunks.has(name)) continue;
seen.add(name);
const c = chunks.get(name);
if (c.dynamic) continue; // 动态 chunk 不属于初始闭包
for (const dep of c.staticImports) stack.push(dep);
}
return [...seen];
}
// 2) 失效面(最坏口径):改动落在"覆盖面最大"的业务模块上
// 一个模块若被 k 个 chunk 包含,它的改动会让这 k 个 chunk 全部失效
for (const [mod, owners] of moduleToChunks) {
if (mod.startsWith('node_modules/')) continue;
const kb = owners.reduce((s, n) => s + chunks.get(n).kb, 0);
if (kb > worstChurn.kb) worstChurn = { module: mod, kb, owners };
}
// 3) 重复:业务模块出现在 >1 个 chunk 里就是缺陷(三方库被有意复制是另一回事)
// 4) 请求数:初始闭包的 chunk 数
把这份脚本挂进 CI,exit 1 就是红线。关键是 duplication 这一项------它是最便宜的护栏,因为它一旦报警,就说明你的切分正在做负功。
2.2 第二步:排除------把字节移出首屏
这是唯一能拿到"千毫秒级"收益的动作(PART 1:−1492.39ms)。三条规则,按优先级:
规则一:入口路由静态引入,其余路由 lazy。
js
// routes.tsx
import Dash from './pages/dash'; // ✅ 首屏路由:静态,进第 0 层
const Order = lazy(() => import('./pages/order')); // 其余:动态
const Report = lazy(() => import('./pages/report'));
// 路由表里 Dash 不用 Suspense 包,因为它已经同步可用
规则二:给入口闭包的每一片加 modulepreload。 现代打包器会在构建期自动生成(vite 默认开、webpack 需要 preload 插件或用 import(/* webpackPreload: */))。手写形态是:
html
<link rel="modulepreload" href="/assets/shared.a1b2c3.js" crossorigin>
<link rel="modulepreload" href="/assets/vendor.d4e5f6.js" crossorigin>
<link rel="modulepreload" href="/assets/route-dash.g7h8i9.js" crossorigin>
这一条买到的是 51.9ms(一个 RTT),成本是 3 行 HTML。
规则三:把"不用"的东西真的删掉,而不是 lazy 掉。 lazy 只是把它从首屏移到第二层;如果它从来没用过,就应该从依赖树里去掉(见 2.5 的摇树)。"用 lazy 掩盖一个过大的依赖"是最常见的假优化:它让首屏数字好看,让用户在第一次导航时付全价。
2.3 第三步:共享提升------让"被复用的模块"只有一个副本
这一步决定"切分是正收益还是负收益"(PART 2)。webpack 5 的配置(版本差异以你所用版本为准):
js
// webpack.config.js
module.exports = {
output: {
filename: 'assets/[name].[contenthash:8].js',
chunkFilename: 'assets/[name].[contenthash:8].js',
},
optimization: {
moduleIds: 'deterministic', // 见 2.4:这一行比任何切分粒度都重要
chunkIds: 'deterministic',
runtimeChunk: 'single', // 把 webpack runtime 抽出来,避免它跟着业务 chunk 一起变
splitChunks: {
chunks: 'all', // 关键:async 之外还要管 initial
minSize: 20000, // 小于 20KB 的片不单独出(压缩损失会吃掉收益)
minChunks: 2, // 被 >=2 个 chunk 用到就提上来 ← 这就是"共享提升"
cacheGroups: {
react: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
name: 'vendor-react',
priority: 40, // 优先级高的先匹配
chunks: 'all',
enforce: true,
},
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 20,
chunks: 'all',
minChunks: 2, // 只被一个入口用到的库,留在原地,别硬提
},
shared: {
test: /[\\/]src[\\/](components|utils|hooks)[\\/]/,
name: 'shared',
priority: 30,
chunks: 'all',
minChunks: 2,
},
},
},
},
};
vite(Rollup)里对应的写法是 manualChunks,语义上更"手写",容易漏掉共享提升:
js
// vite.config.ts
export default defineConfig({
build: {
modulePreload: { polyfill: true }, // 入口闭包自动 preload
cssCodeSplit: true,
rollupOptions: {
output: {
// 函数形态:能拿到 id,才能表达"谁被复用"
manualChunks(id) {
if (id.includes('node_modules')) {
if (/node_modules\/(react|react-dom|scheduler)\//.test(id)) return 'vendor-react';
return 'vendor';
}
if (/src\/(components|utils|hooks)\//.test(id)) return 'shared';
// 其余交给 Rollup 按路由动态 import 自动切
},
// Rollup 4:把过小的 chunk 合并回父 chunk,避免碎成几十片
experimentalMinChunkSize: 20_000,
},
},
},
});
三个"必须理解为什么"的点:
chunks: 'all'而不是默认的'async'。默认值只对动态 import 生效,于是首屏里的共享模块不会被提升------首屏那 3~4 片之间的重复就留在那儿了。minChunks: 2是"提升"的定义本身 。没有这一条,cacheGroups只是在按目录改名。minSize: 20000是"压缩损失"的价格线。PART 1 已经算过:片从 240KB 缩到 30KB,gzip 比从 0.301 涨到 0.376。所以"把每个组件切成一片"在字节上一定亏。
2.4 第四步:锁死失效面------比调粒度重要得多
PART 4 的三个数字(24310.32 / 20480.65 / 8981.65)说明,这一步的杠杆是倍数级的。四行配置:
js
// 1) 用 contenthash,永远不用 fullhash/hash
output.filename = 'assets/[name].[contenthash:8].js';
// 2) module id / chunk id 锁成确定性(webpack 5 生产模式默认是 deterministic,
// 但只要有人为了调试改成 'natural'/'named',或者插件按时间戳生成 id,地板就塌了)
optimization.moduleIds = 'deterministic';
optimization.chunkIds = 'deterministic';
// 3) runtime 单独成片:runtime 里保存着 chunk 的映射表,它跟着业务变是必然的,
// 但不能让它把 vendor 一起拖下水
optimization.runtimeChunk = 'single';
// 4) CSS 也用 contenthash,且跟着它的组件走
new MiniCssExtractPlugin({ filename: 'assets/[name].[contenthash:8].css' });
还有一个容易漏的:CSS 与 JS 的分离粒度要一致 。如果 JS 按路由切、CSS 却抽成一个全局大文件,那么任何一个组件的样式改动都会让整个 CSS 文件失效 ,PART 4 的收益会被吃掉一大半。这也是 cssCodeSplit 存在的理由。
排障口诀 :连续两次构建同一份代码,产物 hash 却变了 → 一定是某个环节引入了非确定性(时间戳、随机 id、Date.now() 写进 banner、namedChunks、或者插件里遍历了未排序的 Set)。修这个比调粒度划算一百倍。
2.5 第五步:摇树------把"可以删"的证明补齐
PART 5 的 46 倍差距,全部落在"声明"上。要做四件事:
(1) 自己发布的包,声明 sideEffects。 精确到目录,而不是一句 false 图省事:
jsonc
// package.json(业务 monorepo 内部的组件库 / 工具库)
{
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills/**", // 这些是"导入即生效",不能删
"./src/styles/global.ts" // 同上
]
}
sideEffects: false 的语义是"这个包里所有模块的顶层都没有可观察行为 "。写错的表现不是报错,是静默错误(PART C 的 return broken)。
(2) 桶文件照写,但必须落在声明了 sideEffects 的包里。 PART B 已经证明:桶 + sideEffects: false = 4.3KB,深导入 = 4.2KB,差 0.1KB。所以"禁止桶文件"这条军规是错的,正确的是:
js
// src/components/index.ts ------ 桶文件本身没问题
export { Table } from './table';
export { Form } from './form';
前提是 src/components 所属的 package.json 声明了 sideEffects。
(3) 禁止 export default { ... } 作为 API 出口。 195.1KB 那一行就是它的价格。用具名导出:
js
// ❌ 摇树关掉
export default { formatDate, parseDate, diffDays };
// ✅ 具名导出,逐个可删
export function formatDate() {}
export function parseDate() {}
export function diffDays() {}
(4) 动态 import 的路径必须静态可枚举。 想按名加载图标,就要给出字面量映射(让打包器能展开成静态分支),而不是拼接字符串:
js
// ❌ 打包器无法枚举,全部保留
const mod = await import(`./icons/${name}.js`);
// ✅ 显式映射:打包器能看到 120 个确定的候选,未用到的可被摇掉
const ICONS = {
heart: () => import('./icons/heart.js'),
star: () => import('./icons/star.js'),
};
顺带一条纪律:新加依赖时,先看它的 package.json 有没有 "type": "module" 和 sideEffects。 这两个字段决定了它在你的包里是"可摇的 ESM"还是"整块石头"。CJS/UMD 入口在 ESM 项目里基本等于摇不动------这是选型阶段就该看的(Day58 会展开)。
2.6 第六步:把三张表挂进流程
yaml
# .github/workflows/bundle-budget.yml
- run: npm ci && npm run build -- --json > stats.json # 产出 chunk 清单
- run: node scripts/budget-gate.mjs stats.json # 四项红线,超了 exit 1
- run: npx size-limit # 或 bundlemon,做历史曲线
- run: node scripts/budget-gate.mjs stats.json --json > budget.json
- uses: actions/upload-artifact@v4
with: { name: bundle-budget, path: budget.json }
门槛要按"路由"分级,而不是一个全局数字。 首页 1000KB 的红线,不该套在设置页上。经验值(按 Day25 那套"字节 → 解析时间"的换算):
| 指标 | 建议红线 | 依据 |
|---|---|---|
| 入口闭包 minified | ≤ 1000KB | 解析 ≈ 1s 量级,已经吃掉大半 TBT 预算 |
| 入口闭包 gzip | ≤ 330KB | 4G 下 ≈ 260ms 传输 |
| 初始 chunk 数 | ≤ 6 | 超过后请求开销与压缩损失开始咬人 |
| 单模块失效面 | ≤ 400KB | 回访用户每次发布要重下的量 |
| 业务模块重复 | 0 | 没有例外 |
2.7 纪律清单
- 先量失效面,再谈粒度。
[contenthash]+deterministicid 没到位之前,任何切分调整都可能是负收益。 - 入口路由不 lazy。 首屏的一部分 lazy 出去 = 主动加一个 RTT。
- 共享模块必须提升。 判断口径是"有没有模块出现在 ≥2 个会被同一用户访问的 chunk 里",不是"我切了几个路由"。
- 粒度按"V × k"定,不按 bundle size 定。 回访多、发布勤 → 可以切细;一次性流量 → 切粗。
minSize是压缩损失的价格线,minChunks才是提升开关。 两者别混。sideEffects: false是事实声明。 写错不报错,只静默出错。export default {}出口 = 关掉摇树。 具名导出。- 动态 import 的路径不能拼接。 静态可枚举,否则分支全保留。
- CI 的红线里必须有"重复体积 = 0"。 这是最便宜、最灵敏的一条。
- 连续两次构建 hash 必须一致。 变了就是有非确定性,先修它,再谈优化。
三、应用场景
场景一:首屏从 2.1MB 砍到 960KB,LCP 却几乎没动
现象:bundle 分析器显示首屏 chunk 从 2190KB 降到 960KB(−56%),LCP 只从 3.1s 降到 3.0s。
原因 :LCP 的决定因素是"首屏最大元素何时渲染",而那个元素往往卡在图片 和关键 CSS 上,不在这 960KB 里(图片字体是 Day31 的事)。bundle 的收益体现在 TBT/INP(解析与执行是主线程活),不在 LCP。
动作 :把观测口径换掉------bundle 改动看 TBT、INP、以及 First Contentful Paint 之后的 long task 总数;LCP 交给图片与关键渲染路径。用错指标比不优化更糟:你会为了 LCP 的数字去动一个不相干的旋钮。
场景二:切了路由之后,导航变慢了
现象:从单包改成路由懒加载,首屏快了,但每次点菜单都白屏 400~600ms。
原因 :两个叠加。① 共享模块没提升,每个路由片自带一份 Table/Form/DateUtil(PART 2:order 一跳 186.66KB 而不是 109.96KB);② 动态 import 让每次导航都多一个 RTT。
动作 :先补 splitChunks.cacheGroups(minChunks: 2、chunks: 'all'),把重复打到 0;再给路由加"悬停/进入视口预取":
js
// 导航前预取:把第 1 层的 RTT 提前到用户犹豫的 200ms 里
const order = () => import(/* webpackPrefetch: true */ './pages/order');
// 悬停即触发
<Link onMouseEnter={() => order()} to="/order">订单</Link>
场景三:日更 5 次的产品,把包切到 192 片
现象 :团队按"最优粒度"把首屏切到 192 片,首屏 +679.78ms,回访每次省 979.7ms,账面漂亮。上线后 CDN 命中率没变,但构建时间翻倍、发布排障困难、Safari 上偶发"某些 chunk 404"(发布时新旧 chunk 混布)。
原因 :模型里没建模的三件事:构建产物数量、CDN 缓存条目数、以及新旧版本共存窗口。片数越多,发布瞬间"用户拿着旧 HTML 请求新 chunk"的概率越大。
动作 :把粒度退回 16~32 片(PART 3 里 3→4 片的边际净收益是 96→192 片的 4.1 倍),同时做两件事:① 保留上一个版本的 chunk(Cache-Control: max-age=31536000 + 保留 N 个旧版本目录);② 用 index.html 短缓存 + 原子发布。发布管理是 Day55 的事,但它决定了你敢切多细。
场景四:只改了一个文件名规则,总账少了一半
现象 :一次重构,把 [fullhash] 改成 [contenthash],代码逻辑零改动,用户侧"每次发版都要重新下载 1MB"的现象消失。
数字 :PART 4 的总账从 23876.62ms → 8981.65ms (2.66 倍),每版失效字节从 960KB → 236KB 。中间还夹着一档"只切分不修 hash"的 24310.32ms------比不切分更差。
动作:把这条做成构建期断言:
js
// 连续两次构建,产物 hash 必须完全一致
const a = JSON.parse(execSync('node build.js --manifest').toString());
const b = JSON.parse(execSync('node build.js --manifest').toString());
assert.deepEqual(Object.keys(a).sort().map(k => a[k]), Object.keys(b).sort().map(k => b[k]));
场景五:import { debounce } from 'lodash-es' 之后包大了 190KB
现象 :从 lodash 换成 lodash-es,本以为会瘦,结果没变。原因:lodash-es 的 package.json 早期版本没有 sideEffects: false,或者项目里 import _ from 'lodash-es' 做了命名空间导入。PART B 的第四行就是这个形态:195.1KB。
动作 :① 确认依赖版本声明了 sideEffects: false;② 一律具名导入;③ 用 import { debounce } from 'lodash-es/debounce' 这种深导入做对照验证------如果深导入明显更小,说明摇树在你的配置里没生效。
场景六:声明 sideEffects: false 之后白屏
现象 :给内部组件库加了 "sideEffects": false,某几个页面的样式全丢、customElements.define 报未注册。
原因 :PART C 那一行 return broken。样式导入(import './button.css')、polyfill、customElements.define、dayjs.extend、Reflect.metadata 这类"导入即生效"的模块,顶层有可观察行为,被声明成纯之后全部被删。
动作 :把 sideEffects 写成数组 ,精确列出"有副作用"的路径(2.5 的第一段代码)。默认 false + 例外列表,是比默认"未知"更危险的一种写法,因为它把"我没想过"变成了"我保证没有"。
场景七:HTTP/1.1 链路上,32 片比 16 片慢 226ms
现象 :本地和预发都是 h2,切到 32 片只慢 5%;灰度到某条老链路上,同样的产物慢了 19.6%(PART 1:h1 下 16 片 1448.48ms、32 片 1674.64ms,而 h2 分别 1338.48 / 1399.64)。
原因:每域名 6 个并发连接。32 片 = 5 轮排队,每轮一个 RTT。
动作 :① 先确认真实链路的协议(response.protocol,别信配置文件);② 在 h1 链路上把片数压到 ≤12;③ 长期方案是确保全链路 h2------"我们用了 h2"必须是测出来的,不是配出来的。
场景八:一个模态框带来 3 个 RTT
现象:点击"导出报表",转圈 600ms 才出内容。
原因 :report/index → 动态引入 chart → 动态引入 echarts,三层嵌套 = 3 个 RTT = 165ms,再加上每一层的下载与解析。
动作 :把"已知必然一起用"的模块合并(chart 与 echarts 打在一片),并把第一层用 modulepreload 提到第 0 层:
html
<link rel="modulepreload" href="/assets/report-chart.g7h8i9.js">
判据 :层级数 > 2 就是设计缺陷。第 3 层开始,每个 RTT 都在为一个"用户还不一定需要"的功能付费。
场景九:vendor 片每次发版都变
现象 :业务代码只改了一行按钮文案,vendor.[hash].js 的 hash 也变了,用户重新下载 300KB。
原因 :三种可能,按概率排序:① runtimeChunk 没开,webpack 的 chunk 映射表写在主 chunk 里,牵一发动全身;② moduleIds 不是 deterministic,新增模块导致 id 平移;③ 某个插件往每个 chunk 里注入了版本号/时间戳 banner。
动作 :按 ①→②→③ 的顺序检查,每一层修完都跑一次"连续两次构建 hash 一致"的断言。这个断言应该和单元测试一样常驻。
四、总结
把这一篇压成四句话:
- 切分不产生收益,排除才产生收益。 同一份 960KB,切成 16 片赔 60.27ms、32 片赔 121.43ms;而把 1230KB 移出首屏省 1492.39ms。"bundle 变小"这个目标,90% 的收益来自减法,10% 来自整形。
- 切分的收益全部来自共享提升。 三次导航的累计传输入 671.9KB(未提升)> 657KB(不切分),525.19KB(提升后)。判断口径是"有没有模块出现在 ≥2 个同用户可达的 chunk 里",不是"我切了几个路由"。
- 第三个预算是失效面,它比前两个都大。 20 次回访的总账:24310.32(route+fullhash)→ 20480.65(id 不稳定)→ 8981.65(contenthash + deterministic id),2.7 倍差距,代码零改动。在调粒度之前先修这一项,是本文最便宜的一条建议。
- 最优粒度由"V × k"决定,不由 bundle size 决定。 V=1 最优 1 片,V=5 最优 96 片,V=20 最优 192 片;边际净收益从 2964.69 衰减到 722.61。所以"最佳拆包实践"这个问题没有通用答案------它需要知道你的用户来几次、你一天发几版。
最后回到那句最容易被说反的话:bundle 优化不是把数字变小,而是决定"哪些字节值得出现在用户的第一个 RTT 里,以及它们值不值得被缓存"。 前者是排除,后者是命名。两个动作都比"切得更细"重要一个数量级。
运行环境
脚本 (day30-assets/,全部零依赖、确定性、断言失败 exit 1):
| 脚本 | 覆盖 | 断言数 |
|---|---|---|
chunk-budget.mjs |
PART 1 首屏 / PART 2 导航累计 / PART 3 粒度扫描 / PART 4 hash 与 id | 58 |
treeshake-model.mjs |
PART A 可达性引擎 / PART B 五种写法 / PART C 副作用可执行证明 | 8 |
budget-gate.mjs |
CI 四闸门(初始字节、gzip、请求数、失效面、重复) | 退出码判定 |
stats.sample.json / stats.bad.json |
良好样本 / 朴素切分样本 | --- |
复现:
bash
node day30-assets/chunk-budget.mjs
node day30-assets/treeshake-model.mjs
node day30-assets/budget-gate.mjs day30-assets/stats.sample.json # 全 PASS,exit 0
node day30-assets/budget-gate.mjs day30-assets/stats.bad.json # 3 项 FAIL,exit 1
关于单价常数的来源(重要,请勿跳过) :NAV_RTT_MS=55、TRANSFER_KB_MS=0.8(≈1250KB/s)、PARSE_KB_MS=1.0、REQ_OVERHEAD_MS=2.5、CHUNK_PARSE_OVERHEAD_MS=0.6、CODE_CACHE_FACTOR=0.15、H1_WAVE_SIZE=6,以及 gzip 比曲线 0.45 − 0.05·log10(KB)(夹在 0.30~0.45 之间)------这些是按中端设备校准的量级常数,不是任何一份可引用的公开测量。
我检索过:web.dev、MDN、Chrome for Developers 与 Lighthouse 都公布口径与阈值 (LCP 2.5s / INP 200ms / CLS 0.1 按 p75;Long Task 门槛 50ms;modulepreload 的语义;sideEffects 字段被 webpack/rollup/esbuild 消费),但没有 公布"每 KB minified JS 解析多少毫秒 / 每个额外请求多少毫秒 / gzip 比随体积怎么变"这类数字。所以请把本文的毫秒数当成同一坐标系内的相对量:它们的用途是回答"排除比整形值多少""模糊优化与锁死失效面哪个杠杆大",而不是"你的机器上会花 2770.6ms"。
为对抗这一点,chunk-budget.mjs 在 PART 3 做了 14 档粒度扫描,把"失效字节随粒度单调不增""首屏随片数单调不减""边际净收益递减"这三条保序结论钉在所有档位上;PART 1/2/4 的结论只依赖"加性成本 + 一个 RTT"这个结构,不依赖单价的具体取值。
各脚本的额外简化与已知的乐观之处:
chunk-budget.mjs:① 把同层 chunk 视为并行下载、带宽串行累加 ,未建模 TCP 慢启动、h2 流控窗口与队头阻塞;② 解析成本按 minified KB 线性 计,真实 V8 有惰性解析(pre-parser)与函数级延迟编译,大函数体与小函数的单价不同;③CODE_CACHE_FACTOR=0.15是"命中 V8 code cache 后重新解析"的量级估计,未建模 code cache 的容量上限与淘汰 ;④ "一次发布改动的模块"是用固定种子的 PRNG 合成 的(70% 聚集在一个热点目录),不是任何真实仓库的 churn 分布------它的作用是把"失效面"变成可复算的算术;⑤ 失效面按最坏口径 (模块所在 chunk 全失效),真实打包器有更细的 hash 传播,可能只失效部分;⑥ 未建模 CSS 的失效 、Cache-Control差异、CDN 边缘命中率、发布窗口内新旧版本共存;⑦ 未建模 Terser/Rollup 的跨模块优化 (属性名压缩、常量折叠)------切成多片会让这一层优化失效,所以"切细的代价"在真实项目里通常比本文更大;⑧ PART 4 的"id 不稳定"是按"vendor/shared/dash 三片全失效"建模的,真实情况取决于 id 平移影响了多少模块。treeshake-model.mjs:① 引擎是简化版 :export *的名字解析用"同名即命中"近似,真实的 webpack/rollup 有更完整的符号表与__esModule互操作处理;② "未声明sideEffects⇒ 被 re-export 的模块全量保留"是对文档化行为的建模 ,不同打包器、不同版本的具体保留范围不同,请以你所用版本的实际产物为准;③ 图标模块的 1.6KB/个 与 120 个的规模是构造的;④ PART C 用new Function+ 参数注入globalThis模拟沙箱,不是真实的打包器行为,它证明的是"删掉顶层副作用会导致静默错误"这个因果,不是"webpack 一定这么删"。budget-gate.mjs:① 需要把 webpackstats.json或 vitemanifest.json归一化成脚本认的格式(chunks[].{name,kb,gzipKb,modules,staticImports,dynamic}),归一化这一步本身要按你的打包器写;② 失效面用最坏口径 ,会偏保守(对 CI 是好事:宁可早报);③ 重复检测只覆盖业务模块,node_modules被有意复制进多个入口的情况不计入。
版本与语义约定(与"模型不确定性"分开标注------这几条是当前实现的事实,落地前应逐条核对目标环境):
[contenthash]vs[fullhash]:webpack 5 里[hash]已废弃,[fullhash]是"整次构建"的哈希,[contenthash]是"该资源内容"的哈希;chunk 文件名必须用[contenthash]。生产模式下optimization.moduleIds与chunkIds默认都是'deterministic',但显式写出来是必要的------因为改回'natural'或'named'只需要一行,而代价是本文量级的 2.3 倍。splitChunks默认值 :webpack 5 的chunks默认'async'、minSize默认约 20000、minChunks默认 1。"默认配置不做 initial 的共享提升"这一条,是"切了路由却没拿到缓存收益"的最常见原因。sideEffects:由 webpack / rollup / esbuild 读取的package.json字段,语义是"该包的模块顶层无可观察行为"。写错的后果是删代码,不是报错。modulepreload:<link rel="modulepreload">让浏览器在解析 HTML 阶段就抓取并预解析 模块图(MDN 与 HTML 规范),这正是"把层级压回第 0 层"的依据。vite 通过build.modulePreload自动生成;webpack 需要preload-webpack-plugin一类工具或 magic comment。export default { ... }与摇树:命名空间对象的成员是运行期属性访问,静态分析无法确定"你只用了其中一个"。这条不是实现缺陷,是静态分析的边界。- 动态 import 的表达式 :
import(expr)的expr不是字面量时,打包器无法枚举候选(webpack 会给出 "Critical dependency: the request of a dependency is an expression" 警告),只能保留整个目录或全部候选分支。 - HTTP/1.1 的每域名 6 连接:来自浏览器实现惯例(Chrome/Firefox/Safari 均为 6),不是规范强制;h2 用单连接多路复用,但受流控窗口限制。
- 上述版本性事实会随版本变化;本文的模型与断言不依赖这些行为,它们只影响"哪些配置项对应本文的哪一种策略"。
自测暴露并修复的 6 个真实问题(保留在此,因为它们都属于"模型写错会让结论反向"的类型,且现在每条都有对应断言守着):
- 把"大文件压得更好"写成了比较两个不同总量的绝对字节 (
chunk-budget.mjsPART 1):初版断言"单包 gzip 后小于 4 片之和",实跑 657KB(2190KB 的单包)当然大于 317.38KB(960KB 的四片)------拿不同分母比绝对量,是彻底的逻辑错误 。改成比较 gzip 比(0.301 vs 0.361 vs 0.376)之后,"切细会多传 43.29KB / +13.6%"这个正确的数才浮现出来。 - 断言"未提升的切分在耗时上也输给单包"------方向反了 :实跑 2718.72ms vs 2770.6ms,它确实快了 1.9%。这条失败的断言反而产出了难点二的准确表述:未提升的切分"多付 2.3% 的字节,换到 1.9% 的时间",等于白折腾,但现在断言的是"时间收益 < 5% 且字节更贵",而不是"更慢"。
- h1 溢价阈值被我写死成 1.1 倍 :16 片在 h1 上只贵 8.2%(1448.48 vs 1338.48),断言失败。修正后改成相对断言:"32 片的 h1 溢价(19.6%)必须大于 16 片的 h1 溢价(8.2%)的 1.5 倍"------这才抓住了"排队溢价随片数放大"这个真结论。
- 粒度网格出现了重复行 :
groupSize = ceil(N/n)让 n=12/16/20/24/32 都落到同一个 chunk 数上,表格里出现三行完全相同的数据,边际收益算出0,"边际递减"断言因此失败。改成用实际 chunk 数做键后,网格变成 1/2/3/4/6/8/12/16/24/32/48/64/96/192,边际序列才有了意义。 - 以为"切成 2 片"能省一半失效字节 :实跑从 960KB 只掉到 912KB(失效片数 1.9)。原因是每版平均改 11.4 个模块,改动散布时两块 480KB 的巨片几乎总是一起被触碰 。这条失败断言直接产出了难点三的核心结论:片数必须超过"每版改动的散布块数",切分才开始有缓存收益 ------1→2 片甚至出现了负的净收益(−233.4ms)。
treeshake-model.mjsPART C 第一版两边都返回ok:因为new Function里写的globalThis.__X = true污染了真实的 全局对象,第二次运行时读到上一次的残留。把globalThis作为函数参数注入沙箱(new Function('globalThis', src)再fn.call({}, {}))之后,才复现出return broken------顺带确认了这类 bug 的真实形态:不是抛错,是静默返回错误值。
关于"为什么没有一个能收进口号的名字" :本文的结论全是交换 ------用首屏换算失效面、用请求数换压缩率、用粒度换运维成本。没有一个能概括成"这样做就对"。如果一定要留一句,就留那句执行顺序:先排除,再提升,再锁死失效面,最后才调粒度。