寫 JavaScript 久了,多少都被這幾個錯誤訊息困擾過:
- 在瀏覽器裡寫
require('lodash'),跳require is not defined - 在 Node 裡寫
import fs from 'fs',跳Cannot use import statement outside a module - 裝了個套件,發現它只給
.mjs,你的專案卻整套是require package.json裡突然要你寫"type": "module",不寫就報錯
這些不是 JavaScript 在找你麻煩。它們全都指向同一件事:JavaScript 有兩套不相容的模組系統,一套叫 CommonJS(CJS),一套叫 ECMAScript Modules(ESM)。
要搞懂為什麼會變成這樣,得先回到那個 JavaScript 根本沒有模組的年代。
沒有模組的年代
最早的 JavaScript 只是拿來讓網頁動一動的腳本。所有程式碼共用同一個全域空間——也就是瀏覽器的 window。
<script src="a.js"></script>
<script src="b.js"></script>
a.js 裡宣告的 var user,b.js 直接就能用——因為它其實被掛到了 window 上。聽起來方便,實際上是災難。兩個檔案不小心用了同名變數,後載入的就把前面的蓋掉,而且沒有任何錯誤。這就是惡名昭彰的全域污染(global pollution)。
當時的人想了個土法煉鋼的解法:用函式把程式碼包起來,製造一個獨立作用域。這就是 IIFE(Immediately Invoked Function Expression,立即執行函式):
var myModule = (function () {
var count = 0; // 被關在函式裡,外面碰不到
function increment() { count++; }
return { increment }; // 只把要公開的東西吐出來
})();
myModule.increment();
IIFE 解決了作用域隔離,但它沒解決依賴管理。模組之間誰依賴誰、載入順序怎麼排,全靠人工在 HTML 裡擺對 <script> 的先後。專案一大就無解。
兩條分岔的路:CommonJS 與 AMD
2009 年,Node.js 出現,要把 JavaScript 帶到伺服器端。伺服器端跟瀏覽器有個本質差異:檔案就在本機硬碟上,讀取幾乎是瞬間完成。
於是 Node 採用了 CommonJS 規範。它的載入是同步的——require 一個檔案,程式就停在那裡等它讀完、執行完、回傳結果,再往下走。在硬碟讀取的情境下,這完全合理。
// math.js
function add(a, b) { return a + b; }
module.exports = { add }; // 把 add 掛到 module.exports 上
// main.js
const { add } = require('./math'); // 同步讀取、執行、回傳
console.log(add(1, 2)); // 3
但同一招搬到瀏覽器就行不通了。瀏覽器的檔案要透過網路下載,可能要等好幾百毫秒。如果 require 是同步的,整個頁面就會卡死在那裡等下載。
所以瀏覽器陣營走了另一條路:AMD(Asynchronous Module Definition,非同步模組定義),代表作是 RequireJS。它用 callback 來處理非同步載入:
// 先宣告依賴,載入完才執行 callback
define(['./math'], function (math) {
console.log(math.add(1, 2));
});
後來又冒出一個 UMD(Universal Module Definition),本質是一段 if-else,偵測當前環境是 CJS 還是 AMD,兩邊都餵。你今天還是常在套件的 dist 檔案開頭看到那段又臭又長的判斷式——那就是 UMD。
局面變成:同一種語言,伺服器端跟瀏覽器端用著完全不同、還互不相容的模組寫法。 這顯然不是長久之計。
ESM:語言層級的官方答案
2015 年,ES6(ES2015)做了一件根本的事:把模組系統寫進語言規範本身。這就是 ESM(ECMAScript Modules),也就是你現在看到的 import / export。
// math.js
export function add(a, b) { return a + b; }
// main.js
import { add } from './math.js';
console.log(add(1, 2)); // 3
ESM 不是又一套第三方方案,而是語言自己定義的標準。它的目標很明確:一套語法,瀏覽器和伺服器都能用,徹底結束分裂。
但事情沒這麼快收尾。Node.js 早就建立在 CommonJS 之上,整個 npm 生態系幾百萬個套件都是 require 寫的。ESM 要進 Node,等於要跟一個已經根深柢固的系統共存。於是就有了我們今天面對的這團——兩套系統並行,還得想辦法互通。
要真正用好它們,得看清楚這兩套在程式碼層面到底差在哪。
實際差別一:語法與匯出機制
最表面的差別是語法,但語法背後是兩種不同的設計哲學。
CommonJS 用 require 匯入、module.exports 匯出。module.exports 本質就是一個普通物件,你可以隨意往上面掛東西,甚至整個換掉:
// CommonJS — exports 是一個可以動態操作的物件
const fs = require('fs');
module.exports.foo = 1;
module.exports = function () {}; // 連整個換掉都行
ESM 用 import / export。它匯出的不是值的複製,而是綁定(binding)——你 import 進來的東西,是對原始變數的唯讀參照:
// counter.js
export let count = 0;
export function inc() { count++; }
// main.js
import { count, inc } from './counter.js';
console.log(count); // 0
inc();
console.log(count); // 1 — 看到的是原始變數的最新值,不是當初的複製
這個差異很關鍵。CJS 的 require 拿到的是匯出當下的那份值——模組內部之後重新賦值,require 方不會跟著變;ESM 的 import 拿到的是一條活的連結,原始變數變了,這邊跟著變。
實際差別二:靜態 vs 動態
這是兩者最深層、影響最大的差別。
CommonJS 是動態的。 require 只是一個普通的函式呼叫,可以出現在任何地方——寫在 if 裡、放在迴圈中、用變數拼出路徑都行:
// CommonJS — require 可以在執行期才決定載入什麼
if (process.env.NODE_ENV === 'development') {
const devTool = require('./dev-tool'); // 完全合法
}
const name = 'math';
const mod = require(`./${name}`); // 路徑用變數組出來也行
ESM 是靜態的。 import 必須寫在模組最頂層,不能包在條件式或函式裡。路徑也只能是寫死的字串字面值:
// ESM — 這些全都會報錯
if (condition) {
import x from './x.js'; // SyntaxError
}
import y from `./${name}.js`; // SyntaxError,路徑不能是變數
為什麼 ESM 要這麼「死板」?因為靜態。靜態的意思是:程式還沒執行,光看原始碼就能完整知道誰 import 了誰、import 了哪些東西。
這個特性換來兩個 CJS 給不了的好處:
- Tree-shaking(搖樹最佳化):打包工具能在編譯期就分析出哪些 export 從來沒被用到,直接從最終 bundle 裡刪掉。CJS 因為是動態的,工具沒辦法確定
module.exports上某個屬性到底會不會在執行期被某個require用到,只能整包留著。 - 更早抓錯:import 的名字打錯,ESM 在載入階段就報錯;CJS 要等到執行到那一行、發現
undefined才炸。
如果真的需要動態載入,ESM 另外提供了 import() 函式(注意是括號,不是宣告),它回傳一個 Promise:
// 動態 import — 回傳 Promise,需要時才載入
if (condition) {
const mod = await import('./heavy-module.js');
mod.run();
}
實際差別三:同步 vs 非同步載入
回到最初那條分岔路——載入時機。
CommonJS 同步載入。 require 一執行,當場就把目標模組讀完、跑完、回傳。後面的程式碼保證能立刻拿到結果。
ESM 非同步載入。 引擎處理 ESM 分成三個階段:先把整棵依賴樹的檔案抓回來(construction)、建立模組之間的綁定關係(instantiation)、最後才執行(evaluation)。這個流程天生支援非同步,也正是為什麼瀏覽器能直接用 <script type="module"> 載入而不卡頁面:
<!-- 瀏覽器原生支援,非同步載入不阻塞 -->
<script type="module" src="main.js"></script>
這也帶來一個實際差異:在 ESM 裡可以直接用頂層 await(top-level await),CJS 不行。
// ESM — 模組最頂層可以直接 await
const config = await fetch('/config.json').then(r => r.json());
export { config };
實際差別四:環境變數與 this
幾個寫程式時會實際踩到的小坑。
CJS 模組裡有幾個現成的變數:require、module、exports、__dirname、__filename。ESM 裡這些全部沒有。最常被絆倒的是 __dirname——在 ESM 裡要這樣自己拼:
// ESM 裡取得當前檔案目錄
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Node 20.11 之後,ESM 補上了
import.meta.dirname和import.meta.filename,新版本直接用就好,上面那段fileURLToPath是舊版才需要的寫法。
還有 this。CJS 模組頂層的 this 指向 module.exports;ESM 模組頂層的 this 是 undefined。
下面這張表把四個差別整理在一起:
| 面向 | CommonJS | ESM |
|---|---|---|
| 匯入 / 匯出 | require / module.exports | import / export |
| 載入時機 | 同步 | 非同步 |
| 解析時機 | 動態(執行期) | 靜態(編譯期) |
| Tree-shaking | 不支援 | 支援 |
| 頂層 await | 不行 | 可以 |
__dirname 等 | 有 | 無(需自己組) |
頂層 this | module.exports | undefined |
互通的陷阱
CJS 和 ESM 沒辦法乾淨地直接互叫,這是現在最折磨人的地方。
ESM 可以 import CJS 模組,因為 Node 幫你把整個 module.exports 包成 default export:
// ESM 引用 CJS 套件——拿到的是整包 module.exports 當 default
import pkg from 'some-cjs-package';
const { foo } = pkg;
反過來就麻煩了。傳統上 CJS 不能直接 require 一個 ESM 模組——因為 require 是同步的,而 ESM 是非同步的,同步硬接非同步接不起來(這道牆在 Node 22 之後才開始鬆動,後面會講)。在那之前,只能改用動態 import():
// CJS 裡要用 ESM 模組,只能走非同步的 import()
async function main() {
const mod = await import('./esm-module.mjs');
mod.run();
}
至於 Node 怎麼判斷一個檔案是 CJS 還是 ESM,規則是:
- 副檔名
.mjs→ ESM;.cjs→ CJS - 副檔名
.js→ 看最近的package.json裡有沒有"type": "module",有就是 ESM,沒有就預設 CJS
這就是為什麼你會看到專案裡冒出 .mjs、.cjs,以及 package.json 那行 "type": "module"——它們都是在替 Node 標記「這個檔案該用哪套系統解析」。
未來是 ESM
方向其實已經很清楚:ESM 是 JavaScript 模組的未來,CommonJS 會慢慢退場。 理由不是誰比較流行,而是幾個結構性的事實。
- ESM 是語言標準。 CJS 是 Node 早年的自訂規範,從來沒進過 ECMAScript。
import/export才是寫進語言裡、瀏覽器原生就支援的那套。 - 瀏覽器只認 ESM。 瀏覽器從來沒有、也不會原生支援
require。前端走打包工具(Vite、esbuild、Rollup)這條路,而這些工具全都以 ESM 為核心——因為只有 ESM 能 tree-shaking。 - 生態系正在整批遷移。 不少知名套件(例如 chalk、node-fetch 的新版本)已經改成 ESM-only,逼著下游跟進。新專案的鷹架(scaffold)現在預設也多半是 ESM。
- Node 持續補齊缺口。 早期 CJS 沒辦法
requireESM,這道牆在 Node 22 之後開始鬆動——較新的版本已經支援在 CJS 裡同步require一個沒有頂層 await 的 ESM 模組,互通的痛點正一點一點被填平。
但要誠實說一句:CommonJS 不會明天就消失。 npm 上累積了十幾年、幾百萬個 CJS 套件,這些存量不可能一夕改寫。在可見的未來,你寫的程式碼仍然得跟 CJS 共存——讀得懂 require、知道怎麼跟 CJS 套件互通,依然是必要技能。
務實的建議很簡單:新專案直接用 ESM,package.json 寫上 "type": "module",一開始就站在標準這邊;維護舊的 CJS 專案則不必急著全面重寫,等遷移成本真的低到划算再動。
結語
CommonJS 與 ESM 的分裂,不是誰設計失誤,而是一段真實歷史留下的痕跡:Node 在 2009 年需要一套能跑在伺服器、同步載入的模組系統,那時 ESM 根本還不存在;六年後語言才補上官方標準,但生態系早已長在 CJS 上,於是兩套被迫並存至今。
把這條線拉直來看:
全域污染 → IIFE 隔離作用域 → CommonJS(同步、伺服器)與 AMD(非同步、瀏覽器)分頭發展 → ESM 以語言標準的身分統一兩端 → 漫長的互通與遷移。
下次你再撞到 Cannot use import statement outside a module,你不會只覺得是「JavaScript 又在搞」——你會知道,自己正站在這場橫跨十幾年的模組演進史的交界線上,而那條線,正緩緩往 ESM 那一端移動。