KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
07 · 三套解析器,三个答案 — keel 龙骨
这一章回答:同一行 import,为什么 Node 说找不到、打包器说没问题、tsc 说你要写扩展名——以及"编译通过"和"运行时找得到"之间到底隔着什么。
这一章回答:同一行 import,为什么 Node 说找不到、打包器说没问题、tsc 说你要写扩展名——以及"编译通过"和"运行时找得到"之间到底隔着什么。
现场:一行 import,三份互相矛盾的答复
一个再普通不过的 TypeScript 源文件:
import { u } from "./util";
import { which } from "condlib";
console.log(u, which);
package.json 里写着 "type": "module",util.ts 就躺在旁边。你把这一行分别交给三个工具:
$ node out/index.js
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../out/util'
imported from .../out/index.js
$ esbuild --bundle src/index.ts --outfile=bundle.js
(成功,产物 1 KB 出头,跑起来正常)
$ tsc -p tsconfig.node16.json
src/index.ts(1,19): error TS2835: Relative import paths need explicit file extensions
in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
Did you mean './util.js'?
先停一下,别往下翻。三个工具看的是同一行字,三个答案却互相否定。你先给出自己的判断:哪一个在说谎?
如果你第一反应是"总有一个是错的"——这正是这一章要纠正的东西。它们都没有错。它们回答的是三个不同的问题:
- Node 回答的是"现在,在这个文件系统上,我把哪个文件加载进来";
- 打包器回答的是"构建期,我能不能把它折进这个产物,让浏览器/Node 跑得起来";
tsc回答的是"按你声明的那套规范,这一行合不合法"。
三个问题不一样,答案当然可以不一样。"三套解析器"是这个板块所有"本地能跑、线上白屏"类事故的共同根因,也是这门课的落点。
一、三套解析器各自按什么规则办事
先把三者的立场摆清楚。它们的差别不在实现细节,在目标:
| 解析器 | 在什么时候解析 | 严格程度由谁决定 | 目标 |
|---|---|---|---|
| Node 运行时 | 进程启动、模块图展开时 | 规范本身(exports 是硬约束) |
在真实文件系统上找到一个可执行的文件 |
| 打包器 | 构建时 | 产物要跑在哪里(--platform) |
把整张图折成少数几个文件,且体积尽可能小 |
| TypeScript | 类型检查时(可选 emit) | moduleResolution 配置 |
让类型对得上;产物的形态由 module 决定 |
Node 的规则在第 03 章已经拆过:exports 是包围盒、条件靠键序、相对导入必须带扩展名。打包器的规则在第 06 章拆过:它按 --platform 选条件,把 import 和 require 都当静态边来处理。只有 TypeScript 一个还没拆——而它恰恰是最容易把人骗过去的那一个,因为它的默认配置来自十几年前的假设。
二、三档 moduleResolution,三种命运
同一个 src/index.ts,三份只改了 moduleResolution 的配置,跑出来的结果完全不同(tsc 7.0.2):
########## moduleResolution = node10 ##########
tsconfig.node10.json(4,25): error TS5108: Option 'moduleResolution=node10'
has been removed. Please remove it from your configuration.
--- 产物里那条 import: import { u } from "./util"; ---
--- 用 node 真跑这个产物 ---
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../out/node10/util'
########## moduleResolution = bundler ##########
(仅 condlib 缺类型的 TS7016,导入语句本身通过)
--- 产物里那条 import: import { u } from "./util"; ---
--- 用 node 真跑这个产物 ---
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../out/bundler/util'
########## moduleResolution = node16 ##########
src/index.ts(1,19): error TS2835: Relative import paths need explicit file
extensions in ECMAScript imports when '--moduleResolution' is 'node16' or
'nodenext'. Did you mean './util.js'?
--- 产物里那条 import: import { u } from "./util"; ---
--- 用 node 真跑这个产物 ---
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../out/node16/util'
########## node16 + 显式扩展名(.js) ##########
--- 产物里那条 import: import { u } from "./util.js"; ---
--- 用 node 真跑 ---
MARK_TS_ENTRY_FIXED MARK_TS_UTIL MARK_COND_NODE
四行结果里有三条值得单独抠出来。
第一条:node10 已经不存在了。 error TS5108: Option 'moduleResolution=node10' has been removed. 这不是"不推荐",是移除。也就是说,一份五年前写的、到处都在用的 "moduleResolution": "node" 配置,在这个编译器上已经是一个错误选项。你要是从旧项目里抄了一份 tsconfig,抄到的可能是一件被拆掉的家具。
第二条:bundler 会把无扩展名的导入原样放行。 这本身没错——打包器的解析规则里就不需要扩展名(第 03 章实测过:打包器 import CJS 对象字面量都能成功,Node 会直接 SyntaxError)。错的是它放行之后你没换工具:产物里那条 from "./util" 一字未改地落到了磁盘上,然后交给 Node 去加载,Node 按它自己的规矩当然找不到。
第三条:node16 会在编译期拦住你,但拦不住产物。 报错里直接给了答案(Did you mean './util.js'?)。可是——注意上面第三段里那句 --- 产物里那条 import: import { u } from "./util"; ---——它照样把产物写出来了。这不是实验出错,是 TypeScript 的默认行为:noEmitOnError 默认是 false。于是你会得到一个"红着报错、但产物目录里有文件"的状态,任何只看产物存在性的流水线都会把这份跑不起来的产物发出去。
三、"编译通过"到"运行时找得到"之间,隔着三件独立的事
把上面三条现象收成一个可以随身带走的判断框架。一条 .ts 到"跑起来",中间是三件彼此独立的事:
flowchart TD
A["源码 src/index.ts"] --> B["① 类型检查<br/>tsc --noEmit"]
A --> C["② 产物形态<br/>module / moduleResolution 决定的 emit"]
C --> D["③ 运行时解析<br/>Node 按 exports 与扩展名找文件"]
B -->|"只回答'类型对不对'"| B2["绿了也不代表能跑"]
B2 -.-> D
C -->|"报错也照样 emit<br/>noEmitOnError=false"| C2["产物存在 ≠ 产物可用"]
C2 -.-> D
D -->|"ERR_MODULE_NOT_FOUND"| E["线上白屏 / CI 起不来"]
B -.->|"假绿:类型过了、产物没写过"| E
图里在说什么。 三个方框是三件事,不是一条链上的三步:类型检查回答"类型对不对",emit 回答"产物长什么样",运行时解析回答"这个文件此刻在不在那儿"。①→③ 之间那条虚线才是真正的杀手——没有任何一个环节会替你检查"我 emit 出来的这行字,Node 到底认不认"。左边那条虚线是另一种假绿:类型检查通过,而产物根本没生成(noEmit 开着、或者构建脚本另有分支)。两条虚线最后都汇到同一个终点。
四、同一个包,两档配置解析到不同文件
这一节是本章最容易被忽略、后果却最严重的一处。
condlib 的 exports 是这么写的:
{
"exports": {
".": { "browser": "./browser.mjs", "node": "./node.mjs", "default": "./def.mjs" }
}
}
第 03 章我们已经知道:条件里写 node,Node 运行时就会走 node.mjs。现在把同一份源码交给两档 moduleResolution,用 --traceResolution 把解析轨迹打出来:
$ tsc -p tsconfig.bundler.json --noEmit --traceResolution
======== Module name 'condlib' was successfully resolved to
'.../node_modules/condlib/def.mjs' with Package ID 'condlib/def.mjs@1.0.0'. ========
bundler 档解析到了 def.mjs(default 条件);而上面第四节最后那次真跑,node16 档解析到的是 node.mjs(node 条件,产物跑出来的输出里带着 MARK_COND_NODE)。
两档配置,同一个包名,两个不同的文件。这意味着什么,取决于这两个文件里的东西一不一样:
- 如果它们只是同一份代码的两种打包形态——没事;
- 如果
node.mjs里import了node:fs(而browser.mjs里换成了别的东西)——那么tsc类型检查看到的世界,和你运行时进入的世界,是两个不同的世界。类型检查通过了,因为你检查的是def.mjs的类型;运行时报错了,因为它跑的是node.mjs。
解析器不同 → 条件集不同 → 落到不同文件,这条链在第 03 章讲"条件靠键序"时已经埋下,在这里才显出代价。
五、types 字段与 exports 的顶牛
上面每一次跑 bundler 档和 node16 档,都稳定地带着同一条错误:
src/index.ts(2,23): error TS7016: Could not find a declaration file for module 'condlib'.
'.../node_modules/condlib/node.mjs' implicitly has an 'any' type.
There are types at '.../node_modules/condlib/index.d.ts', but this result could not
be resolved when respecting package.json "exports".
The 'condlib' library may need to update its package.json or typings.
condlib 的 package.json 里明明有 "types": "./index.d.ts",index.d.ts 也好好地躺在那儿。为什么找不到?
因为一旦 exports 存在,顶层 types 就不一定被尊重了——exports 是包围盒(第 03 章的结论),而 index.d.ts 没有出现在包围盒的名单里。TS 的这段话其实把判决说得很清楚:不是没有类型,是这份类型过不了 exports 这道门。
这条报错有两个方向值得记住:
- 站在消费者角度:
Could not find a declaration file ... implicitly has an 'any' type加上 "respecting package.json exports" 这几个字,就等于"这个包的类型没登记进exports"。它的类型不是丢了,是被门挡住了。 - 站在包作者角度:
exports与types(以及main)是两套并存的机制,而exports优先级更高。要么把类型也写进exports的条件里,要么就别开exports。"我明明写了types"是无效的辩护。
六、那么,该选哪一档
三档都测过了,落到一条可执行的规矩上。先问一个问题:你的产物最终交给谁执行?
| 产物最终交给 | 该选 | 配套动作 | 为什么 |
|---|---|---|---|
| Node(服务端、CLI、脚本) | module: "node16"(或 nodenext) |
所有相对导入写显式扩展名(./util.js,哪怕源文件是 .ts) |
只有这一档与 Node 的运行时规则对齐;其余两档都会放行 Node 不接受的写法 |
| 打包器(浏览器应用) | module: "esnext" + moduleResolution: "bundler" |
构建链必须真的过打包器;不要拿 tsc 的 emit 产物去跑 Node |
打包器自己解析,规则与 bundler 档一致 |
| 同时要两种 | node16 打底 |
由打包器覆盖浏览器侧 | 严的那一档能过,松的那一档一定能过;反过来不成立 |
三条必须同时记住的规矩:
tsc的moduleResolution要和"产物交给谁"对齐,而不是和"你的源码看起来像什么"对齐。 源码里写的是 ESM 语法,不代表产物会被 Node 直接加载——但它可能会被,而bundler档不会为这种可能做准备。"编译报错"与"有没有产物"是两件事。 默认
noEmitOnError: false,报错也 emit。要它不产出,就显式关掉;要别人不误用,就在流水线里把"产物存在"和"类型通过"分别当门禁,而不是只看目录里有没有文件。别用旧项目的 tsconfig 当模板。
node10在这个编译器上已经被移除——不是弃用。抄一份五年前的配置,抄到的可能是一个不存在的选项,而它带来的解析行为与今天的 Node 完全不对齐。
本章脉络
flowchart TD
S["src/index.ts<br/>import { u } from './util'"] --> R1["类型期<br/>tsc<br/>moduleResolution"]
S --> R2["构建期<br/>打包器<br/>--platform"]
S --> R3["运行期<br/>Node<br/>exports + 扩展名"]
R1 -->|"node16"| A1["报 TS2835<br/>要求写 ./util.js"]
R1 -->|"bundler"| A2["放行无扩展名<br/>与打包器一致"]
R1 -->|"node10"| A3["TS5108<br/>该选项已被移除"]
R2 --> B1["按 platform 选条件<br/>折叠整张图"]
R3 --> C1["按 exports 键序选条件<br/>相对导入必须带扩展名"]
B1 -.->|"选了 def.mjs"| D1["与 R3 选的 node.mjs<br/>不是同一个文件"]
C1 -.-> D1
A2 -.->|"emit 出 './util'<br/>Node 认不了"| E["ERR_MODULE_NOT_FOUND"]
D1 -.->|"类型看的世界<br/>≠ 运行的世界"| E2["类型通过但运行时行为不同"]
图里在说什么。 三条竖线是三个解析器,它们各自独立地看同一行源码。左边一列的结果是编译期的三种态度(报错 / 放行 / 选项没了);中间是打包器;右边是 Node 运行时。整张图里最关键的是那两条从左边和中间指向右边的虚线——跨解析器的落差不会有人在构建期替你发现:打包器选的文件(def.mjs)和 Node 选的文件(node.mjs)不是同一个,而唯一知道这件事的时刻,是运行时炸掉的那一刻。图里所有 -.-> 都指向同一个终点,这不是巧合。
生产边界
本课的替身是"最小复现",不是"生产构建链"。 实验里只有一个源文件、一个包、三份 tsconfig。真实项目里还有:路径别名(paths)、monorepo 的工作区链接、tsconfig 的 extends 继承链、多个 tsconfig 分别管源码和测试、以及构建工具自己再插一层解析(Vite 的 resolve.conditions、Next 的 transpilePackages)。这些都会各自引入一套条件集。
所以这一章给出的不是"正确答案",是"判断方法":
- 想知道产物会被谁解析,就去看产物里那行 import 长什么样(本课全程就是这么做的:
grep 'import' out/node16/index.js)。产物是唯一不会骗人的东西。 - 想知道某个包在你这条链上会落到哪个文件,就用
--traceResolution(TS)或import.meta.resolve(Node 运行时)把落点打印出来,而不是推导。 - 三档配置的具体行为随 TS 版本变(
node10被移除就是活证据)。本课记下的报错码(TS5108 / TS2835 / TS7016)与产物形态是 7.0.2 上的事实,请在你自己的版本上复核;"三套解析器会有三个答案"这个形状跨版本成立。
动手:可观察结果
挑一个你手上真实的项目(或者用本课实验台的最小例子),回答下面五个问题。每个问题的答案都必须来自命令输出,不能来自记忆或文档:
- 我的
moduleResolution是哪一档? ——npx tsc --showConfig里找moduleResolution;如果它来自extends的父配置,把父配置也找出来。 - 我的相对导入写了扩展名吗? —— 随机抽 3 个源文件,数一下有几条是
from "./x"(无扩展名)。node16档下这些每一条都是潜在事故。 - 产物里那行 import 长什么样? ——
grep -n "^import\|require(" 构建产物 | head。看到无扩展名的相对导入,就把这个产物交给 Node 实际跑一次。 - 我最依赖的那个包,两档配置会不会解析到不同文件? —— 对它跑
--traceResolution(TS 档)和node -e "console.log(import.meta.resolve('包名'))",比对落点。 - 我的流水线是拿"类型通过"当门禁,还是拿"产物存在"当门禁? —— 去看 CI 配置。如果只有"构建成功"一个判据,那就同时给了
noEmitOnError: false一条放行通道。
完成标志:五个问题都能贴出命令和输出;并且你能说出一句"如果第 4 条的落点不一样,后果是什么"。
故障注入
三种注入方式,每一种都有明确的观察点和期望行为。先写下你的预测,再跑。
| 注入 | 怎么做 | 观察什么 | 期望行为 |
|---|---|---|---|
| 把扩展名删掉 | 把 node16 档下唯一写对了的 from "./util.js" 改回 from "./util" |
tsc 的输出 + 产物能否跑 |
编译期报 TS2835(并给出建议的写法);但产物照样产出,产物照样 ERR_MODULE_NOT_FOUND —— 两个信号同时出现 |
把档位从 node16 换成 bundler |
只改一行配置,源码不动 | 编译是否还报错 + 产物是否还能跑 | 编译不再报错(假绿),产物仍然跑不起来。这就是"配置让错误消失但没让问题消失" |
| 让两条条件分支内容不同 | 在 condlib 的 def.mjs 里加一句 console.log("def"),在 node.mjs 里加 console.log("node") |
编译期解析轨迹 + 运行时输出 | --traceResolution 说解析到 def.mjs,运行时打印 node —— 同一份源码,两个世界 |
第三种注入之后别再改回去。保留这个不一致的状态跑一次完整构建,然后问自己一个问题:从"构建成功"到"用户白屏"这一段路上,有没有任何一个环节本该发现这件事,却没有?找出来,那就是你项目里最该补的那个门禁。
自测题
- 一个同事说"我这里
tsc是绿的,产物也生成了,怎么线上还是ERR_MODULE_NOT_FOUND"。请按本章的框架给出至少三条互不重叠的可能原因,并说明每条可以用哪个命令区分。 --traceResolution说某个包解析到了def.mjs,而运行时实际加载的是node.mjs。这两个结论可以同时为真吗?为什么?- 你的项目
tsconfig写着"moduleResolution": "node"(旧写法)。升级编译器后会发生什么?你会怎么迁移?迁移时最容易漏掉哪一类导入? noEmitOnError默认是false。请说明这个默认值在开发期和CI 里各自带来的是便利还是风险,并给出你的结论(没有唯一答案,但必须有理由)。- 一个包的
package.json里同时有main、types和exports。请按第 03 章与本章的结论,说出这三者各自的"优先级"和"失效条件"。 - 回到第 06 章:打包器会按
--platform选条件,TypeScript 会按moduleResolution选条件,Node 会按--conditions选条件。同一个exports对象,会出现三者选中三个不同文件的情况吗? 如果可以,请构造一个最小的exports把它造出来。
现在能解释什么
这门课从一个 .js 文件开始,到"三套解析器三个答案"结束。回一遍这一路,看看现在能解释的东西:
第 01 章的那个问题——"这个文件按哪套规范加载"——现在你知道答案是一条三段判定链(扩展名 → 就近 package.json 的 type → 语法嗅探),而且链的第三段是默认开启的实验特性。你还知道两条方向相反的报错抛在不同的阶段:import 写在 CJS 里是解析期就炸,require 写在 ESM 里要跑到那一行才炸——所以"改文件内容"和"改加载方式"是两种完全不同的急救手段。
第 02 章的那个问题——"导入的是值还是接线"——现在你能一句话答完:ESM 给的是接线,CJS 给的是一块内存加拍下来的值。循环依赖上这一点被放大到极致:ESM 抛错(fail fast),CJS 静默 undefined(fail silent,只附一条警告)。而这个差别在编译之后会被抹平——这也是为什么"源码里跑得好好的,编译完就出鬼"。
第 03 章的那个问题——"from "pkg/sub" 落到哪个文件"——现在你知道 exports 是一道包围盒,加一个字段就能让包外所有深路径失效;条件的选择靠对象键序,不靠谁写得"更精确";子路径模式是字符串替换,多写一个扩展名就变成 deep.js.js。你还知道相对导入在 ESM 里必须带扩展名、不能指向目录、读 JSON 要写 import attribute——三条都不是风格问题,是硬规则。
第 04 章的那个问题——"import 一个 CJS 包,default 是什么"——现在你知道它恒等于整个 module.exports(不管是对象还是函数),而命名导出是静态猜出来的:认赋值形态、不认对象字面量整体替换、不认 Object.assign、转发一层就丢。反方向上,require(esm) 已经在 Node 22 上默认可用,但它有一条硬边界——顶层 await。
第 05 章的那个问题——"为什么 instanceof 返回 false"——现在你知道那不是 bug,是双格式发布的账单:import 和 require 是两个条件,落到两个文件,模块图上是两个键,状态自然两份。你也知道怎么用两条命令证实它,以及两条避免它的路(单实现 + 外壳 / 状态外置)各自的代价。
第 06 章的那个问题——"为什么删了代码产物没瘦"——现在你知道打包器只做静态分析,所以能不能摇掉取决于能不能在语法上证明没用。那条最实用的判据也还在:未使用的对象、数组、函数、类、纯字面量都能摇掉;只要初始化表达式里出现一次"属性访问 + 调用",就全部保留。所以"这个库不支持摇树"这句话,通常应该改成"这个库里有一行初始化代码调了个方法"。
以及最后这一章:tsc 绿、产物在、运行时报错——三件事互不担保。"编译通过"从来不是"运行时找得到"的证明,它们只是两个不同解析器各自的意见。
一个提醒收尾:这一行的版本比任何框架都动得快。本课记下的多处行为都是被某一版改过的——.js 的语法嗅探从无到有、require(esm) 从要 flag 到默认可用、moduleResolution: "node10" 从推荐到被移除。所以真正值钱的不是这七章里的那几十个结论,而是得到它们的那套做法:写一个最小复现、把真实的报错原文留下来、用产物而不是文档当作判据。版本会变,这套做法不会。