模块解析
Bun 如何解析模块以及处理 JavaScript 和 TypeScript 中的导入
JavaScript 生态系统正经历一场从 CommonJS 模块转向原生 ES 模块(ESM)的长期变革。不同的运行时和构建工具历来对导入说明符如何映射到磁盘上的文件意见不一。Bun 致力于提供一致且可预测、无需配置的模块解析系统。
语法
考虑以下文件。
import { hello } from "./hello";
hello();
export function hello() {
console.log("Hello world!");
}
运行 index.ts 会打印 "Hello world!"。
bun index.ts
Hello world!
这里的 ./hello 是一个没有扩展名的相对路径。带扩展名的导入是可选的,但受支持。 为解析此导入,Bun 会按以下顺序检查文件:
./hello.tsx./hello.jsx./hello.mts./hello.ts./hello.mjs./hello.js./hello.cts./hello.cjs./hello.json./hello/index.tsx./hello/index.jsx./hello/index.mts./hello/index.ts./hello/index.mjs./hello/index.js./hello/index.cts./hello/index.cjs./hello/index.json
如果导入路径包含扩展名,Bun 会首先检查完全匹配的文件。如果不存在完全匹配的文件,Bun 会回退到将上述扩展名列表追加到完整路径后进行尝试(因此 ./hello.world 可以解析为 ./hello.world.ts)。
import { hello } from "./hello";
import { hello } from "./hello.ts"; // 这样写是可以的
TypeScript 兼容性还有一条额外规则。如果你导入 from "*.js" 或 from "*.jsx",Bun 也会检查是否存在匹配的 *.ts 或 *.tsx 文件。在 node_modules 之外,from "*.mjs" 也会匹配 *.mts。这条规则遵循 TypeScript 编译器的文件扩展名替换规则,因此源文件可以通过编译后的输出路径互相引用。与 TypeScript 不同,Bun 不会将 .cjs 重写为 .cts。
import { hello } from "./hello";
import { hello } from "./hello.ts"; // 这样写是可以的
import { hello } from "./hello.js"; // 这也可以
Bun 同时支持 ES 模块(import/export 语法)和 CommonJS 模块(require()/module.exports)。以下 CommonJS 版本也可以在 Bun 中运行。
const { hello } = require("./hello");
hello();
function hello() {
console.log("Hello world!");
}
exports.hello = hello;
不过,建议新项目避免使用 CommonJS。
模块系统
Bun 原生支持 CommonJS 和 ES 模块。对于新项目,推荐使用 ES 模块格式,但 CommonJS 模块在 Node.js 生态系统中仍被广泛使用。
在 Bun 的 JavaScript 运行时中,ES 模块和 CommonJS 模块都可以使用 require。如果目标模块是 ES 模块,require 会返回模块命名空间对象(等价于 import * as)。如果目标模块是 CommonJS 模块,require 会返回 module.exports 对象(与 Node.js 中相同)。
| 模块类型 | require() | import * as |
|---|---|---|
| ES 模块 | 模块命名空间对象 | 模块命名空间对象 |
| CommonJS | module.exports | default 是 module.exports,module.exports 的键作为命名导出 |
使用 require()
你可以 require() 任何文件或包,甚至是 .ts 或 .mjs 文件。
const { foo } = require("./foo"); // 扩展名可选
const { bar } = require("./bar.mjs");
const { baz } = require("./baz.tsx");
什么是 CommonJS 模块?
2016 年,ECMAScript 增加了对 ES 模块的支持。ES 模块是 JavaScript 模块的标准。然而,数百万个 npm 包仍然使用 CommonJS 模块。
CommonJS 模块使用 module.exports 导出值,通常使用 require 导入。
const stuff = require("./stuff");
module.exports = { stuff };
CommonJS 与 ES 模块之间最大的区别在于,CommonJS 模块是同步的,而 ES 模块是异步的。其他区别包括:
- ES 模块支持顶层
await,而 CommonJS 模块不支持。 - ES 模块始终处于严格模式,而 CommonJS 模块则不然。
- 浏览器不原生支持 CommonJS 模块,但可以通过
<script type="module">原生支持 ES 模块。 - CommonJS 模块无法进行静态分析,而 ES 模块只允许静态导入和导出。
- 静态
import语句会像 CommonJSrequire一样同步运行。你也可以使用异步import()函数即时加载 ES 模块,这种方式称为“动态导入”。
使用 import
你可以 import 任何文件或包,甚至是 .cjs 文件。
import { foo } from "./foo"; // 扩展名可选
import bar from "./bar.ts";
import { stuff } from "./my-commonjs.cjs";
同时使用 import 和 require()
在 Bun 中,你可以在同一文件内同时使用 import 和 require —— 它们都随时有效。
import { stuff } from "./my-commonjs.cjs";
import Stuff from "./my-commonjs.cjs";
const myStuff = require("./my-commonjs.cjs");
顶层 await
唯一的例外是顶层 await。你不能 require() 使用了顶层 await 的文件,因为 require() 是同步函数。
幸运的是,很少有库使用顶层 await,因此这通常不是问题。但如果你在应用程序代码中使用顶层 await,请确保该文件不会在应用程序的其他位置被 require()。请改用 import 或动态 import()。
导入包
Bun 实现了 Node.js 的模块解析算法,因此可以用裸模块名从 node_modules 中导入包。
import { stuff } from "foo";
完整算法请参阅 Node.js 文档。简而言之:如果你从 "foo" 导入,Bun 会沿文件系统向上查找包含 foo 包的 node_modules 目录。
找到 foo 包后,Bun 会读取其 package.json 以确定包的入口点。Bun 会先读取 exports 字段,并检查以下条件。
{
"name": "foo",
"exports": {
"bun": "./index.js",
"node-addons": "./index.js", // 除非传入了 --no-addons
"node": "./index.js",
"require": "./index.js", // 如果导入方使用 require()
"import": "./index.mjs", // 如果导入方使用 import
"default": "./index.js"
}
}
package.json 中最先出现的条件决定了包的入口点。
Bun 支持子路径的 "exports" 和 "imports"。
{
"name": "foo",
"exports": {
".": "./index.js"
}
}
子路径导出和条件导出可以一起使用。
{
"name": "foo",
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.js"
}
}
}
与 Node.js 一样,在 "exports" 映射中指定任何子路径后,其他子路径就无法导入。你只能导入明确导出的文件。对于前面的 package.json:
import stuff from "foo"; // 这样可行
import stuff from "foo/index.mjs"; // 这样不行
如果未定义 exports,Bun 会回退到传统的顶层入口点字段。在运行时,如果存在 "main"(或隐式的 index.* 文件),Bun 会优先使用它,否则使用 "module"。
{
"name": "foo",
"module": "./index.js",
"main": "./index.js"
}
NODE_PATH
Bun 支持使用 NODE_PATH 指定其他模块解析目录:
NODE_PATH=./packages bun run src/index.js
// packages/foo/index.js
export const hello = "world";
// src/index.js
import { hello } from "foo";
多个路径使用平台的分隔符(Unix 上为 :,Windows 上为 ;):
NODE_PATH=./packages:./lib bun run src/index.js # Unix/macOS
NODE_PATH="./packages;./lib" bun run src/index.js # Windows
自定义条件
--conditions 标志指定从 package.json 的 "exports" 解析包时要使用的条件。
bun build 和 Bun 运行时都支持此标志。
# 使用 bun build:
bun build --conditions="react-server" --target=bun ./app/foo/route.js
# 使用 Bun 运行时:
bun --conditions="react-server" ./app/foo/route.js
你也可以在 Bun.build 中以编程方式使用 conditions:
await Bun.build({
conditions: ["react-server"],
target: "bun",
entryPoints: ["./app/foo/route.js"],
});
npm 包的内置替代项
Bun 内置了少数几个 npm 包的实现。当你导入以下说明符之一时,Bun 会加载内置模块。即使该包已安装在 node_modules 中,Bun 也会这样处理。
| 说明符 | 内置模块 |
|---|---|
undici | fetch、Request、Response、Headers、FormData、File 和 WebSocket 均由 Bun 自行实现。request() 会调用 Bun 的 fetch。 |
node-fetch | Bun 的 fetch 的包装器。response.body 是 Node.js Readable 流,与 node-fetch 中相同。 |
isomorphic-fetch | Bun 的 fetch。 |
@vercel/fetch | Bun 的 fetch 的包装器。 |
ws、ws/lib/websocket | 基于 Bun 原生 WebSocket 客户端和服务器构建的 WebSocket 和 WebSocketServer。 |
utf-8-validate | 与 node:buffer 中的 isUtf8() 相同的原生函数。 |
abort-controller、abort-controller/polyfill | 全局的 AbortController 和 AbortSignal。 |
next/dist/compiled/undici、next/dist/compiled/node-fetch、next/dist/compiled/ws | 与 undici、node-fetch 和 ws 相同的内置模块。 |
内置模块并未实现其替代包的所有 API。例如,内置的 undici 导出了 Agent、Pool、Client、ProxyAgent 和 MockAgent,以确保导入成功。这些类不会管理连接,而且 fetch 会忽略 dispatcher 选项。stream()、pipeline()、connect() 和 upgrade() 会抛出错误。如果你的代码需要的 API 缺失,请提交问题。
Bun 会匹配整个说明符。其他任何子路径(例如 undici/package.json)都会解析为已安装的包。对于被替代的说明符,require.resolve() 和 import.meta.resolve() 会返回内置模块的名称,而不是文件路径。例如,"undici" 和 "next/dist/compiled/undici" 都会解析为 "undici"。
当目标为 bun 时,bun build 会应用相同的替代项,并在打包结果中保留导入。使用 --target=node 或 --target=browser 时,bun build 会打包 node_modules 中的副本。
路径重映射
Bun 支持通过 tsconfig.json 中 TypeScript 的 compilerOptions.paths 重新映射导入路径,这与编辑器配合使用效果良好。如果你不是 TypeScript 用户,可以在项目根目录中使用 jsconfig.json 来实现相同的行为。
{
"compilerOptions": {
"paths": {
"config": ["./config.ts"], // 映射标识符到文件
"components/*": ["components/*"] // 通配符匹配
}
}
}
Bun 还支持 package.json 中 Node.js 风格的子路径导入,其中映射路径必须以 # 开头。TypeScript 和编辑器也能解析这些路径,你可以同时使用这两种机制。
{
"imports": {
"#config": "./config.ts", // 映射标识符到文件
"#components/*": "./components/*" // 通配符匹配
}
}
Bun 中 CommonJS 互操作的底层细节
Bun 的 JavaScript 运行时原生支持 CommonJS。当 Bun 的 JavaScript 转译器检测到 module.exports 的使用时,会将该文件视为 CommonJS。随后,模块加载器会将转译后的模块包装在一个如下所示的函数中:
(function (module, exports, require) {
// 转译后的模块代码
})(module, exports, require);
module、exports 和 require 与 Node.js 中的 module、exports 和 require 非常相似。Bun 在 C++ 中通过 with scope 为它们赋值。内部的 Map 会存储 exports 对象,以便在模块完全加载前处理循环 require 调用。
CommonJS 模块成功执行后,Bun 会创建一个 Synthetic Module Record,并将默认 ES 模块导出设置为 module.exports。如果 module.exports 是一个对象,Bun 也会将其键重新导出为命名导出。
Bun 的打包器工作方式有所不同:它会将 CommonJS 模块包装在一个 require_${moduleName} 函数中,该函数返回 module.exports 对象。
import.meta
import.meta 对象公开当前模块的信息。它是 JavaScript 语言的一部分,但其内容并未标准化:每个“宿主”(浏览器或运行时)都会在 import.meta 对象上实现自己的属性。
Bun 实现了如下属性。
import.meta.dir; // => "/path/to/project"
import.meta.file; // => "file.ts"
import.meta.path; // => "/path/to/project/file.ts"
import.meta.url; // => "file:///path/to/project/file.ts"
import.meta.main; // 如果此文件是由 `bun run` 直接执行,返回 `true`
// 否则返回 `false`
import.meta.resolve("zod"); // => "file:///path/to/project/node_modules/zod/index.js"
| 属性 | 描述 |
|---|---|
import.meta.dir | 包含当前文件的目录的绝对路径,例如 /path/to/project。等同于 CommonJS 模块(以及 Node.js)中的 __dirname |
import.meta.dirname | import.meta.dir 的别名,用于兼容 Node.js |
import.meta.env | process.env 的别名。 |
import.meta.file | 当前文件的名称,例如 index.tsx |
import.meta.path | 当前文件的绝对路径,例如 /path/to/project/index.ts。等同于 CommonJS 模块(以及 Node.js)中的 __filename |
import.meta.filename | import.meta.path 的别名,用于兼容 Node.js |
import.meta.main | 表示当前文件是否为当前 bun 进程的入口点:如果由 bun run 直接执行,则为 true;如果是导入的,则为 false |
import.meta.resolve | 将模块说明符(例如 "zod" 或 "./file.tsx")解析为 URL。等同于浏览器中的 import.meta.resolve。例如,import.meta.resolve("zod") 会返回 "file:///path/to/project/node_modules/zod/index.js" |
import.meta.url | 当前文件的 string URL,例如 file:///path/to/project/index.ts。等同于浏览器中的 import.meta.url |