JavaScript 模塊系統:一場至今未醒的歷史夢魘
一、引言:我們真的解決了“模塊化”嗎?
你可能以為,JavaScript 模塊系統早已標準化,import/export 就是答案。 但現實卻是另一番景象:構建報錯、依賴沖突、加載失敗幾乎成了日常。 從 <script>
到 require()
到 import/export
,我們始終在為過去的架構選擇埋單。
模塊化理應是解決復雜項目的基礎設施,卻變成了開發者最常踩雷的區域。
事實上模塊系統不僅沒帶來統一,反而成為 JavaScript 疲勞的結構性根源。這不得不談起JavaScript的歷史談起。
二、模塊混亂簡史:從混沌到多頭并立
-
沒有模塊的年代(2000 年代初)
JavaScript 的早期設計壓根沒考慮模塊化,全靠全局變量堆疊邏輯。 開發者只能依賴 <script>
標簽的順序加載,代碼易碎且無法維護。 每多一個依賴,就多一次“希望變量名別撞上”的祈禱。
-
社區自救:非官方解決方案
在官方遲遲不出手的背景下,社區自發提出了模塊化“假方案”:IIFE、揭示模塊模式、命名空間對象。
這些方法聰明,但彼此無法兼容,無法跨項目協作,也缺乏系統級支持。
JavaScript 項目開發在很長一段時間里都像是“野路子拼圖”。
-
Node.js 引入 CommonJS
Node.js 首次將模塊概念“官方化”:使用 require()
同步加載模塊、通過 module.exports
暴露接口。 這讓服務端開發變得清晰許多,但也制造了新的麻煩——瀏覽器根本不支持這一套。 為了“翻譯” CommonJS 模塊,我們被迫發明 Browserify、Webpack 等復雜工具鏈。
-
ES Modules 到來
ES6 標準引入了 import
和 export
,看似終于有了解藥。 可惜為時已晚:CommonJS 早已根深蒂固,打包工具演化成龐然大物,模塊格式分裂成混戰狀態。 從此之后,模塊系統不再是“寫法選擇”,而是構建工具之間的談判協議。
三、模塊系統的真實代價
你可能遇到過:“Cannot use import outside a module”、“SyntaxError: Unexpected token 'export'” 等經典報錯。
這些并不是語法問題,而是模塊格式錯配、環境配置錯誤的表現。
每一次 import 報錯背后,都隱藏著 JavaScript 二十年歷史的裂縫。
模塊系統的混亂還導致 tree shaking 常常失效、包體積變大、加載性能下降。
開發者發布一個包,不得不生成 CommonJS、ESM、UMD 等多個格式,搞懂每種寫法的兼容差異。
最終,“模塊”這個原本該簡化協作的機制,反而成了構建過程最大的復雜源之一。
四、CommonJS vs ESM:核心差異與兼容性問題
CommonJS(CJS)和 ECMAScript Modules(ESM)在 Node.js 中長期共存,成為 JavaScript 最頑固的技術債之一。
它們語法、加載方式和運行時特性都有差異,開發者在寫模塊時常常小心翼翼,很多報錯并非代碼寫錯,而是模塊系統錯用。
語法:require() 與 import/export 的差異
CommonJS 使用 require()
同步加載,接口通過 module.exports
暴露,簡單直觀,成為 Node.js 服務端的事實標準。
// CommonJS 示例 const { addTwo } = require('./addTwo.js'); console.log(addTwo(2));
而 ESM 使用靜態語法的 import
和 export
,支持靜態分析和 tree shaking,是 ES6 標準,適用于瀏覽器和服務器。
// ESM 示例 import { addTwo } from './addTwo.mjs'; console.log(addTwo(2));
兩者不能直接混用,需額外適配層實現互操作。
加載方式:同步 vs 異步
CommonJS 采用同步加載,適合服務端讀取本地文件,但瀏覽器端不適用。 ESM 采用異步加載,import
語句必須頂層使用,符合現代網絡環境需求,更適合性能優化。
Tree shaking 與靜態分析
ESM 支持 tree shaking,構建工具可去除未使用代碼,提升性能。
CommonJS 運行時動態加載,無法靜態分析,導致包體積通常較大。
__dirname、__filename 與 import.meta.url
CommonJS 中可以直接用 __dirname
和 __filename
獲取當前路徑。 ESM 中這兩個變量被移除,需使用 import.meta.url
配合 Node.js 內置模塊處理路徑,容易踩坑。
import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);
其他細節差異
特性 | CommonJS | ESM |
加載方式 | 同步 require() | 異步 import |
Tree shaking | 不支持 | 支持 |
擴展名 | 可省略 .js | 必須寫明 .mjs 或設置 "type":"module" |
JSON 導入 | require('./data.json') | import data from './data.json' with { type: 'json' } (Node 17+) |
頂層 await | 不支持 | 支持 |
動態導入 | 僅支持 require() | 支持 import() 動態加載 |
內建模塊導入 | require('fs') | import fs from 'node:fs' (Node 12.20+) |
模塊緩存 | 共享 require.cache | 獨立緩存 |
互操作:CommonJS 與 ESM 混用
-
ESM 中用 CommonJS:使用
createRequire()
創建加載器,或用動態import()
。
import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const lodash = require('lodash');
-
CommonJS 中用 ESM:必須使用動態
import()
,Node.js 23+ 支持用require()
直接加載無頂層 await 的 ESM 模塊。
async function loadESM() { const { addTwo } = await import('./addTwo.mjs'); console.log(addTwo(3)); } loadESM(); // Node 23+ 新特性 const esm = require('./esm-file.mjs'); console.log(esm);
五、現實中的遷移方案
新項目建議直接使用 ESM,從一開始就站在“更現代、更統一”的起跑線。
但對于舊項目來說,遷移之路并不輕松。CommonJS 與 ESM 在模塊加載方式、路徑解析、緩存機制、動態導入等方面都存在結構性差異。
為了平穩過渡,你可以采用以下策略:
-
漸進式遷移:保留 CommonJS 主體結構,逐步將核心模塊替換為 ESM,并通過
await import()
在 CJS 中引入新模塊。 -
分層測試環境:為每次模塊替換設立測試邊界,確保行為一致性。
-
利用 Node.js 23+ 的新特性:該版本提供了有限條件下的
require()
加載 ESM 支持,減少早期轉譯依賴。 -
使用 ServBay:它提供了快速搭建支持多模塊系統的 Node 項目能力,默認支持
.mjs
、"type": "module"
配置,并允許你在本地獨立測試 CJS/ESM 混合代碼,避免在 CI/CD 中踩雷。
六、不為舊坑背鍋:寫給每一位 JavaScript 開發者
JavaScript 的模塊系統從來不是被“設計”出來的,而是被“補丁”堆出來的。 最早沒有模塊,我們拼命創造“偽模塊”;Node.js 引入 CommonJS,瀏覽器不認;ESM 到來,卻又太遲,生態已四分五裂。 結果是現在的模塊化不再只是技術問題,而是一種系統性的歷史負擔。
你不是因為不懂 import/export 才被報錯折磨,而是因為這本來就不是統一的世界。
Maxime 在《Modules in JavaScript: A 20-Year Mistake》中說得很直接:
“我們沒構建出模塊系統,我們只是造了個兼容層,用來蓋住 20 年來的混亂。”
即便如此,模塊遷移依舊值得進行。 它不僅能提高構建效率、支持現代瀏覽器和服務端 API,更是未來生態向前演進的基石。 你無需一夜轉型,可以選擇“舊中有新”,逐步引入標準寫法、修復遺留邊界。
最后別忘了:模塊是用來組織代碼的,不是用來折磨開發者的。 我們不該為歷史重復付出代價,而應該用工具和知識構筑一條更清晰的道路。