KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 一个文件,两套加载规范 — keel 龙骨
这一章回答:同一个 .js 文件,Node 会按哪套规范加载它?为什么两个方向相反的报错,连"什么时候抛"都不一样?
这一章回答:同一个 .js 文件,Node 会按哪套规范加载它?为什么两个方向相反的报错,连"什么时候抛"都不一样?
现场:同一目录,两个方向相反的报错
小林在改一个工具仓。type-module/ 下的 package.json 里写着 "type": "module"。她跑两个文件:import-in-cjs.cjs 第一行是 import { readFileSync } from "node:fs";,require-in-esm.mjs 第一行是 const fs = require("node:fs");。
$ node type-module/import-in-cjs.cjs
(node:85296) Warning: Failed to load the ES module: ...\import-in-cjs.cjs.
Make sure to set "type": "module" in the nearest package.json file or use the .mjs extension.
SyntaxError: Cannot use import statement outside a module
at wrapSafe (node:internal/modules/cjs/loader:1637:18)
--- exit=1 ---
$ node type-module/require-in-esm.mjs
ReferenceError: require is not defined in ES module scope, you can use import instead
at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
--- exit=1 ---
两个报错在互相指着对方说:上面那条判定这个文件不是模块,下面那条判定这个文件是模块。同一份 package.json 下,两个文件被判成了相反的东西。而且那条 Warning: Failed to load the ES module 是误导的——它劝你"记得设置 type: module",可这个目录的 type 明明已经是 module,它出现的地方是一个明确写了 .cjs 的文件。
先别往下看。你会怎么判断? 写下答案:这两个文件各按哪套规范加载?为什么同一份 package.json 下判定相反?两个报错为什么一个炸在加载里、一个炸在运行里?
一、判定链:扩展名 → 就近 package.json 的 type → 语法嗅探
Node 决定"按 ESM 还是 CJS 加载",走的是三段顺序判定,前一段命中就不看后一段。
① 扩展名一票定。 .mjs 永远 ESM,.cjs 永远 CJS,package.json 写什么都不改这件事。实测:type-commonjs/b.mjs 待在 "type": "commonjs" 目录里仍是 ESM;type-module/b.cjs 待在 "type": "module" 目录里仍是 CJS。
② .js 看就近那份 package.json 的 type。 "type": "module" → ESM(type-module/a.js 打出 esm-via-type);"type": "commonjs" → CJS(type-commonjs/a.js,__dirname 是字符串)。"就近"很关键:nested/sub/a.js 被同子目录那份 "type": "commonjs" 判成 CJS,nested/top.js 没有更近的,落到父目录的 "type": "module",是 ESM。
③ type 缺失或非法时,Node 先按 CJS 解析,失败就嗅探有没有 ESM 语法,有就换解析器重来一遍。
二、🔴 最反直觉的一格:完全没有 package.json,也能跑通
一个目录里什么都没有——没有 package.json,没有 .mjs,只有一个写了 export const x = 1; 的 .js:
$ node bare/s1-export.js
s1: detected ESM, x = 1
--- exit=0 ---
exit=0。按判定链推,没有 package.json 就该先按 CJS 解析,而 CJS 解析器看到 export 应该死在 Unexpected token 'export'。它没死。
它没死是因为 Node 22 默认开着 module syntax detection(模块语法探测),开关写成这样:
$ node --help | grep detect
--no-experimental-detect-module
--no- 前缀只说一件事:这是一个默认开启的实验特性。把探测关掉,同一份文件立刻回到 CJS 解析路径:
$ node bare/s1-export.js --no-experimental-detect-module
SyntaxError: Unexpected token 'export'
at wrapSafe (node:internal/modules/cjs/loader:1637:18)
同一个文件、同一台机器,加一个 flag 就从跑通变成报错。所以"这个文件是 ESM"不是文件里写死了什么,是一个默认开着的探测替它做了判断。Node 24.9.0 上重跑,结论一致:bare/s1-export.js 仍是 s1: detected ESM, x = 1、exit=0,顶层 await 的 bare/s3-tla-only.js 仍跑通。
三、探测认什么、不认什么
探测不是"看见 import 这个词就改判"。证据的形式很干脆——关掉探测后各自报什么错:
| 语法形态 | 关掉探测后的报错 | 证据文件 |
|---|---|---|
import 声明 |
SyntaxError: Cannot use import statement outside a module |
bare/s2-import.js |
export 声明(含空的 export {}) |
SyntaxError: Unexpected token 'export' |
bare/s6-empty-export.js |
import.meta |
SyntaxError: Cannot use 'import.meta' outside a module |
bare/s4-meta-only.js |
顶层 await |
SyntaxError: await is only valid in async functions and the top level bodies of modules |
bare/s3-tla-only.js |
后两行最值得停:bare/s3-tla-only.js 整个文件只有一行 const v = await Promise.resolve("tla");,没有 import 也没有 export,开着探测时它打出 s3: top-level await ran, v = tla、exit=0。顶层 await 本身就是"这是 ESM"的信号,因为 CJS 里 await 只能待在 async 函数体里。
不认什么:.js 里只有动态 import()。bare/s7-dynamic.js 里只有 await import("./s1-export.js"),不触发探测——因为 import() 在 CJS 里本来就合法,它是函数调用形态,不是声明。它按 CJS 加载,输出 s7: only dynamic import -> no static syntax、exit=0。
四、type 写错不会报错,只会在运行时替你猜一次
type 只接受 "module" 和 "commonjs"。写别的:{"type": ""} 和 {"type": "bogus"} 都被静默忽略,等价于没写,退回第 ③ 段。type-bogus/a.js、type-empty/a.js、bogus/k3-cjs.js 都跑到 body,都 exit=0,没有报错。
代价不是白拿的,探测真被用上时 Node 打这条警告:
(node:65576) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///.../k2-export.js
is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to .../package.json.
逐句读,它讲的就是第 ③ 段:it doesn't parse as CommonJS 是"先按 CJS 解析失败了";Reparsing as ES module 是"嗅探到 ESM 语法、换解析器重来一遍";incurs a performance overhead 把代价说明白了——这个文件被解析了两次;末尾那句就是"把第 ② 段填上,别让我走到第 ③ 段"。
这条警告的性质和别的不一样。 它不是"你错了",是"我替你猜了,还把代价报给你"。写 "type": "" 的人多半以为自己声明了类型,实际上 Node 当它没写。没有报错不等于配置生效。
五、显式 commonjs 会关掉探测
第 ③ 段的前提是 type 缺失或非法。type 合法为 "commonjs" 时,第 ② 段就命中。explicit-cjs/k1-export.js(同目录 {"type":"commonjs"})里写 export const x = 1;:
$ node explicit-cjs/k1-export.js
SyntaxError: Unexpected token 'export'
at wrapSafe (node:internal/modules/cjs/loader:1637:18)
这条报错和"关掉探测跑 bare/s1-export.js"那条一模一样,因为两者都是"以 CJS 解析、不嗅探"。"type": "commonjs" 等价于对这个文件关掉了探测。 很多人以为"探测默认开"等于"任何写了 export 的文件都能跑"——不是,一句明确指令会赢过探测。
六、两条报错抛在不同阶段——这是本章的骨架
| 写法 | 报错原文 | 栈顶 | 阶段 |
|---|---|---|---|
import 写在 CJS 里 |
SyntaxError: Cannot use import statement outside a module |
wrapSafe |
解析期 |
require 写在 ESM 里 |
ReferenceError: require is not defined in ES module scope, you can use import instead |
ModuleJob.run |
运行期 |
module 在 ESM 里用 |
ReferenceError: module is not defined in ES module scope |
ModuleJob.run |
运行期 |
差别不是错误类型,是时间。CJS 加载文件时,Node 把它整段包进一个函数(栈顶那个 wrapSafe 就是从这儿来的),在编译这个函数体时做语法检查。import 是声明,属于语法;语法不合法,这个文件连执行的资格都没有——所以它在"文件刚被读进来"时就炸了,报错指在第 1 行,但错误发生在任何一行代码跑起来之前。
ESM 反过来:require 不是语法结构,它是 CJS 环境里的一个变量。ESM 里没有它,它只是一个"没定义的名字",而引用没定义的名字是运行期的事——引擎解析时不拦,要等真执行到那一行、去找、发现找不到,才抛 ReferenceError。
bare/s5-both.js 把"探测判成 ESM、然后运行期炸"整条链走了一遍:
$ node bare/s5-both.js
s5: reached body
ReferenceError: module is not defined in ES module scope
at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
第一行打出来了,说明它确实被判成 ESM 并开始执行,紧接着炸在 module 上。"开始执行了"和"抛错"同时出现,就是运行期报错的指纹。
这个区别决定你该怎么修:解析期报错意味着这个文件一行都没跑,任何日志都不会打印,改的是加载规范(扩展名、type),不是那一行语法;运行期报错意味着它已经跑了一段,报错行号就是"跑到哪停下"的准确位置,改法是换成 import 或用 createRequire。一个在文件被读进来时就炸,另一个要跑到那一行才炸——同一次启动,两个报错的"进度条位置"完全不同。
七、两边的内建全局不一样
| ESM | CJS | |
|---|---|---|
__dirname / __filename |
undefined |
字符串 |
require |
undefined |
函数 |
this |
undefined |
=== module.exports |
import.meta.dirname |
有(绝对路径) | 不适用 |
| 未声明赋值 | ReferenceError: x is not defined(默认严格模式) |
不抛(sloppy) |
证据是 type-module/meta.mjs 与 nomarker/cjs-globals.cjs:ESM 侧打出 __dirname = undefined | require = undefined | this = undefined,import.meta.dirname 给出绝对路径,未声明赋值报 ReferenceError: undeclaredX is not defined;CJS 侧打出 __dirname = string | this === module.exports -> true,同一句赋值 no throw (sloppy mode)。
三处值得留意:ESM 里 this 是 undefined,所以老代码里"顶层写 this.x = 1 导出东西"的写法搬进来会变成对 undefined 取属性;__dirname 是 undefined 不是不存在,要路径就上 import.meta.dirname;ESM 默认严格模式。
本章脉络
flowchart TD
A["node 某个文件"] --> B{"① 扩展名"}
B -- ".mjs" --> ESM["按 ESM 加载"]
B -- ".cjs" --> CJS["按 CJS 加载"]
B -- ".js" --> T{"② 就近 package.json 的 type"}
T -- "type=module" --> ESM
T -- "type=commonjs" --> CJS
T -.->|缺失 / 非法:静默忽略| P["先按 CJS 解析"]
P -.->|解析失败| S{"③ 嗅探 ESM 语法"}
S -- "有 import / export / import.meta / 顶层 await" --> R["重解析为 ESM"]
R --> W["MODULE_TYPELESS_PACKAGE_JSON 警告<br/>Reparsing ... incurs a performance overhead"]
W --> ESM
S -.->|"都没有(只有动态 import 也不算)"| X["SyntaxError: Unexpected token 'export'<br/>栈顶 wrapSafe —— 解析期"]
ESM --> Y1["require / module 在这里炸<br/>ReferenceError,栈顶 ModuleJob.run —— 运行期"]
CJS --> Y2["import 声明在这里炸<br/>SyntaxError,栈顶 wrapSafe —— 解析期"]
style ESM fill:#e8f5e9,color:#1b5e20
style CJS fill:#e3f2fd,color:#0d47a1
style X fill:#ffebee,color:#b71c1c
style Y1 fill:#ffebee,color:#b71c1c
style Y2 fill:#ffebee,color:#b71c1c
style W fill:#fff3e0,color:#e65100
图里在说什么。 从上往下是一条判定流:先撞 ① 扩展名,.mjs / .cjs 两个出口直接定死;只有 .js 才进 ②,看就近 package.json 的 type;type=module / type=commonjs 也是直接出口,只有缺失或非法才落进 ③。③ 分两条路:嗅探到四种 ESM 语法之一就重解析成 ESM,路上带一条橙色警告;嗅探不到就退回收到的那个 SyntaxError,图里红色的 X。最下面两个红框是全章骨架:同样"按 ESM 加载",require / module 炸在运行期(ModuleJob.run);"按 CJS 加载"这边,import 声明炸在解析期(wrapSafe)。两个红框的区别不是错误类型,是位置——一个在编译那一格,一个在执行那一格。
生产边界
- 判定链是版本的函数,不是语言的公理。 "没有
package.json也能跑export"是探测默认开启的后果,而探测是实验特性(开关写作--no-experimental-detect-module)。真正由你控制的只有package.json里的type;依赖探测,等于把实验特性写进构建契约。 - 别把"能跑通"当成"配置对"。
type写错值静默忽略,Node 替你嗅探并重重解析一遍,MODULE_TYPELESS_PACKAGE_JSON是唯一会打印出来的提示,它不会让进程失败。CI 里只看exit code,这类问题永远发现不了。 - 报错的栈顶比文案可靠。
SyntaxError和ReferenceError都可能来自十几种原因,但栈顶是wrapSafe还是ModuleJob.run,直接告诉你是解析期还是运行期。记栈顶,别记文案——文案会被版本改。 - 开篇那句
Make sure to set "type": "module"不能照做。 它出现在import-in-cjs.cjs(你写的是.cjs)、explicit-cjs/k1-export.js(你本来就要 CJS)、bare/s1-export.js --no-experimental-detect-module(你自己关的探测)三种不同场景里,它不知道你在哪条路径上失败。 - 跨版本实测只覆盖 22.22.2 与 24.9.0,这两个版本上结论一致。更老的版本不在实验范围内。
动手:可观察结果
自己造目录,填这张表。三格:加载规范、exit、若不通过栈顶是谁。
| # | 场景 | 你的预测 | 期望答案 |
|---|---|---|---|
| 1 | 无 package.json,.js 只有 export const x = 1 |
? | ESM,exit=0,附 MODULE_TYPELESS_PACKAGE_JSON |
| 2 | 无 package.json,.js 只有 module.exports = 1 |
? | CJS,exit=0 |
| 3 | {"type":"module"},.js 写 module.exports = 1 |
? | ESM,exit=1,ReferenceError: module is not defined in ES module scope,栈顶 ModuleJob.run |
| 4 | {"type":"commonjs"},.js 写 export const x = 1 |
? | CJS,exit=1,SyntaxError: Unexpected token 'export',栈顶 wrapSafe |
| 5 | {"type":""},.js 写 export const x = 1 |
? | 非法值被忽略 → 探测命中 → ESM,exit=0,附警告 |
| 6 | {"type":"bogus"},同上 |
? | 同第 5 格(type-bogus/a.js 实测走到 body) |
| 7 | .mjs 加 {"type":"commonjs"} |
? | 仍是 ESM,exit=0 |
| 8 | .cjs 加 {"type":"module"} |
? | 仍是 CJS,exit=0 |
| 9 | 无 package.json,.js 顶层写 await |
? | 探测命中 → ESM,exit=0;加 --no-experimental-detect-module 后 SyntaxError: await is only valid in async functions and the top level bodies of modules |
| 10 | 无 package.json,.js 里只有 await import("./s1-export.js") |
? | CJS,exit=0(动态 import() 不触发探测) |
完成标志:随便给你一个 .js 和它的目录配置,你能在敲 node 之前先写出"按哪套规范、会不会抛、抛在哪一期",再跑一下核对。第 10 格是试金石:能把"出现 import 六个字母"和"有 import 声明"分开的人,才算读懂了第 ③ 段。
故障注入
| 注入方式 | 观察 |
|---|---|
把 package.json 的 type 整行删掉 |
探测接手。看有没有 MODULE_TYPELESS_PACKAGE_JSON——只有走了嗅探才有这条警告,它同时是"你走没走到第 ③ 段"的探针 |
把 "type": "module" 改成 "type": "Module" |
非法值被忽略,退回探测。文件照样跑通,警告出现了,exit code 一点不变 |
把 "type": "module" 改成 "type": "commonjs" |
探测被关掉,export 直接 SyntaxError: Unexpected token 'export',栈顶 wrapSafe |
在顶层加一行 this.x = 1 或读 __dirname |
ESM 下是 TypeError / undefined,CJS 下是 module.exports / 字符串 |
把 import 声明换成 import() |
探测不再命中,文件回到 CJS。再和 --no-experimental-detect-module 对比,确认起作用的是"声明 / 表达式"这条线 |
给命令加 --no-experimental-detect-module |
同一份文件从 exit=0 变 exit=1——"探测默认开启"最直接的证明方式 |
| CI 上的 Node 比本地低(低到没有这个开关) | 同一文件可能从"跑通"变成 SyntaxError: Unexpected token 'export',"本机能跑、CI 跑不了"的一类原型。本条由判定链推出,本实验台没有旧版本可跑 |
自测题
"type": "module"和.mjs都能让文件变成 ESM,它们在判定链上的位置一样吗?哪个更"硬"?- 没有
package.json的目录里,写了export的.js跑出exit=0。把这件事拆成判定链三段,说清每段做了什么。 - 开关为什么写成
--no-experimental-detect-module?这个名字本身告诉了你什么? - 顶层
await和import.meta都不是import/export关键字,为什么也能触发探测?import()为什么不行? "type": ""和"type": "bogus"都跑出了exit=0。为什么说这两个exit=0是"危险的成功"?- 同样是
exit=1,wrapSafe和ModuleJob.run对你的排错动作有什么实际影响?给出两种情境下各自的第一步动作。 - 开篇那条
Make sure to set "type": "module"出现在一个明确写了.cjs的文件上,它错在哪?
现在能解释什么
- 为什么"我的文件是 ESM 还是 CJS"没有单一答案——它是一条三段判定链的输出,扩展名、就近
package.json、语法嗅探各占一段,顺序不可换; - 为什么没有
package.json的目录里,写了export的.js会跑通:Node 22 的 module syntax detection 默认开着,先按 CJS 解析、失败后嗅探 ESM 语法、再重解析一遍; - 探测认什么(
import声明、export声明、import.meta、顶层await)、不认什么(只有import()的文件); - 为什么
"type": ""/"type": "bogus"不报错——它们被静默忽略,代价是 Node 替你猜一次并重重解析一次,那条MODULE_TYPELESS_PACKAGE_JSON就是它在告诉你这件事; - 为什么
import写在 CJS 里炸在解析期(栈顶wrapSafe,文件一行没跑),而require/module写在 ESM 里炸在运行期(栈顶ModuleJob.run,前面的行已经跑了); - 为什么那句
Make sure to set "type": "module"是误导性的——它在type已经是module时出现,也在你明确写了.cjs时出现。
下一步:02 章 · 导出的是接线还是快照 —— 现在你知道文件按哪套规范加载了,接下来要看加载完之后,import 进来的东西到底是什么:一个值,还是一条通向导出方那块内存的接线?