KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
模块与构建 — keel 龙骨
模块与构建 的参考信息:模块与构建
课程导读 · 每一条结论都要能在自己的 Node 上跑出同一个报错
这门课解决的是同一类问题:代码在本机能跑、装到 CI 里跑不了;本地 tsc 绿的、上线白屏;删了两千行代码,产物一个字节没瘦;明明只 import 了一个函数,样式却没了;instanceof 在地图上飘出 false。它们的共同点是——你写下的那行 import 被交给了一个解析器,而你不清楚这个解析器按什么规则办事。
你现在的起点
- 日常写
import/require,知道.mjs大概和 ESM 有关,但说不清"一个.js文件到底按哪套规范加载"; - 见过
Cannot use import statement outside a module,也见过require is not defined in ES module scope,但没意识到这两个错的抛出时机不一样; - 用过
exports字段,遇到过ERR_PACKAGE_PATH_NOT_EXPORTED,通常是搜一下把版本降回去; - 打开过打包产物,看到一堆
__commonJS/__toESM前导,没细想过它们为什么在那儿; - 相信"ESM 支持摇树,CJS 不支持",但没验证过自己项目里到底摇了多少。
如果你符合上面这几条,这门课就是为你写的。前提是你会写 JavaScript 并且跑得起来 Node——这门课不教语法,它教的是"这行字被谁翻译、翻译成了什么"。
一条能走通的学习路径
先定盘(01 一个文件按哪套规范加载)
→ 判定链:扩展名 → 就近 package.json 的 type → 语法嗅探
→ 两条方向相反的报错,一条抛在解析期、一条抛在运行期
再拆语义(02 导出的是"接线"还是"快照")
→ 活绑定、只读视图、import 提升
→ 循环依赖:一边抛 TDZ 错,一边静默 undefined
然后是整门课最实用的一章(03 一行 import 落到磁盘上哪个文件)
→ exports 是包围盒;条件靠键序;子路径模式是字符串替换
交界处(04 两套规范相遇时的形状)
→ default 到底等于什么、named 是猜出来的、require(esm) 的边界
发布形态(05 双格式发布的账单)
→ 单例变两份、instanceof 失效、计数器重数
产出侧(06 打包器能看见什么)
→ 它只做静态分析;摇不动的地方恰恰是有函数调用的那行
收口(07 三套解析器,三个答案)
→ Node / 打包器 / TypeScript 各按各的规则解析同一行 import
你会拿到什么
| 章节 | 一句话 | 关键动作 |
|---|---|---|
| 01 一个文件,两套加载规范 | "type" 不是唯一判定者 |
判定矩阵真跑 + 两个方向的报错原文与抛出阶段 |
| 02 导出的是接线还是快照 | 同一段"改了就生效"的代码,一边生效一边不生效 | 活绑定三态对照 + 循环依赖双形态(TDZ 错 vs 残缺 exports) |
| 03 一行 import 落到磁盘上哪个文件 | exports 是一道包围盒 |
23 个探针各自的报错码 + 条件选择的键序规则 |
| 04 两套规范相遇时的形状 | default 不是你以为的那个东西 |
九种 CJS 导出写法 × 两种导入方式 + require(esm) 边界 |
| 05 双格式发布的账单 | 一份代码会被加载两次 | 同进程双侧对照:单例 id、instanceof、计数器 |
| 06 打包器能看见什么 | 摇不动的地方是有函数调用的那行 | 逐例产物字节数 + 残留标记 grep + 摇树粒度表 |
| 07 三套解析器,三个答案 | 编译通过 ≠ 运行时找得到 | 三档 moduleResolution × 产物能否真跑 |
每章结构统一:现场 → 形态 → 原因 → 本章脉络 → 生产边界 → 动手:可观察结果 → 故障注入 → 自测题 → 现在能解释什么。
三个必须先纠偏的认知
"type": "module"不是一个"把文件变成模块"的开关。 它只是判定链上的一环。Node 22 起,一个完全没有package.json的目录里,写了export的.js会被嗅探为 ESM 并正常跑完(实测 exit=0)。反过来,你显式写"type": "commonjs"会把嗅探关掉。所以"我的文件是什么模块"这件事,答案取决于一条三段判定链,而不是某一个字段。import不是require的语法糖。 导入进来的是接线(导出方改了,你这边跟着变),CJS 里写进字面量的是快照(永远不变)。报错时机也不同:import出现在 CJS 里是解析期的SyntaxError,require出现在 ESM 里是运行期的ReferenceError。循环依赖上更极端:ESM 抛错(fail fast),CJS 静默undefined(fail silent,只附一条警告)。打包器摇不掉一段"看起来没用"的代码,通常不是它笨,而是那行初始化代码里有一次函数调用。 实测:未使用的
const OBJ = {...}、数组、函数、类、纯字面量拼接全部被摇掉;而const CALL = "x" + "y".repeat(400)、"MARK".toLowerCase()、Math.random()一个都没摇掉。"明显是纯函数"是你的判断,不是打包器的判断。
学完这门课你能做什么
- 拿到一个"本地能跑、CI 跑不了"的模块报错,能先判断它抛在解析期还是运行期,从而知道该改文件内容还是改加载方式;
- 看到
ERR_PACKAGE_PATH_NOT_EXPORTED,能读着包的exports字段说出"我要的那条路径有没有被登记",而不是直接降版本; - 遇到
instanceof失效、配置改了不生效、单例变成两套,能立刻怀疑到双格式发布,并知道怎么用一个探针证实; - 收到"包体积怎么又大了"的反馈,能定位到是哪个模块的哪一行初始化表达式挡住了摇树,而不是笼统地说"这个库不支持摇树";
- 看到
tsc绿但运行时ERR_MODULE_NOT_FOUND,能说出是哪一档moduleResolution允许了无扩展名导入,以及它的产物为什么跑不起来。
前置要求
- 会写 JavaScript,能跑起一个 Node 脚本(这门课全程用 Node,不依赖任何框架);
- 建议先读同板块的 Prisma 数据访问层——那门课建立了本板块的纪律:结论必须落在"它实际发出了什么"上。这里只是把"产物"从 SQL 换成了模块图与 bundle;
- 模模糊糊记得
npm install之后node_modules里有什么就够了,不需要会发包。
替身边界
实验以 Node 22.22.2(另有 24.9.0 对照)+ esbuild 0.28.2 + TypeScript 7.0.2 为准。这一行比任何框架都动得快——本课程记下的多处行为都是被某一版改过的:"type" 非法值会被静默忽略、.js 的语法嗅探默认开启(且开关形式是 --no-experimental-detect-module)、require(esm) 从"要 flag"变成"默认可用"、moduleResolution: "node10" 在 TS 7 里已被移除。
结论的形状("判定链的顺序"、"绑定是接线还是快照"、"exports 是包围盒"、"包围盒里条件靠键序")跨版本成立;具体字段名、默认值、报错文案请在你自己的版本上复核。
实验全部是单机、小文件、无网络。凡涉及字节数与毫秒数的地方,请只看比值和形状,不要抄绝对值。