KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · 一行 import 落到磁盘上哪个文件 — keel 龙骨

这一章回答:from "fakepkg/sub/deep" 这行字,是怎么变成磁盘上某一个具体文件的?为什么一个包加了 exports 字段之后,老代码突然全报 ERR_PACKAGE_PATH_NOT_EXPORTED?

这一章回答:from "fakepkg/sub/deep" 这行字,是怎么变成磁盘上某一个具体文件的?为什么一个包加了 exports 字段之后,老代码突然全报 ERR_PACKAGE_PATH_NOT_EXPORTED?

现场:文件就在那儿,Node 说它 not defined

一个同学升级一个 Node 服务的依赖,改了一行:

import "fakepkg/legacy-main.js";

启动直接炸:

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './legacy-main.js' is not defined by "exports"
  in .../node_modules/fakepkg/package.json imported from .../probes/p04-import-legacy.mjs

他打开 node_modules/fakepkg/ 越看越不对:legacy-main.js 就在那儿;package.json 里 "main": "./legacy-main.js" 还写着它;这条路径以前从 CJS 侧 require 明明能用。文件在、字段在、以前能用,现在 Node 说它 not defined。你会怎么判断?

先排掉两个省事的解释:文件没被删(ls 得到它),路径没拼错(报错里就是他写的那串)。方向只剩一个——他写下的"文件名"和 Node 认定的"可用入口"是两套东西:Node 根本没去磁盘上找这个文件,它先做了另一件事。

下文每条现象、每个报错码都来自同一台实验上的 23 个探针(多数打在同一个 fakepkg 上,另有 orderpkg / noexports / selfref / strpkg 四个对照组):

{ "name": "fakepkg", "type": "commonjs", "main": "./legacy-main.js",
  "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" },
    "./feature": "./src/feature.js", "./sub/*": "./src/sub/*.js", "./package.json": "./package.json" } }

盯住一处:main 指向 legacy-main.js,而 exports 的每个目标里没有一个是它。第一句结论就藏在这儿。整件事拆三步:① 包根有没有 exports;② 有的话用子路径去匹配 exports 的键;③ 命中后若是条件对象,再按条件挑文件。

一、① 有没有 exports:它是一道包围盒

flowchart TD
    A["说明符:fakepkg/sub/deep"] --> C{"① 包根有 exports 吗"}
    C -. 没有 .-> D["回落传统解析<br/>main 生效,可补目录与扩展名<br/>任意子路径可进(p09/p10)"]
    C -- 有 --> F["② 子路径匹配 exports 键<br/>先精确键,再试 * 模式"]
    F -. 一条都没命中 .-> G["ERR_PACKAGE_PATH_NOT_EXPORTED<br/>p04 ESM / p05 CJS,抛错栈不同"]
    F -- 命中 --> I{"③ 目标是不是条件对象"}
    I -- 是 --> J["按 import / require / default 选文件"]
    I -. 不是 .-> K["落盘:读这个文件"]
    J --> K
    style G fill:#ffebee,color:#b71c1c
    style D fill:#e8f5e9,color:#1b5e20
    style K fill:#e3f2fd,color:#0d47a1

图里在说什么:这行字没有哪一步是"直接拼到磁盘上"。先在 ① 处分岔——包里有没有 exports。没有,走绿线回落到老规矩:main 生效,目录和扩展名可以补全,包里哪一层都能进;探针 p09 从 noexports 里拿到 extra.js、p10 拿到 main.js,而那个包只写了 main、没有任何 exports。有,就进红线:exports 把包收成一个盒子,盒子外面的一切——包括 main 指的那个文件——全部不可达。② 是拿子路径到盒子上找键,找不到就走那条红边;③ 是命中之后还要做的一次选择,目标是条件对象时按条件挑文件。

包围盒是"只放行登记过的,其余全封",它把旧规则整个替换掉,不是在旧规则上打补丁。 现场那个怪事就此解释:legacy-main.js 在磁盘上、在 main 里,但没被 exports 登记过——从盒子外面看,它和不存在没有区别。同一个 fakepkg、同一堵墙,两个方向撞,报错一样、抛错的模块栈不一样:ESM 侧抛在 node:internal/modules/esm/resolve:314,CJS 侧抛在 node:internal/modules/cjs/loader:657——两套加载器各自实现了一遍包围盒检查。

main 只在包里没有 exports 时作数。 exports 一出现,main 就被架空:还在文件里,解析器根本不看它。p22/p23 把它推到极致——strpkg 同时写 "main": "./legacy.mjs" 和字符串简写 "exports": "./only-root.mjs",于是 import "strpkg" 走 only-root.mjs,而 import "strpkg/legacy.mjs" 直接 ERR_PACKAGE_PATH_NOT_EXPORTED:字符串简写只放行包根,等于把整个包收成一个点,main 指向的文件从 main 都走不进去。

二、② 子路径匹配:精确键与 * 替换

键有两种:写死的精确键和带星号的模式键。"./feature": "./src/feature.js" 是精确键,require("fakepkg/feature") 命中它、落到 src/feature.js(p03)。"./sub/*": "./src/sub/*.js" 是模式键,p06 用 import "fakepkg/sub/deep" 命中、加载 src/sub/deep.js。

顺理成章,直到说明符末尾多带一个扩展名——await import("fakepkg/sub/deep.js")——报的是 ERR_MODULE_NOT_FOUND,而它去找的路径是 .../fakepkg/src/sub/deep.js.js。双扩展名。不是 Node 手抖,是规则本身:* 是字面字符串替换,不是"路径拼接后再补全"。

步骤 fakepkg/sub/deep fakepkg/sub/deep.js
子路径 ./sub/deep ./sub/deep.js
匹配 ./sub/* 取星号 deep deep.js
替换进 ./src/sub/*.js ./src/sub/deep.js ./src/sub/deep.js.js
结果 命中读文件 ERR_MODULE_NOT_FOUND

模板里已经写了 .js,说明符里就不要再带 .js,你只填那个"裸名字"。

imports 里的 # 别名是同一套匹配机制,作用域反过来——只对包内代码生效。selfref 的包根写了 "imports": { "#utils": "./src/utils.mjs", "#internal/*": "./src/internal/*.mjs" },包内两个 # 导入都成功(p11,u = utils | d = deep);包外 p12 去 import "#utils" 则报 TypeError [ERR_PACKAGE_IMPORT_NOT_DEFINED],且它查的是发起方自己的 package.json,不是 selfref 的。也就是说,exports 管"别人怎么进我的包",imports 管"我包内代码怎么用短名字指自己的文件"——# 天生包私有,出了包就没有主人,报出来的错也就和越界访问包内文件的 ERR_PACKAGE_PATH_NOT_EXPORTED 不同。

三、③ 命中之后:条件决定落到哪个文件

键匹配只回答"这条路放不放行";放行之后读哪个文件,由条件决定。p01 从 CJS 侧 require 得到 dist/index.cjs(require 条件),p02 从 ESM 侧 import 得到 dist/index.mjs(import 条件)——这就是双格式包分流两条入口的机制(第 05 章算它的代价:单例变两份、instanceof 失效)。

一个很实用的判断,p20 给的:在 CJS 文件里用动态 import(),走的是 import 条件。 那个文件的扩展名是 .cjs,可 import("fakepkg") 拿到的 kind 是 esm,落点就是 dist/index.mjs。判据是"谁在调加载器":require 走 CJS 加载器、吃 require 条件;import(静态动态都一样)走 ESM 加载器、吃 import 条件。

条件靠键序,不靠"谁更精确"

这是最容易翻车的地方。orderpkg 只写了一个 ".",键序 default 在前、import 在后;从 ESM 侧 import "orderpkg",直觉会说"我是 ESM,该走 import.mjs",实测是 加载了 default.mjs。永远走 default.mjs:解析器从上往下扫,第一个"当前条件集里命中的键"就赢,然后停——default 是万能键且排第一,它先命中,后面的 import 没机会被看到。

condpkg 换一组自定义条件再证一遍。它的键序是 development → production → default:

命令 加载
默认(无条件) def.mjs
--conditions=development dev.mjs
--conditions=production prod.mjs
--conditions=production --conditions=development dev.mjs
--conditions=production,development def.mjs
--conditions=custom def.mjs

两个反常识点。其一,两个条件都给时走 dev.mjs:条件是一个集合、没有优先级,production 和 development 平级,胜负由 exports 键序决定——development 在前就先命中;你没法靠"多给一个更想要的条件"覆盖包作者写的键序。 其二,--conditions=production,development 落到 def.mjs:逗号不拆分,整串被当成一个名叫 production,development 的单一条件名,谁都没有,于是掉到 default;想给两个条件就写两个 --conditions=。这是 Node 22.22.2 上的实测,请自己复核。

四、自引用,与相对说明符的三条硬规则

p19 干的事有点绕:在一个包里用包自己的名字去取 package.json——成功,输出 name = fakepkg。能取到,是因为 fakepkg 的 exports 里显式登记了 "./package.json": "./package.json":package.json 也是一个文件,一旦 exports 存在它同样在包围盒里,想做自引用的包必须自己把这条放进去。

上面讲的都是"包名说明符"。换成 ./ ../ 开头的相对说明符,规则换成另一套——不算条件、不算包围盒,但有更死的三条:

=== p13  import "../src/plain"(无扩展名)===
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../fixtures/src/plain'
=== p14  import "../src/dir"(目录)===
Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import '.../fixtures/src/dir' is not supported ...
=== p15  import "../src/dir/index.mjs"(点到文件)===
  加载了 src/dir/index.mjs

对照看:无扩展名 → ERR_MODULE_NOT_FOUND;指向目录 → ERR_UNSUPPORTED_DIR_IMPORT;点到具体文件 → 成功。 第一条说明 ESM 相对导入没有 CJS 那套"自动补 .js/.json//index.js"的补全——你写 ../src/plain,它就照着这串字去找,找不到就报错。第二条更狠:即使目录里真有一个合格的入口文件(p15 里 src/dir/index.mjs 就在那儿),../src/dir 也不会替你进去。规则是"相对说明符必须精确指到一个文件"。

第三条落在 JSON 上。p16 直接 import "../src/data.json":

TypeError [ERR_IMPORT_ATTRIBUTE_MISSING]: Module "file:///.../src/data.json" needs an import
  attribute of "type: json"

原文就这一句 needs an import attribute of "type: json"。JSON 不是 JavaScript 模块,进 ESM 图必须打个标签;补上标签就通,p17 拿到 default = {"ok":true}。而同一份文件从 CJS 侧取什么都不用加,p18 的 require("../src/data.json") 直接可用。同一个 JSON 文件,ESM 要 import attribute,CJS 不要:CJS 从第一天就把 .json 当成一种可 require 的文件类型写死在解析规则里,ESM 把"文件类型"外化成显式声明、宁可不猜。

本章脉络

收成一条链:exports 决定"哪些路存在",键匹配决定"这条路对应哪个目标",条件决定"目标里读哪个文件"——三件事依次发生,一步都不能跳。没 exports 的包走传统解析、任意子路径可进;有 exports 的包只要有一条说明符没被任何键接住,就是 ERR_PACKAGE_PATH_NOT_EXPORTED。相对说明符是另一套平行规则:不参与条件与包围盒,但要受"带扩展名、不指目录、JSON 打 attribute"三条约束。

生产边界

动手:可观察结果

先写一个"这行字到底落到哪个文件"的探针。ESM 侧用 import.meta.resolve——它只解析、不执行,正好用来问"落到哪";CJS 侧换成 require.resolve 问同一个问题,对照落点:

// tools/where.mjs
const targets = ["fakepkg", "fakepkg/feature", "fakepkg/sub/deep",
                 "fakepkg/sub/deep.js", "fakepkg/legacy-main.js", "fakepkg/package.json", "orderpkg"];
for (const t of targets) {
  try { console.log(t.padEnd(24), "->", import.meta.resolve(t)); } // 返回 file:// URL,不加载
  catch (e) { console.log(t.padEnd(24), "->", e.code ?? e.name); }
}

把探针指向你自己的依赖,只填说明符,先猜再验。下面这张表是拿 fakepkg 系列探针搭的模板,右列是实测结果——先把右列盖住自己猜再对:

说明符(在哪侧) 先猜落到哪 / 什么错 实测
fakepkg(require) ? dist/index.cjs
fakepkg(import) ? dist/index.mjs
fakepkg(CJS 里 import()) ? dist/index.mjs(走 import 条件)
fakepkg/feature(require) ? src/feature.js
fakepkg/sub/deep(import) ? src/sub/deep.js
fakepkg/sub/deep.js(import) ? ERR_MODULE_NOT_FOUND(src/sub/deep.js.js)
fakepkg/legacy-main.js(import) ? ERR_PACKAGE_PATH_NOT_EXPORTED
fakepkg/package.json(import + json attribute) ? fakepkg/package.json(须登记过)

完成标志:给一个没见过的包和一条说明符,你能先说出"它有没有 exports、子路径命中哪个键、命中后走哪个条件",再跑探针核对——而不是看到 ERR_PACKAGE_PATH_NOT_EXPORTED 就搜"降版本"。

故障注入

给一个现在没有 exports 的多入口包(有根 main、深路径 lib/internal/x.js、dist/ 目录、若干带扩展名的文件,以及 package.json 自身),加上一节 exports,看下面五类访问路径哪些立刻断:

注入 "exports": { "./x": "./lib/x.js" } 后 立刻断掉的访问路径 对应证据
① 深路径 pkg/lib/internal/thing.js——没登记,ERR_PACKAGE_PATH_NOT_EXPORTED p04/p05 的机制
② 原来从 main 走的 pkg/legacy-main.js——main 被架空 p22/p23
③ 带扩展名命中模式的 pkg/sub/deep.js——替换成 deep.js.js p07
④ 目录路径 pkg/dist 或 pkg/lib——指向目录 p14
⑤ 用包名取 pkg/package.json——除非显式登记 ./package.json p19

再补两组对照:把 exports 改成字符串简写 "exports": "./only-root.mjs",看是不是只剩根能进(p22/p23);把 default 挪到条件对象最前面,看 --conditions=production 是不是彻底失效、永远走 default(p08 的机制)。

自测题

  1. 一个包同时写了 main 和 exports,main 指的文件却进不去。用"包围盒"解释为什么,并说出从 ESM 和 CJS 两侧撞墙时抛错的模块栈分别是什么。
  2. "./sub/*": "./src/sub/*.js" 下,pkg/sub/deep 和 pkg/sub/deep.js 各落到哪?为什么后者会多出一个 .js?
  3. exports 是 { "import": "./a.mjs", "default": "./b.mjs" },从 ESM 侧 import 会走哪个文件?把 default 换到最前面呢?
  4. --conditions=production --conditions=development 和 --conditions=production,development 结果不同,分别落到哪、为什么?(带上你验证用的 Node 版本)
  5. import "#utils" 在包内能用、包外报 ERR_PACKAGE_IMPORT_NOT_DEFINED;同一个 data.json,ESM 侧要 import attribute、CJS 侧 require 直接可用。这两件事分别说明什么规则?

现在能解释什么

下一步:04 章 · 两套规范相遇时的形状 —— 现在你知道一行说明符怎么落到一个文件了,接下来看两套规范真的碰上时会发生什么:ESM 去 import 一个 CJS 包,default 到底是什么,{ named } 里的名字是真有的还是猜出来的。

进入 keel 阅读