模块解析
本教程共 34 篇 · 第 10 篇 · 更新于 2026-08-06
本节目标:
- 掌握 Bun 对无扩展名导入的候选查找顺序,以及
require/import/node_modules三种上下文的差异。- 理解 ESM 与 CommonJS 在 Bun 中的双向互操作规则,以及顶层
await的唯一例外。- 读懂 npm 包的
exports条件匹配顺序,知道"bun"条件的特殊价值。- 区分
bun:与node:两类内置模块前缀。- 会用
tsconfig.json的paths和package.json的imports做路径重映射。
10.1 背景:为什么”模块解析”值得单独一章
JavaScript 生态正处在从 CommonJS 迁向原生 ES 模块的多年过渡期里。不同运行时、不同构建工具,对”一条 import 说明符究竟对应磁盘上哪个文件”这件事,历史上给出过互不兼容的答案。结果就是:同一份代码在 Node 下能跑、在打包器里报错,或者反过来。
Bun 的目标是提供一套一致、可预测、零配置的模块解析系统。理解它的规则,能帮你在遇到 “Cannot find module” 时快速定位问题,而不是靠改扩展名试运气。
10.2 扩展名可以省略
先看最基础的情况:
// index.ts
import { hello } from "./hello";
hello();
// hello.ts
export function hello() {
console.log("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
Note上面这个顺序是本地 ESM
import语境下的。实际顺序会随上下文变化:
require()会先尝试 CommonJS 扩展名(.cts、.cjs),再尝试 ESM 的(.mts、.mjs);node_modules内部的导入会先尝试 JavaScript 扩展名,再尝试 TypeScript 扩展名。
如果导入路径本身带了扩展名,Bun 会先精确匹配这个文件;没找到时,再把上面那张扩展名列表追加到完整路径后重试。所以 ./hello.world 有可能解析到 ./hello.world.ts。
TypeScript 扩展名替换
还有一条专为 TypeScript 兼容而设的规则:如果你导入的是 *.js 或 *.jsx,Bun 也会去找同名的 *.ts 或 *.tsx;在 node_modules 之外,*.mjs 同样会匹配 *.mts。
// index.ts
import { hello } from "./hello"; // 可以
import { hello } from "./hello.ts"; // 可以
import { hello } from "./hello.js"; // 也可以,实际解析到 hello.ts
这与 TypeScript 编译器的「文件扩展名替换」行为一致,让源文件之间可以按”编译产物路径”互相引用。
Warning有一处和 TypeScript 不同:Bun 不会把
.cjs重写成.cts。写import "./foo.cjs"时,它就只找.cjs。
10.3 ESM 与 CommonJS 互操作
Bun 对 ES 模块(import/export)和 CommonJS 模块(require()/module.exports)都有原生支持。下面这份 CommonJS 写法在 Bun 里同样能跑:
// index.js
const { hello } = require("./hello");
hello();
// hello.js
function hello() {
console.log("Hello world!");
}
exports.hello = hello;
新项目仍然推荐用 ES 模块,但你不必为了迁移而一次性重写所有文件。
require 的返回值规则
在 Bun 的运行时里,ES 模块和 CommonJS 模块都能被 require。返回什么取决于目标模块的类型:
| 目标模块类型 | require() 返回 | import * as 得到 |
|---|---|---|
| ES 模块 | 模块命名空间对象 | 模块命名空间对象 |
| CommonJS | module.exports | default 为 module.exports,module.exports 的各个键作为具名导出 |
两种语法可以混用
require() 可以加载任何文件或包,包括 .ts、.mjs:
// index.ts
const { foo } = require("./foo"); // 扩展名可省略
const { bar } = require("./bar.mjs");
const { baz } = require("./baz.tsx");
import 同样可以加载 .cjs:
// index.ts
import { foo } from "./foo";
import bar from "./bar.ts";
import { stuff } from "./my-commonjs.cjs";
甚至在同一个文件里混用两者也没问题:
// index.ts
import { stuff } from "./my-commonjs.cjs";
import Stuff from "./my-commonjs.cjs";
const myStuff = require("./my-commonjs.cjs");
唯一的例外:顶层 await
这条规则只有一个例外——你不能 require() 一个使用了顶层 await 的文件。原因很直接:require() 本质上是同步的,而顶层 await 需要异步求值。
// 如果 ./config.ts 使用了顶层 await,这行会失败
const config = require("./config");
好在使用顶层 await 的库极少,实践中很少遇到。如果你自己的应用代码用了顶层 await,确保那个文件不会被别处 require()——改用 import 或动态 import() 即可。
10.4 导入 npm 包
Bun 实现了 Node.js 的模块解析算法,所以裸说明符(bare specifier)可以直接从 node_modules 导入:
// index.ts
import { stuff } from "foo";
简单说:当你 import from "foo" 时,Bun 会沿文件系统向上逐级查找包含 foo 包的 node_modules 目录。
Bun 也支持 NODE_PATH 指定额外的解析目录:
NODE_PATH=./packages bun run src/index.js
多个路径用平台分隔符连接(Unix 用 :,Windows 用 ;):
NODE_PATH=./packages:./lib bun run src/index.js # Unix / macOS
NODE_PATH=./packages;./lib bun run src/index.js # Windows
exports 条件的匹配顺序
找到 foo 包之后,Bun 读它的 package.json 确定入口,优先看 exports 字段,并按以下顺序匹配条件:
{
"name": "foo",
"exports": {
"bun": "./index.js",
"node-addons": "./index.js",
"node": "./index.js",
"require": "./index.js",
"import": "./index.mjs",
"default": "./index.js"
}
}
"bun":Bun 专属条件;"node-addons":除非传了--no-addons;"node":Node 环境条件;"require":导入方使用require()时;"import":导入方使用import时;"default":兜底。
**关键规则:这些条件中在 package.json 里出现得最早的那个,决定最终入口。**顺序敏感,不是”优先级表”,而是”先到先得”。
Bun 同样遵循子路径 "exports" 与 "imports":
{
"name": "foo",
"exports": {
".": "./index.js"
}
}
子路径与条件导出可以组合:
{
"name": "foo",
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.js"
}
}
}
Warning和 Node.js 一样,一旦在
exports里声明了任何子路径,其余未显式导出的子路径就不可导入。以上面的配置为例:import stuff from "foo"; // 可以 import stuff from "foo/index.mjs"; // 不行
如果包里没有 exports 字段,Bun 会回退到传统的顶层入口字段:运行时优先用 "main"(或隐式的 index.* 文件),没有时才用 "module"。
{
"name": "foo",
"module": "./index.js",
"main": "./index.js"
}
Tip发布 TypeScript 源码:Bun 支持特殊的
"bun"导出条件。如果你的库用 TypeScript 写成,可以把未转译的*.ts文件直接发到 npm,并在"bun"条件里指向*.ts入口——Bun 会直接导入并执行 TypeScript 源文件。这样 Bun 用户拿到的是原始源码(调试体验更好),其他运行时仍走"node"/"default"指向的编译产物。
自定义条件
--conditions 用于指定解析 exports 时启用的额外条件。Bun 的运行时和 bun build 都支持:
# 打包时使用
bun build --conditions="react-server" --target=bun ./app/foo/route.js
# 运行时使用
bun --conditions="react-server" ./app/foo/route.js
也可以在 Bun.build 中以编程方式传入:
// build.ts
await Bun.build({
conditions: ["react-server"],
target: "bun",
entryPoints: ["./app/foo/route.js"],
});
10.5 两类内置模块前缀
除了相对路径和 npm 包,还有两类”不在磁盘上”的模块。
bun: —— Bun 自带模块
import { Database } from "bun:sqlite";
import { test, expect } from "bun:test";
import { dlopen } from "bun:ffi";
bun: 前缀标识 Bun 内置能力,不需要安装、不会去 node_modules 里找,也不会和任何 npm 包重名冲突。这一点很重要:明确的前缀避免了”某天有人在 npm 上发了个同名包”的歧义。
node: —— Node.js 内置模块
import fs from "node:fs";
import path from "node:path";
import { createServer } from "node:http";
Bun 的目标是 100% Node.js API 兼容(属目标,尚未完全达成),绝大多数 node: 模块可以直接使用。为了兼容存量代码,省略前缀的写法(import fs from "fs")同样有效,但新代码建议统一带 node: 前缀——它更明确,也能避免和同名 npm 包混淆。
10.6 路径重映射
项目一大,../../../utils/format 这种相对路径就会失控。Bun 支持两套重映射机制,可以同时使用。
方案一:tsconfig.json 的 paths
Bun 支持 TypeScript 的 compilerOptions.paths,编辑器也能识别,跳转和补全都正常:
{
"compilerOptions": {
"paths": {
"config": ["./config.ts"],
"components/*": ["components/*"]
}
}
}
之后就可以这样写:
import { db } from "config";
import Button from "components/Button";
Tip不用 TypeScript 的项目,在根目录放一个
jsconfig.json,写法完全相同,行为一致。
方案二:package.json 的 imports
Bun 也支持 Node.js 风格的子路径导入。映射键必须以 # 开头:
{
"imports": {
"#config": "./config.ts",
"#components/*": "./components/*"
}
}
import { db } from "#config";
import Button from "#components/Button";
TypeScript 和编辑器同样能解析这套机制。两种方案可以在同一个项目里并存——# 前缀的走 package.json,其余走 tsconfig.json。
10.7 import.meta
import.meta 对象暴露当前模块的信息。它是 JavaScript 语言的一部分,但具体内容由各”宿主”自行定义。Bun 实现了以下属性:
// /path/to/project/file.ts
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 | 当前文件所在目录的绝对路径,等价于 CommonJS 的 __dirname |
import.meta.dirname | import.meta.dir 的别名,用于 Node.js 兼容 |
import.meta.env | process.env 的别名 |
import.meta.file | 当前文件名,如 index.tsx |
import.meta.path | 当前文件的绝对路径,等价于 __filename |
import.meta.filename | import.meta.path 的别名,用于 Node.js 兼容 |
import.meta.main | 当前文件是否为进程入口 |
import.meta.resolve | 把说明符解析为 URL,行为对齐浏览器的 import.meta.resolve |
import.meta.url | 当前文件的 URL 字符串 |
import.meta.main 的典型用法是写”既能当库导入、又能直接执行”的脚本:
export function run() {
console.log("doing work");
}
if (import.meta.main) {
run(); // 只在被直接执行时触发
}
10.8 没有 node_modules 时会发生什么
这是 Bun 一个容易被忽略的行为:如果 Bun 在当前工作目录及其上级目录里找不到 node_modules,它会放弃 Node.js 风格解析,改用 Bun 风格模块解析。
在 Bun 风格解析下,被导入的包会在执行过程中自动安装到全局模块缓存(就是 bun install 用的那个缓存):
// index.ts
import { foo } from "foo"; // 自动安装 latest 版本
foo();
第一次运行时 Bun 自动装好 "foo" 并缓存,后续运行直接用缓存。版本的确定顺序是:
- 项目根目录有
bun.lock→ 用锁文件里的版本; - 否则沿目录树向上找包含
"foo"依赖的package.json→ 用其中声明的 semver 范围; - 都没有 → 用
latest。
你也可以在 import 说明符里直接写版本,完全跳过版本推断:
import { z } from "zod@3.0.0"; // 指定版本
import { z } from "zod@next"; // npm tag
import { z } from "zod@^3.20.0"; // semver 范围
这套机制让单文件脚本可以完全自包含——分享一个 gist 就能跑,不需要附带 package.json。
Warning自动安装有两个已知代价:没有 IntelliSense(编辑器的类型补全依赖
node_modules里的声明文件),以及不支持 patch-package。所以它适合脚本和小工具,正式项目仍应老老实实bun install。
10.9 小结与常见误区
- 扩展名可省略,但顺序有讲究:TSX → JSX → MTS → TS → MJS → JS → CTS → CJS → JSON,再试目录下的
index.*。上下文(require/import/node_modules内外)会改变这个顺序。 import "./x.js"可能命中x.ts,这是刻意的 TypeScript 兼容行为;但.cjs不会被重写成.cts。exports条件是”先出现先匹配”,不是按优先级排序。改包的exports字段顺序会改变解析结果。- 声明了任一子路径
exports后,未导出的子路径就无法导入——这是 Node.js 规范行为,不是 Bun 的限制。 - 顶层
await的文件不能被require(),这是 ESM / CJS 互操作的唯一硬边界。 bun:与node:前缀要用起来,比裸写sqlite、fs明确得多。- 没有
node_modules就会触发自动安装,这在预期之外时可能让你困惑于”我明明没装为什么能跑”。
至此,第二篇「运行时」全部结束。从下一章开始,我们进入第三篇——包管理器,先从 bun install 讲起。