moduleResolution: Choosing Between bundler and nodenext
A real-world case of switching from nodenext to bundler, understanding the differences and when to use each
tsconfig.base.json的moduleResolution從nodenext改成bundler,同時所有.jsextension 都被移除了——這兩個改動是連動的嗎?
答案是:是的。這兩個改動背後牽涉到 TypeScript 對模組解析策略的根本差異
nodenext 是什麼
TypeScript 4.7 引入了 nodenext,嚴格遵循 Node.js 的 ESM 規範。最明顯的特徵:import 必須加 .js extension
// nodenext 要求 .js extension
export * from "./lib/fixtures.js";
export * from "./lib/testable-page.js";
export * from "./lib/steps/index.js";為什麼是 .js 不是 .ts?因為 TypeScript 不會改寫 import path,你寫的 import
會原封不動地出現在編譯後的 JavaScript 中,而 Node.js runtime 需要的是 .js 檔案。所以即使 source
是 .ts,import 時仍然要寫 .js——第一次看到確實很反直覺,但理解背後邏輯後就合理了
nodenext 的核心理念是:TypeScript 不該替你處理模組解析,你寫的 import path 就是最終 runtime 會看到的 path。
這表示:
- 不能省略副檔名
- 不能 import 一個資料夾(除非有
package.json的exports) - 完全遵循 Node.js 的 ESM 演算法
bundler 是什麼
TypeScript 5.0 引入了 bundler,它假設你的程式碼會經過 bundler(Webpack、Vite、esbuild)或其他 transformer 處理,因此模組解析的規則可以更寬鬆。
// bundler 不需要 extension
export * from "./lib/fixtures";
export * from "./lib/testable-page";
export * from "./lib/steps/index";bundler 的核心理念是:你的 import path 不是給 Node.js 直接解析的,中間有 bundler 幫你處理。所以 TypeScript 不需要強制你遵循 Node.js 的嚴格規範
對照表
| 特性 | nodenext | bundler |
|---|---|---|
| Import extension | 必須加 .js | 不需要 |
| 適用場景 | 直接在 Node.js 執行 | 有 bundler/transformer |
| ESM 嚴格度 | 嚴格遵循 Node.js 規範 | 寬鬆,由 bundler 處理 |
| package.json exports | 完整支援 | 完整支援 |
| 發佈到 npm | 適合 | 不適合(消費者可能沒有 bundler) |
搭配的 module 設定 | nodenext | esnext |
什麼時候用哪個
選 nodenext: 你的程式碼要直接在 Node.js 跑(CLI tool、server),或者要發佈到 npm
讓別人用。因為你無法假設消費者的環境有 bundler,import path 必須在 Node.js runtime 直接可用
選 bundler: 內部 monorepo、有 bundler 的前端專案、E2E 測試專案(Playwright 有自己的
transform)。這些場景中,程式碼不會直接被 Node.js 執行,中間一定有工具層處理模組解析
簡單的判斷流程:
- 程式碼會發佈到 npm 嗎? →
nodenext - 程式碼會直接在 Node.js 執行,沒有 bundler? →
nodenext - 有 bundler 或 transformer? →
bundler
實際案例
背景是一個 E2E testing monorepo,用 Playwright 跑測試。原本設定為 nodenext,後來切換成 bundler
改了什麼
tsconfig.base.json 的 module 和 moduleResolution:
// tsconfig.base.json
- "module": "nodenext",
- "moduleResolution": "nodenext",
+ "module": "esnext",
+ "moduleResolution": "bundler",所有 packages 的 import 移除 .js extension:
// packages/e2e/src/index.ts
- export * from './lib/fixtures.js';
- export * from './lib/testable-page.js';
+ export * from './lib/fixtures';
+ export * from './lib/testable-page';為什麼適合 bundler
這個案例切換到 bundler 有三個原因:
- Playwright 有自己的 TypeScript transformer,測試程式碼不會直接透過 Node.js ESM 執行,所以不需要嚴格遵循 Node.js 的模組解析規則
- 所有 packages 都是
private: true,不會發佈到 npm,不需要考慮消費者的環境 - Import 更乾淨,不用寫看起來很奇怪的
.jsextension
module 和 moduleResolution 是連動的——用 nodenext 時,module 也要設成 nodenext;切換到
bundler 時,module 通常改成 esnext。不匹配的話 TypeScript 會報錯
參考資源
系列文章
- Nx Monorepo 架構演進
- 型別解析:paths 與 Project References
- moduleResolution:bundler vs nodenext ← 目前位置