跳到主要內容
程式語言

從全域污染到 import:CommonJS 與 ESM 的由來、差別與未來

為什麼 Node 用 require,瀏覽器卻用 import?為什麼有些套件叫 .mjs,有些叫 .cjs?這些混亂的根源,是 JavaScript 模組系統一段橫跨十幾年的演進史。本文從沒有模組的年代講起,用實際程式碼對照 CommonJS 與 ESM 的差別,再說清楚為什麼 ESM 是未來。

寫 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 userb.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,等於要跟一個已經根深柢固的系統共存。於是就有了我們今天面對的這團——兩套系統並行,還得想辦法互通。

要真正用好它們,得看清楚這兩套在程式碼層面到底差在哪。


實際差別一:語法與匯出機制

最表面的差別是語法,但語法背後是兩種不同的設計哲學。

CommonJSrequire 匯入、module.exports 匯出。module.exports 本質就是一個普通物件,你可以隨意往上面掛東西,甚至整個換掉:

// CommonJS — exports 是一個可以動態操作的物件
const fs = require('fs');

module.exports.foo = 1;
module.exports = function () {};   // 連整個換掉都行

ESMimport / 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 模組裡有幾個現成的變數:requiremoduleexports__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.dirnameimport.meta.filename,新版本直接用就好,上面那段 fileURLToPath 是舊版才需要的寫法。

還有 this。CJS 模組頂層的 this 指向 module.exports;ESM 模組頂層的 thisundefined

下面這張表把四個差別整理在一起:

面向CommonJSESM
匯入 / 匯出require / module.exportsimport / export
載入時機同步非同步
解析時機動態(執行期)靜態(編譯期)
Tree-shaking不支援支援
頂層 await不行可以
__dirname無(需自己組)
頂層 thismodule.exportsundefined

互通的陷阱

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 沒辦法 require ESM,這道牆在 Node 22 之後開始鬆動——較新的版本已經支援在 CJS 裡同步 require 一個沒有頂層 await 的 ESM 模組,互通的痛點正一點一點被填平。

但要誠實說一句:CommonJS 不會明天就消失。 npm 上累積了十幾年、幾百萬個 CJS 套件,這些存量不可能一夕改寫。在可見的未來,你寫的程式碼仍然得跟 CJS 共存——讀得懂 require、知道怎麼跟 CJS 套件互通,依然是必要技能。

務實的建議很簡單:新專案直接用 ESMpackage.json 寫上 "type": "module",一開始就站在標準這邊;維護舊的 CJS 專案則不必急著全面重寫,等遷移成本真的低到划算再動。


結語

CommonJS 與 ESM 的分裂,不是誰設計失誤,而是一段真實歷史留下的痕跡:Node 在 2009 年需要一套能跑在伺服器、同步載入的模組系統,那時 ESM 根本還不存在;六年後語言才補上官方標準,但生態系早已長在 CJS 上,於是兩套被迫並存至今。

把這條線拉直來看:

全域污染 → IIFE 隔離作用域 → CommonJS(同步、伺服器)與 AMD(非同步、瀏覽器)分頭發展 → ESM 以語言標準的身分統一兩端 → 漫長的互通與遷移。

下次你再撞到 Cannot use import statement outside a module,你不會只覺得是「JavaScript 又在搞」——你會知道,自己正站在這場橫跨十幾年的模組演進史的交界線上,而那條線,正緩緩往 ESM 那一端移動。