Node.js ESM 模块解析算法理解
Node.js ESM 解析算法(Resolution Algorithm)的职责只有两个:
- 将
import中的 specifier 解析为最终 URL - 判断该 URL 对应的模块格式
例如:
import react from "react";
import util from "./utils.js";
import config from "#config";
Node.js 最终需要得到:
react -> file:///project/node_modules/react/index.js
./utils.js -> file:///project/src/utils.js
#config -> file:///project/src/config/index.js
以及:
module
commonjs
json
wasm
等模块格式。
整个 ESM 规范本质上是在描述:
specifier
↓
resolved URL
↓
module format
↓
load
↓
execute
一、ESM_RESOLVE 总体流程
Node.js ESM 的入口算法:
ESM_RESOLVE(specifier, parentURL)
可以简化为:
判断 specifier 类型
URL
路径
#imports
裸包名
↓
解析得到 URL
↓
检查文件是否合法
↓
判断模块格式
↓
返回给 Loader
整个算法的核心目标:
specifier
↓
唯一 URL
而不是像 CommonJS 那样不断猜测。
二、specifier 分类
Node.js 将 specifier 分为四类。
1. URL Specifier
例如:
import x from "file:///app/src/a.js";
或者:
import x from "data:text/javascript,export default 1";
如果本身是合法 URL:
new URL(specifier)
成功。
那么直接返回。
2. Relative Specifier
例如:
import x from "./foo.js";
import y from "../bar.js";
Node.js 根据当前模块位置解析。
假设:
file:///app/src/main.js
执行:
import "./utils.js";
得到:
file:///app/src/utils.js
本质上就是:
new URL("./utils.js", parentURL)
3. Package Imports
例如:
import db from "#db";
或者:
import logger from "#utils/logger";
以:
#
开头。
会进入:
PACKAGE_IMPORTS_RESOLVE()
读取当前包:
{
"imports": {
"#db": "./src/db/index.js",
"#utils/*": "./src/utils/*.js"
}
}
例如:
import db from "#db";
最终得到:
./src/db/index.js
4. Bare Specifier
例如:
import react from "react";
import lodash from "lodash";
import axios from "axios";
既不是:
URL
路径
#import
就属于裸包名。
进入:
PACKAGE_RESOLVE()
开始查找 node_modules。
三、PACKAGE_RESOLVE
这是 Node.js 查找 npm 包的核心逻辑。
例如:
import react from "react";
当前文件:
/app/src/pages/home/index.js
Node.js 会不断向上查找:
/app/src/pages/home/node_modules/react
/app/src/pages/node_modules/react
/app/src/node_modules/react
/app/node_modules/react
/node_modules/react
直到找到。
等价于:
while(currentDirectory){
查找 node_modules/packageName
找不到继续向上
}
四、读取 package.json
找到包以后:
node_modules/react/package.json
Node.js 开始读取配置。
优先级如下:
exports
↓
main
↓
直接路径
五、exports 机制
现代 Node.js 包解析几乎完全依赖 exports。
例如:
{
"exports": {
".": "./dist/index.js",
"./jsx-runtime": "./dist/jsx-runtime.js"
}
}
允许:
import React from "react";
对应:
.
得到:
./dist/index.js
允许:
import jsx from "react/jsx-runtime";
对应:
./jsx-runtime
得到:
./dist/jsx-runtime.js
但是:
import internal from "react/internal";
如果 exports 中不存在:
"./internal"
则报错:
Package Path Not Exported
六、exports 的本质
exports 可以理解为:
包对外暴露的公开 API
例如:
{
"exports": {
".": "./dist/index.js",
"./api": "./dist/api.js"
}
}
允许:
import x from "my-lib";
import y from "my-lib/api";
禁止:
import z from "my-lib/dist/internal.js";
即使文件真实存在。
七、Conditional Exports
exports 不一定是字符串。
可以是对象。
例如:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
}
}
Node.js 会根据条件选择。
ESM:
import x from "lib";
匹配:
"import"
得到:
./dist/index.mjs
CommonJS:
require("lib");
匹配:
"require"
得到:
./dist/index.cjs
八、PACKAGESELFRESOLVE
假设:
{
"name": "my-lib",
"exports": {
".": "./src/index.js",
"./core": "./src/core.js"
}
}
当前代码就在:
my-lib
内部。
那么:
import core from "my-lib/core";
不会去外部 node_modules 查找。
Node.js 会发现:
当前包名就是 my-lib
直接使用当前 package.json 的 exports。
这就是:
PACKAGE_SELF_RESOLVE
九、imports 机制
imports 和 exports 很像。
区别:
exports 给别人用
imports 给自己用
例如:
{
"imports": {
"#db": "./src/db/index.js",
"#utils/*": "./src/utils/*.js"
}
}
之后:
import db from "#db";
实际解析:
./src/db/index.js
再例如:
import stringUtil from "#utils/string.js";
得到:
./src/utils/string.js
十、Pattern Match
imports 和 exports 支持通配符。
例如:
{
"exports": {
"./features/*": "./src/features/*.js"
}
}
执行:
import user from "pkg/features/user";
匹配:
*
得到:
user
最终:
./src/features/user.js
十一、PACKAGETARGETRESOLVE
这是 exports/imports 真正执行映射的地方。
支持四种类型。
String
{
"exports": {
".": "./dist/index.js"
}
}
直接解析。
Object
{
"exports": {
".": {
"node": "./node.js",
"browser": "./browser.js",
"default": "./index.js"
}
}
}
根据 conditions 选择。
Array
{
"exports": {
".": [
"./native.js",
"./fallback.js"
]
}
}
前一个失败。
继续尝试下一个。
null
{
"exports": {
"./internal/*": null
}
}
明确禁止导出。
十二、ESMFILEFORMAT
URL 定位完成后。
Node.js 需要判断:
这个文件应该按什么格式加载?
.mjs
module
.cjs
commonjs
.json
json
.wasm
wasm
.node
addon
原生扩展模块。
十三、type 字段的作用
对于:
.js
文件。
Node.js 会查找最近的 package.json。
例如:
{
"type": "module"
}
那么:
app.js
被解释为:
ESM
如果:
{
"type": "commonjs"
}
则:
app.js
被解释为:
CommonJS
因此:
.mjs
永远 ESM。
.cjs
永远 CommonJS。
.js
取决于 type。
十四、为什么 ESM 不支持目录导入
CommonJS:
require("./foo");
Node.js 会尝试:
foo.js
foo.json
foo.node
foo/index.js
foo/index.json
ESM:
import "./foo";
不会猜。
如果:
foo
是目录。
直接报错:
Unsupported Directory Import
正确写法:
import "./foo/index.js";
或者:
import "./foo.js";
十五、LOOKUPPACKAGESCOPE
Node.js 如何找到最近的 package.json?
算法:
当前目录
↓
父目录
↓
继续向上
↓
直到根目录
例如:
/app/src/pages/home/index.js
查找:
/app/src/pages/home/package.json
/app/src/pages/package.json
/app/src/package.json
/app/package.json
找到第一个就停止。
十六、自定义 ESM Resolver
Node.js 默认解析:
import x from "./a.js";
import y from "react";
如果想支持:
import x from "@/utils";
怎么办?
答案:
Loader Hook
例如:
import { pathToFileURL } from "node:url";
import path from "node:path";
export async function resolve(
specifier,
context,
nextResolve
) {
if (specifier.startsWith("@/")) {
return {
url: pathToFileURL(
path.resolve(
process.cwd(),
"src",
specifier.slice(2)
)
).href,
shortCircuit: true
};
}
return nextResolve(specifier, context);
}
运行:
node --loader ./loader.mjs app.js
之后:
import util from "@/utils.js";
就会自动映射:
src/utils.js
十七、完整解析链路
Node.js ESM 解析可以总结为:
import specifier
↓
ESM_RESOLVE
↓
判断类型
URL
路径
#imports
裸包名
↓
PACKAGE_RESOLVE
↓
exports
imports
main
↓
得到最终 URL
↓
ESM_FILE_FORMAT
↓
module
commonjs
json
wasm
↓
Loader
↓
Execute
总结
Node.js ESM 的核心思想不是“寻找文件”。
而是:
specifier
↓
确定 URL
↓
确定模块格式
↓
加载执行
整个 exports、imports、type、conditional exports、loader 机制,本质上都是围绕这一目标构建的。
ESM 解析模型相比 CommonJS 更严格、更静态、更接近浏览器,也更适合现代工具链(Vite、Webpack、Rspack、Rollup、Turbopack)进行分析和优化。