首页 / Bun 入门教程 / 模块解析

Bun 入门教程

模块解析

本教程共 34 篇 · 第 10 篇 · 更新于 2026-08-06

Bun模块解析ESMCommonJStsconfig pathsexports 条件node 兼容

本节目标:

  • 掌握 Bun 对无扩展名导入的候选查找顺序,以及 require / import / node_modules 三种上下文的差异。
  • 理解 ESM 与 CommonJS 在 Bun 中的双向互操作规则,以及顶层 await 的唯一例外。
  • 读懂 npm 包的 exports 条件匹配顺序,知道 "bun" 条件的特殊价值。
  • 区分 bun:node: 两类内置模块前缀。
  • 会用 tsconfig.jsonpathspackage.jsonimports 做路径重映射。

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 模块模块命名空间对象模块命名空间对象
CommonJSmodule.exportsdefaultmodule.exportsmodule.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.jsonpaths

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.jsonimports

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.dirnameimport.meta.dir 的别名,用于 Node.js 兼容
import.meta.envprocess.env 的别名
import.meta.file当前文件名,如 index.tsx
import.meta.path当前文件的绝对路径,等价于 __filename
import.meta.filenameimport.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" 并缓存,后续运行直接用缓存。版本的确定顺序是:

  1. 项目根目录有 bun.lock → 用锁文件里的版本;
  2. 否则沿目录树向上找包含 "foo" 依赖的 package.json → 用其中声明的 semver 范围;
  3. 都没有 → 用 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: 前缀要用起来,比裸写 sqlitefs 明确得多。
  • 没有 node_modules 就会触发自动安装,这在预期之外时可能让你困惑于”我明明没装为什么能跑”。

至此,第二篇「运行时」全部结束。从下一章开始,我们进入第三篇——包管理器,先从 bun install 讲起。