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)。这一章就是把这条线画出来,然后回答你真正遇到的那个问题——你删掉的那两千行,本来就没进产物;撑着体积的,是你删不掉的那部分。

一、前置认知:它没有运行时,它只有语法

打包器能看到的只有源码文本。它不执行你的代码,也没有任何运行时信息。所以「这段代码没人用」对它是一个语法问题:能不能在不执行任何东西的前提下,证明这段代码对输出没有影响。

两个推论,本章各用一次:

判据先给出:它删不掉的东西不是「有用的东西」,是「它不敢判的东西」。

术语纪律:本章所有摇树行为都是 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 验证机制。)

给你一条能直接用的判据。 遇到「产物降不下来」时,按这个顺序走:

  1. 在产物里 grep 你埋的 MARK_* 标记,找出哪些本该没用的导出还留在产物里;
  2. 回到源码看这些导出的初始化表达式——凡是带 .方法( 的,就是挡住摇树的那一行;
  3. 想让它被摇掉,三条路选一条:
    • 把初始化改成纯字面量/对象/数组。E7b 里的 LIT 就是这么写的(生成期把 "y".repeat(400) 展开成了 400 个字面量字符),摇掉了;写成 "y".repeat(400) 的 CALL 留下了。同一段语义,两种写法,一个走一个留。
    • 给调用加 /* @__PURE__ */——第 ③ 条实测 178 B → 59 B。代价是这句承诺由你负责。
    • 把初始化挪进函数体,让「没人调用」变成语法上可判。

别把 /* @__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 的红框。

生产边界

动手:可观察结果

先做一件事:三行改一处,看产物落到哪一格。 用第 ③ 条那对文件做底:

步骤 改什么 期望落到哪一格
起点 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 并真的用它 样式还会被丢吗——验证「没有导出」才是这张免死金牌的触发条件

自测题

  1. 同一份文件里,unused() 被删、CONST_UNUSED 留下,两者都没被使用。差别在哪一个语法特征上?
  2. const OBJ = {...} 摇得掉,const CALL = "x" + "y".repeat(400) 摇不掉。用「打包器能证明什么」解释,并说明为什么 "x".toLowerCase() 也在「摇不掉」那一类里。
  3. /* @__PURE__ */ 是什么?谁在验证这个承诺?写错了后果是什么?
  4. 模块顶层那句 console.log 明明没人用,为什么摇不掉?它和 sideEffects: false 有什么直接关系?
  5. --splitting 之后两个 chunk 加起来(163 + 99)比不切分(828)小。这个「变小」是你要的结果吗?切分真正买到的是什么?
  6. 为什么 sideEffects: false 会把一个样式 import 整包丢掉?为什么 style.js 这个文件特别危险?
  7. 同一个 import "condlib",Node 和 esbuild 分别加载哪个文件?再补一套——TypeScript 的两档 moduleResolution 各解析到哪个文件?为什么这会让「本地能跑、打包后不一样」成为常见事故?
  8. 打包器为什么能 import { a, b } from 一个 CJS 对象字面量,而 Node 会 SyntaxError?那 1.4 KB 的前导在做哪件事?

现在能解释什么

下一步:07 章 · 三套解析器,三个答案 —— 这一章你已经在三个地方碰到了同一个问题:同一行 import,Node 落到一个文件、打包器落到一个文件、TypeScript 又落到一个文件。下一章把这三套规则并排放到同一张表上,然后回答那个最常见的现象:tsc 全绿,产物跑不起来。

进入 keel 阅读