模組解析
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 |