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 运行时 进程启动、模块图展开时 规范本身(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)。

两档配置,同一个包名,两个不同的文件。这意味着什么,取决于这两个文件里的东西一不一样:

解析器不同 → 条件集不同 → 落到不同文件,这条链在第 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 这道门。

这条报错有两个方向值得记住:

六、那么,该选哪一档

三档都测过了,落到一条可执行的规矩上。先问一个问题:你的产物最终交给谁执行?

产物最终交给 该选 配套动作 为什么
Node(服务端、CLI、脚本) module: "node16"(或 nodenext) 所有相对导入写显式扩展名(./util.js,哪怕源文件是 .ts) 只有这一档与 Node 的运行时规则对齐;其余两档都会放行 Node 不接受的写法
打包器(浏览器应用) module: "esnext" + moduleResolution: "bundler" 构建链必须真的过打包器;不要拿 tsc 的 emit 产物去跑 Node 打包器自己解析,规则与 bundler 档一致
同时要两种 node16 打底 由打包器覆盖浏览器侧 严的那一档能过,松的那一档一定能过;反过来不成立

三条必须同时记住的规矩:

  1. tsc 的 moduleResolution 要和"产物交给谁"对齐,而不是和"你的源码看起来像什么"对齐。 源码里写的是 ESM 语法,不代表产物会被 Node 直接加载——但它可能会被,而 bundler 档不会为这种可能做准备。

  2. "编译报错"与"有没有产物"是两件事。 默认 noEmitOnError: false,报错也 emit。要它不产出,就显式关掉;要别人不误用,就在流水线里把"产物存在"和"类型通过"分别当门禁,而不是只看目录里有没有文件。

  3. 别用旧项目的 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)。这些都会各自引入一套条件集。

所以这一章给出的不是"正确答案",是"判断方法":

动手:可观察结果

挑一个你手上真实的项目(或者用本课实验台的最小例子),回答下面五个问题。每个问题的答案都必须来自命令输出,不能来自记忆或文档:

  1. 我的 moduleResolution 是哪一档? —— npx tsc --showConfig 里找 moduleResolution;如果它来自 extends 的父配置,把父配置也找出来。
  2. 我的相对导入写了扩展名吗? —— 随机抽 3 个源文件,数一下有几条是 from "./x"(无扩展名)。node16 档下这些每一条都是潜在事故。
  3. 产物里那行 import 长什么样? —— grep -n "^import\|require(" 构建产物 | head。看到无扩展名的相对导入,就把这个产物交给 Node 实际跑一次。
  4. 我最依赖的那个包,两档配置会不会解析到不同文件? —— 对它跑 --traceResolution(TS 档)和 node -e "console.log(import.meta.resolve('包名'))",比对落点。
  5. 我的流水线是拿"类型通过"当门禁,还是拿"产物存在"当门禁? —— 去看 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 —— 同一份源码,两个世界

第三种注入之后别再改回去。保留这个不一致的状态跑一次完整构建,然后问自己一个问题:从"构建成功"到"用户白屏"这一段路上,有没有任何一个环节本该发现这件事,却没有?找出来,那就是你项目里最该补的那个门禁。

自测题

  1. 一个同事说"我这里 tsc 是绿的,产物也生成了,怎么线上还是 ERR_MODULE_NOT_FOUND"。请按本章的框架给出至少三条互不重叠的可能原因,并说明每条可以用哪个命令区分。
  2. --traceResolution 说某个包解析到了 def.mjs,而运行时实际加载的是 node.mjs。这两个结论可以同时为真吗?为什么?
  3. 你的项目 tsconfig 写着 "moduleResolution": "node"(旧写法)。升级编译器后会发生什么?你会怎么迁移?迁移时最容易漏掉哪一类导入?
  4. noEmitOnError 默认是 false。请说明这个默认值在开发期和CI 里各自带来的是便利还是风险,并给出你的结论(没有唯一答案,但必须有理由)。
  5. 一个包的 package.json 里同时有 main、types 和 exports。请按第 03 章与本章的结论,说出这三者各自的"优先级"和"失效条件"。
  6. 回到第 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" 从推荐到被移除。所以真正值钱的不是这七章里的那几十个结论,而是得到它们的那套做法:写一个最小复现、把真实的报错原文留下来、用产物而不是文档当作判据。版本会变,这套做法不会。

进入 keel 阅读