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)。两个红框的区别不是错误类型,是位置——一个在编译那一格,一个在执行那一格。

生产边界

动手:可观察结果

自己造目录,填这张表。三格:加载规范、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 跑不了"的一类原型。本条由判定链推出,本实验台没有旧版本可跑

自测题

  1. "type": "module" 和 .mjs 都能让文件变成 ESM,它们在判定链上的位置一样吗?哪个更"硬"?
  2. 没有 package.json 的目录里,写了 export 的 .js 跑出 exit=0。把这件事拆成判定链三段,说清每段做了什么。
  3. 开关为什么写成 --no-experimental-detect-module?这个名字本身告诉了你什么?
  4. 顶层 await 和 import.meta 都不是 import / export 关键字,为什么也能触发探测?import() 为什么不行?
  5. "type": "" 和 "type": "bogus" 都跑出了 exit=0。为什么说这两个 exit=0 是"危险的成功"?
  6. 同样是 exit=1,wrapSafe 和 ModuleJob.run 对你的排错动作有什么实际影响?给出两种情境下各自的第一步动作。
  7. 开篇那条 Make sure to set "type": "module" 出现在一个明确写了 .cjs 的文件上,它错在哪?

现在能解释什么

下一步:02 章 · 导出的是接线还是快照 —— 现在你知道文件按哪套规范加载了,接下来要看加载完之后,import 进来的东西到底是什么:一个值,还是一条通向导出方那块内存的接线?

进入 keel 阅读