KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
06 · 打包器能看见什么 — keel 龙骨
这一章回答:打包器凭什么判断「这段代码没人用」?为什么你删了两千行,产物一个字节没瘦?
这一章回答:打包器凭什么判断「这段代码没人用」?为什么你删了两千行,产物一个字节没瘦?
现场:同一个文件里,一个没用的函数被删了,另一个没用的常量留下了
// src/lib/util.mjs
export function used() {
return "MARK_USED";
}
export function unused() {
return "MARK_UNUSED" + "x".repeat(400);
}
export const CONST_UNUSED = "MARK_CONST_UNUSED" + "y".repeat(400);
// src/e1-tree.mjs —— 只用了一个导出
import { used } from "./lib/util.mjs";
console.log(used());
$ esbuild --bundle --format=esm src/e1-tree.mjs --outfile=bundles/e1.js
bundles/e1.js 161b
Done in 10ms
产物全文:
// src/lib/util.mjs
function used() {
return "MARK_USED";
}
var CONST_UNUSED = "MARK_CONST_UNUSED" + "y".repeat(400);
// src/e1-tree.mjs
console.log(used());
grep -o 'MARK_[A-Z0-9_]*' bundles/e1.js | sort | uniq -c 只报两个:MARK_USED 一次、MARK_CONST_UNUSED 一次。
MARK_UNUSED 一次都没有——unused() 被删了。而 CONST_UNUSED 同样从没被用过,它留下了。
停一下。你会怎么判断?
先别急着说「esbuild 有 bug」。同一份文件里这两个东西的差别只有一处:一个函数声明的函数体里写着 return,一个初始化表达式里有一次 .repeat(400)。这一章就是把这条线画出来,然后回答你真正遇到的那个问题——你删掉的那两千行,本来就没进产物;撑着体积的,是你删不掉的那部分。
一、前置认知:它没有运行时,它只有语法
打包器能看到的只有源码文本。它不执行你的代码,也没有任何运行时信息。所以「这段代码没人用」对它是一个语法问题:能不能在不执行任何东西的前提下,证明这段代码对输出没有影响。
两个推论,本章各用一次:
- 摇不掉 ≠ 你在用。 它可能只是无法证明没人用。
CONST_UNUSED就是这一类。 - 摇得掉 ≠ 安全。 它的证明建立在语法假设上,而假设可以是错的(第四节那条事故)。
判据先给出:它删不掉的东西不是「有用的东西」,是「它不敢判的东西」。
术语纪律:本章所有摇树行为都是 esbuild 0.28.2 在 --bundle --format=esm 下的实测。换个打包器,规则会变;但「能不能在语法上证明」这条判据不变。
二、六个对照:命令、字节数、残留标记
① 只 import 一个导出 → 161 B
$ esbuild --bundle --format=esm src/e1-tree.mjs --outfile=bundles/e1.js
bundles/e1.js 161b
残留标记:MARK_USED 1 次、MARK_CONST_UNUSED 1 次。unused() 被删,CONST_UNUSED 留下——本章的核心谜题,第三节解。
② 模块顶层的 console.log → 158 B
// src/lib/effect.mjs
console.log("MARK_EFFECT_SIDE_TOPLEVEL");
export function touched() {
return "MARK_TOUCHED";
}
export function untouched() {
return "MARK_UNTOUCHED" + "z".repeat(400);
}
$ esbuild --bundle --format=esm src/e2-effect.mjs --outfile=bundles/e2.js
bundles/e2.js 158b
残留标记:MARK_EFFECT_SIDE_TOPLEVEL 1 次、MARK_TOUCHED 1 次;MARK_UNTOUCHED 0 次。产物里那句 console.log 原样保留。
结论:函数能摇,语句不能。 模块顶层的一句 console.log 被当成副作用——打包器无法证明它没影响,所以它跟着模块一起留着。这就是你说的「删了两千行没瘦」的另一半原因:代码是按「能不能证明不影响」算的,不是按行数算的。
③ 无注解 vs /* @__PURE__ */ → 178 B → 59 B
// src/e3-pure-off.mjs
import { makeBig } from "./lib/pure.mjs";
const v = makeBig();
console.log("MARK_ENTRY_3", v ? "yes" : "no");
$ esbuild --bundle --format=esm src/e3-pure-off.mjs --outfile=bundles/e3-off.js
bundles/e3-off.js 178b
产物里 makeBig 整段都在(MARK_PURE_CALL 1 次):
// src/lib/pure.mjs
function makeBig() {
return "MARK_PURE_CALL" + "w".repeat(400);
}
// src/e3-pure-off.mjs
var v = makeBig();
console.log("MARK_ENTRY_3", v ? "yes" : "no");
同一件事只加一个注解、且不再用 v 判断:
// src/e3-pure-on.mjs
import { makeBig } from "./lib/pure.mjs";
const v = /* @__PURE__ */ makeBig();
console.log("MARK_ENTRY_3", "done");
$ esbuild --bundle --format=esm src/e3-pure-on.mjs --outfile=bundles/e3-on.js
bundles/e3-on.js 59b
// src/e3-pure-on.mjs
console.log("MARK_ENTRY_3", "done");
178 B → 59 B,整个 pure.mjs 被删(MARK_PURE_CALL 0 次)。/* @__PURE__ */ 是你对打包器的承诺:这个调用没有副作用,结果又没人用,可以整段丢掉。它信你,不检查你。
④ 打包器 import 一个 CJS 对象字面量 → 1647 B
// src/lib/plain.cjs
module.exports = { a: "MARK_CJS_A", b: "MARK_CJS_B" };
// src/e4-cjs.mjs
import { a, b } from "./lib/plain.cjs";
console.log(a, b);
$ esbuild --bundle --format=esm src/e4-cjs.mjs --outfile=bundles/e4.js
bundles/e4.js 1.6kb
--- 字节数: 1647 ---
MARK_CJS_A、MARK_CJS_B 都在。打包成功。
但同一份源文件交给 Node 直接跑,是这条:
SyntaxError - Named export 'a' not found. The requested module './lib/plain.cjs' is a CommonJS module,
which may not support all module.exports as named exports.
差在哪?产物开头有约 1.4 KB 的互操作前导。它把整个 CJS 模块包成一个运行时执行的函数:
var require_plain = __commonJS({
"src/lib/plain.cjs"(exports, module) {
module.exports = { a: "MARK_CJS_A", b: "MARK_CJS_B" };
}
});
var import_plain = __toESM(require_plain(), 1);
console.log(import_plain.a, import_plain.b);
这 1.4 KB 就是「跨规范是有成本的」的物证。 打包器不靠语法去猜命名导出,它选择在运行时把对象造出来、再把属性拷到 import_plain 上。代价写在字节数上:一个三行的小文件,产物 1647 B,其中绝大部分是你从没写过、也永远不会写的前导。
⑤ 动态 import():828 B 单文件 vs 163 B + 99 B
// src/e5-dynamic.mjs
export async function load() {
const m = await import("./lib/heavy.mjs");
return m.big();
}
console.log("MARK_ENTRY_5");
$ esbuild --bundle --format=esm src/e5-dynamic.mjs --outfile=bundles/e5-nosplit.js
bundles/e5-nosplit.js 828b
--- 出现过的标记 ---
1 MARK_ENTRY_5
1 MARK_HEAVY
MARK_HEAVY 在里面——heavy.mjs 被塞进了同一个文件。
$ esbuild --bundle --format=esm --splitting --outdir=bundles/e5split src/e5-dynamic.mjs
bundles/e5split/e5-dynamic.js 163b
bundles/e5split/heavy-D2RRHZ5M.js 99b
163 + 99 = 262 B,比 828 B 小。但「变小」不是你要的结果——切分买到的是那 99 B 不在首屏的加载路径上:MARK_ENTRY_5 在 163 B 那个入口 chunk 里,MARK_HEAVY 在 99 B 那个带内容哈希(D2RRHZ5M)的 chunk 里,只有真的 await import() 才去取。注意两条命令的差别:切分那次用的是 --splitting --outdir(产出一个目录、多个文件),不切分用的是 --outfile(一个文件)。
⑥ --platform 决定条件导出走哪一支
包 condlib 的 exports 是 { ".": { "browser": "./browser.mjs", "node": "./node.mjs", "default": "./def.mjs" } },入口只有一行 import { which } from "condlib"。
| 命令 | 产物 | 残留标记 | 走了哪一支 |
|---|---|---|---|
--platform=browser |
125 B | MARK_COND_BROWSER |
browser.mjs |
--platform=node |
119 B | MARK_COND_NODE |
node.mjs |
--platform=neutral |
121 B | MARK_COND_DEFAULT |
def.mjs(default) |
三份产物字节数几乎一样(125 / 119 / 121),内容却不同——同一行 import,被三个平台选项解析到了三个文件。这是下一节「两套解析器」的入口。
三、解开第 ① 条的谜题:摇树粒度是单变量的
E7b 做了个单变量实验:一个文件里放八种未被使用的导出,入口只 import 其中一个,看谁留下。产物 1044 B,残留标记只有四个:MARK_CALL、MARK_LIT、MARK_RANDOM、MARK_UPPER。
| 导出的初始化形态 | 未被使用 | 结果 |
|---|---|---|
OBJ = { … } 对象字面量 |
是 | 摇掉 |
ARR = [ … ] 数组字面量 |
是 | 摇掉 |
fnUnused() {} 函数声明 |
是 | 摇掉 |
class ClsUnused {} |
是 | 摇掉 |
LIT = "…" + 400 个字面量 y 纯字面量拼接 |
是 | 摇掉 |
CALL = "…" + "y".repeat(400) |
是 | 留下 |
RANDOM = "…" + Math.random()… |
是 | 留下 |
UPPER = "MARK_UPPER".toLowerCase() + "…" |
是 | 留下 |
(MARK_OBJ、MARK_ARR、MARK_FN_UNUSED、MARK_CLS_UNUSED 在产物里都是 0 次,所以那四行是「摇掉」。)
还有一次对照:把入口换成 import "./vars.mjs"(什么都不用),产物 620 B,残留只剩 MARK_CALL、MARK_RANDOM、MARK_UPPER——MARK_LIT 这次也没了,因为 LIT 变成未使用,而它是纯字面量拼接。
规则一句话:
字面量、对象、数组、函数、类都摇得掉;初始化表达式里只要出现一次「属性访问 + 调用」,esbuild 就保守保留——哪怕是 "x".toLowerCase() 这种显然纯的。
机制也可以一句话说:打包器无法证明这个调用没有副作用——被访问的那个属性可能被改写过,而 String.prototype.toLowerCase 本身就是一个可以被赋值的属性。凡是「取属性 + 调用」这个形状,它一律不判。
(这条机制是从「行为只与有没有『属性访问 + 调用』相关」这个单变量结果反推的。E7b 证明了行为,没有去改写 String.prototype 验证机制。)
给你一条能直接用的判据。 遇到「产物降不下来」时,按这个顺序走:
- 在产物里
grep你埋的MARK_*标记,找出哪些本该没用的导出还留在产物里; - 回到源码看这些导出的初始化表达式——凡是带
.方法(的,就是挡住摇树的那一行; - 想让它被摇掉,三条路选一条:
- 把初始化改成纯字面量/对象/数组。E7b 里的
LIT就是这么写的(生成期把"y".repeat(400)展开成了 400 个字面量字符),摇掉了;写成"y".repeat(400)的CALL留下了。同一段语义,两种写法,一个走一个留。 - 给调用加
/* @__PURE__ */——第 ③ 条实测 178 B → 59 B。代价是这句承诺由你负责。 - 把初始化挪进函数体,让「没人调用」变成语法上可判。
- 把初始化改成纯字面量/对象/数组。E7b 里的
别把 /* @__PURE__ */ 加在真有副作用的调用上。那不是在帮打包器,那是在骗它。
四、sideEffects: false 与那条经典事故
一个包 effpkg-off,package.json 里写着 "sideEffects": false,它有一个 style.js:
// node_modules/effpkg-off/style.js
console.log("MARK_STYLE_OFF_INJECTED");
globalThis.__styleOff = true;
你需要它的样式,于是写一行裸导入:
// src/e7-style-sideeffects-false.mjs
import "effpkg-off/style.js";
console.log("MARK_ENTRY_7");
$ esbuild --bundle --format=esm src/e7-style-sideeffects-false.mjs --outfile=bundles/e7-off.js
▲ [WARNING] Ignoring this import because "node_modules/effpkg-off/style.js" was marked as having no side effects [ignored-bare-import]
src/e7-style-sideeffects-false.mjs:1:7:
1 │ import "effpkg-off/style.js";
╵ ~~~~~~~~~~~~~~~~~~~~~
"sideEffects" is false in the enclosing "package.json" file:
node_modules/effpkg-off/package.json:5:2:
5 │ "sideEffects": false,
╵ ~~~~~~~~~~~~~
1 warning
bundles/e7-off.js 67b
产物 67 B,全文只有一行 console.log("MARK_ENTRY_7");MARK_STYLE_OFF_INJECTED 0 次。样式没了,而且只在终端里换回一行 warning。
对照包 effpkg-on 只把 sideEffects 改成 true,其他一模一样:
bundles/e7-on.js 172b
--- 出现过的标记 ---
1 MARK_ENTRY_7B
1 MARK_STYLE_ON_INJECTED
67 B 对 172 B,差的正是那个被整包丢掉的副作用模块。
为什么 sideEffects: false 会把样式 import 也一起丢掉?因为 style.js 在语法上就是一个没有导出的模块——它只有顶层语句。对打包器来说,一个「没有导出、又被声明为没有副作用」的模块跟着走只会白占体积。而第 ② 条已经量过:没有这句声明的时候,顶层语句是被当成副作用保留的。 所以 sideEffects: false 干的事,就是把第 ② 条那层保护撤掉。
sideEffects 是对打包器的承诺,不是对运行时的事实。 你写下 false 的那一刻,说的是「我这个包里没有顶层副作用」;而 CSS 注入恰恰就是一个顶层副作用。
判据:最容易被 sideEffects: false 干掉的是「没有导出的模块」——样式注入、polyfill、globalThis 补丁、原型扩展、注册表注册。它们和第 ② 条那句顶层 console.log 是同一类东西:只有顶层语句、没有导出。区别只在于,第 ② 条里没人给它们签那张免死金牌。
修法是别用 false(那等于宣称整个包都纯),改成数组形式的例外清单,把真正有副作用的文件列出来,惯例写法形如 "sideEffects": ["**/*.css", "./src/polyfill.js"]。(这条是社区惯例,不是本实验室的实测值。)
本章脉络
flowchart TD
A["入口只 import 了一个导出"] --> B["① 161 B<br/>unused() 删除<br/>CONST_UNUSED 留下"]
A --> G["② 158 B<br/>顶层 console.log 保留<br/>untouched() 删除"]
A --> H["④ 1647 B<br/>__commonJS / __toESM 前导约 1.4 KB"]
A --> I["⑤ 不切分 828 B<br/>--splitting 后 163 B + 99 B"]
A --> J["⑥ --platform<br/>browser 125 B / node 119 B / neutral 121 B"]
B --> C{"初始化表达式里<br/>有没有属性访问 + 调用?"}
C -- 有 --> D["保守保留<br/>CALL / RANDOM / UPPER"]
C -- 没有 --> E["摇掉<br/>OBJ / ARR / 函数 / 类 / 纯字面量拼接"]
D -.-> F["③ 加 /* @__PURE__ */<br/>178 B → 59 B,整模块被删"]
E -.-> F
C -.-> K["裸 import 一个没有导出的模块<br/>遇上 sideEffects: false<br/>→ 整包丢掉 67 B(对照 172 B)"]
style D fill:#ffebee,color:#b71c1c
style K fill:#ffebee,color:#b71c1c
style F fill:#e8f5e9,color:#1b5e20
图里在说什么。 顶上那个方框是「一个入口、只用一个导出」这个统一前提,六条实线分支就是第二节的六个对照,编号 ① 到 ⑥ 与正文一模一样,方框里带上各自的字节数——你会发现它们的差别不在「谁更省」,而在哪一份输入被解析成了什么。左边的 ① 往下走是本章的主线:它接到一个判断节点(有没有属性访问 + 调用),这个节点分出两条路——有,就保守保留(红框,CALL/RANDOM/UPPER);没有,就摇掉(OBJ/ARR/函数/类/纯字面量拼接)。两条路都能靠 /* @__PURE__ */ 那条虚线汇到 ③ 的绿框(178 B → 59 B,整个模块消失),这正是「摇不动的地方,是你自己可以拆的地方」。右下那条最长的虚线是第四节那条事故的完整路径:裸 import 一个没有导出的模块,再遇上 sideEffects: false,整包丢掉变成 67 B 的红框。
生产边界
- 打包器和 Node 是两套解析器。 同一行
import "condlib",--platform=neutral走def.mjs(第 ⑥ 条实测),而 Node 面对同一个exports走的是require/import那两支(E4 实测:require→index.cjs,import→index.mjs)。第三套还在旁边——TypeScript 的moduleResolution(E8 实测,包名也是condlib、条件形状相同):bundler档解析到def.mjs,node16档解析到node.mjs。三套解析器,三个答案。「本地 dev 能跑、打包后行为不一样」的根因就在这:你以为只有一份真相,其实每个工具各按各的规则选文件。遇到这类事故,先问一句「这一行是被哪个工具解析的」。 - 打包器处理 CJS 是「运行时包装」,不是「静态分析」。 第 ④ 条的产物把
module.exports = {…}整段包成__commonJS里的一个函数,再用__toESM做一次属性拷贝。这意味着在 CJS 模块内部,摇树没有着力点:一整块module.exports = {…}对它是运行时代码,不是可分析的声明。你在 Node 里靠 lexer 静态猜命名导出(E5),在打包器里靠运行时包装拿——两条路,两套规则。 - 切分要
--outdir,单文件用--outfile。 第 ⑤ 条那两条命令的差别就是这个。另外别把「切分后两个 chunk 加起来更小」当成收益指标:163 + 99 比 828 小,但你要买的从来不是总量,是首屏路径变短。 - 字节数只看形状和比值。 161 / 158 / 178 / 59 / 67 / 172 / 1044 / 620,全是一两个小文件的结果。要记的是量级差异(百字节位 vs 十字节位)和「整模块有没有消失」,别把绝对值搬到自己项目里当预期。
- 本实验室没测
--minify。 全部产物都是未压缩、未混淆的。压缩会改字节数,但不会改判据——判据是「grep 到哪些MARK_*」,不是「产物多少字节」。这也是为什么本章每个实验都埋了标记。 /* @__PURE__ */和sideEffects都是「你说、它信」的机制。 两个都写在实验里,两个都不会被验证。写错的后果不对称:@__PURE__写错只多留一点体积,sideEffects: false写错是功能消失(第四节那条事故)。
动手:可观察结果
先做一件事:三行改一处,看产物落到哪一格。 用第 ③ 条那对文件做底:
| 步骤 | 改什么 | 期望落到哪一格 |
|---|---|---|
| 起点 | const v = makeBig(); + console.log("MARK_ENTRY_3", v ? "yes" : "no") |
百字节位(实测 178 B),MARK_PURE_CALL 在产物里 1 次 |
| 改一处 | 调用前加 /* @__PURE__ */,并且不再用 v 判断 |
十字节位(实测 59 B),MARK_PURE_CALL 归零,整个 pure.mjs 消失 |
| 再改一处 | 把这次调用整段移出入口(或删掉) | 仍是十字节位那一档的形态:产物只剩入口的一条语句,依赖一点不剩 |
每一步都用 grep 标记验证,不要只用字节数:MARK_* 的个数从 2 → 1 → 0,比字节数更稳。
| 产出 | 判断标准 |
|---|---|
| 六个对照各自的产物 | 能说出每一份产物里哪些标记还在、哪些归零,以及为什么 |
| 一张摇树粒度对照表 | 能自己造出「摇掉 / 留下」两类各三个例子,并指出分类依据是同一个语法特征 |
一次 sideEffects 开关对照 |
能拿出 67 B 与 172 B 两份产物,并把那条 [ignored-bare-import] warning 原文抄下来 |
| 一次切分对照 | 能指出 MARK_HEAVY 落在哪个 chunk 里,并说明切分买到的是什么(不是总量) |
一次 --platform 三连 |
能指出三份产物各残留哪个 MARK_COND_*,并把它和 Node 侧的条件选择对上 |
完成标志:拿到「包体积又大了」的反馈时,你能先定位到是哪个模块的哪一行初始化表达式挡住了摇树(grep 标记 → 看初始化表达式有没有 .方法(),再决定是用 @__PURE__、改写法,还是接受它——而不是笼统地说一句「这个库不支持摇树」。
故障注入
| 注入方式 | 观察 |
|---|---|
把 CONST_UNUSED 的初始化从 "y".repeat(400) 改成写死的 400 个 y |
它还留下吗(对照 E7b 的 LIT) |
给 const v = makeBig(); 加 @__PURE__,但继续用 v ? "yes" : "no" |
产物回落到哪一格;注解救不了「结果被使用」这件事 |
删掉 effect.mjs 顶部那句 console.log |
untouched() 之外的东西能不能一起消失;模块能不能整块不见 |
把那句 console.log 改成赋值给 globalThis |
保留还是消失(提示:它仍是顶层语句) |
--platform 从 browser 依次换成 node、neutral |
每个产物里是哪个 MARK_COND_*;和 Node 侧挑的文件一样吗 |
把 effpkg-off 的 sideEffects 改成 true |
回到 172 B 那一格,[ignored-bare-import] 的 warning 消失 |
在 effpkg-off/style.js 里加一个非空 export const x = 1 并真的用它 |
样式还会被丢吗——验证「没有导出」才是这张免死金牌的触发条件 |
自测题
- 同一份文件里,
unused()被删、CONST_UNUSED留下,两者都没被使用。差别在哪一个语法特征上? const OBJ = {...}摇得掉,const CALL = "x" + "y".repeat(400)摇不掉。用「打包器能证明什么」解释,并说明为什么"x".toLowerCase()也在「摇不掉」那一类里。/* @__PURE__ */是什么?谁在验证这个承诺?写错了后果是什么?- 模块顶层那句
console.log明明没人用,为什么摇不掉?它和sideEffects: false有什么直接关系? --splitting之后两个 chunk 加起来(163 + 99)比不切分(828)小。这个「变小」是你要的结果吗?切分真正买到的是什么?- 为什么
sideEffects: false会把一个样式 import 整包丢掉?为什么style.js这个文件特别危险? - 同一个
import "condlib",Node 和 esbuild 分别加载哪个文件?再补一套——TypeScript 的两档moduleResolution各解析到哪个文件?为什么这会让「本地能跑、打包后不一样」成为常见事故? - 打包器为什么能
import { a, b } from一个 CJS 对象字面量,而 Node 会SyntaxError?那 1.4 KB 的前导在做哪件事?
现在能解释什么
- 为什么「删了两千行、产物一个字节没瘦」——你删的是本来就没进产物的东西,撑着体积的是打包器不敢判的那部分;
- 为什么「看起来没人用」和「它删不掉」可以同时成立:摇树的依据是能不能在语法上证明,不是「有没有用」;
- 为什么初始化表达式里只要有一次「属性访问 + 调用」,这段代码就留了下来——以及一条能立刻上手的定位办法(
grep标记 → 看初始化表达式); - 为什么
/* @__PURE__ */和sideEffects是「你说、它信」的机制,以及两种写错的后果为什么不对称(多留体积 vs 功能消失); - 为什么打包器 import CJS 会带出那 1.4 KB 的
__commonJS/__toESM前导——跨规范是有成本的,物证就在字节数里; - 为什么
--platform一变,同一行 import 就换了一个文件——以及 Node、打包器、TypeScript 是三套解析器,三个答案。
下一步:07 章 · 三套解析器,三个答案 —— 这一章你已经在三个地方碰到了同一个问题:同一行 import,Node 落到一个文件、打包器落到一个文件、TypeScript 又落到一个文件。下一章把这三套规则并排放到同一张表上,然后回答那个最常见的现象:tsc 全绿,产物跑不起来。