JavaScript 在誕生之初並沒有模塊系統,所有代碼共享同一個全局作用域。隨着前端項目規模的增長,模塊化方案經歷了 IIFE、CommonJS、AMD、UMD、ES Modules 的演變。理解這段歷史,能幫助我們在實際項目中做出更好的選擇。
原始時代:全局變量
最早期的前端開發,所有 JS 文件通過 <script> 標籤引入,所有變量都掛在 window 上:
<!-- index.html -->
<script src="jquery.js"></script>
<script src="utils.js"></script>
<script src="app.js"></script>
<!-- 問題:utils.js 和 app.js 共享全局作用域,
變量名衝突是家常便飯 -->
// utils.js —— 全局污染
var name = 'utils';
function add(a, b) {
return a + b;
}
// app.js —— name 被覆蓋了!
var name = 'app'; // 覆蓋了 utils.js 中的 name
console.log(name); // 'app'
這種模式的痛點顯而易見:命名衝突、依賴關係不明確、代碼組織混亂。
IIFE:最早的模塊化方案
IIFE(Immediately Invoked Function Expression,立即執行函數表達式)利用函數作用域來隔離變量:
// 模塊定義:用 IIFE 包裹,創建獨立作用域
var myModule = (function() {
// 私有變量:外部無法訪問
var privateVar = 'I am private';
var counter = 0;
// 私有函數
function privateMethod() {
console.log('私有方法被調用');
counter++;
}
// 返回公共 API(閉包)
return {
// 公共方法
increment: function() {
privateMethod();
return counter;
},
getCount: function() {
return counter;
},
// 公共變量
name: 'myModule'
};
})();
// 使用
myModule.increment(); // '私有方法被調用'
myModule.increment();
myModule.getCount(); // 2
myModule.privateVar; // undefined —— 訪問不到
IIFE 還支持「依賴注入」模式,jQuery 插件大量使用這種寫法:
// 傳入依賴作為參數,避免全局查找
var myPlugin = (function($, utils) {
// $ 是 jQuery,utils 是另一個模塊
// 都通過參數傳入,不依賴全局變量
return {
init: function(selector) {
var elements = $(selector);
elements.each(function() {
utils.addClass(this, 'initialized');
});
}
};
})(jQuery, myUtils);
// ↑ 立即執行,傳入依賴
IIFE 解決了作用域隔離的問題,但模塊的加載順序仍然需要手動管理,而且多個模塊之間的依賴關係很不直觀。
CommonJS:Node.js 的模塊標準
2009 年 Node.js 誕生,帶來了 CommonJS 規範。它用 require 導入、module.exports 導出,是同步加載的:
// math.js —— 定義模塊
// module.exports 導出整個模塊
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
function multiply(a, b) {
return a * b;
}
// 方式一:導出整個對象
module.exports = {
add: add,
subtract: subtract,
multiply: multiply
};
// 方式二:逐個掛載到 exports 上
// 注意:不能直接賦值 exports = {...},這會斷開引用
// exports.add = add;
// exports.subtract = subtract;
// app.js —— 使用模塊
var math = require('./math');
// 注意:require 是同步的,執行到這裏會阻塞,
// 直到 math.js 加載並執行完畢
console.log(math.add(1, 2)); // 3
console.log(math.subtract(5, 3)); // 2
// 也可以解構導入
var { add, subtract } = require('./math');
console.log(add(10, 20)); // 30
CommonJS 的一個重要特性是模塊緩存:
// moduleA.js
console.log('moduleA 被加載了');
module.exports = { loaded: true };
// file1.js
var a = require('./moduleA'); // 輸出: "moduleA 被加載了"
// file2.js
var a = require('./moduleA'); // 不會再輸出,直接從緩存中讀取
// file1.js 和 file2.js 拿到的是同一個對象
var a1 = require('./moduleA');
var a2 = require('./moduleA');
console.log(a1 === a2); // true
CommonJS 的問題:它是同步加載的,在瀏覽器端使用時,網絡請求會阻塞頁面渲染。所以瀏覽器端需要異步的模塊方案。
AMD:瀏覽器端的異步模塊
AMD(Asynchronous Module Definition)專為瀏覽器設計,核心是異步加載模塊:
// math.js —— 用 AMD 定義模塊
// 第一個參數是模塊名(可選),第二個參數是依賴數組,第三個是工廠函數
define('math', [], function() {
return {
add: function(a, b) {
return a + b;
},
subtract: function(a, b) {
return a - b;
}
};
});
// calculator.js —— 依賴 math 模塊
// 依賴會在回調之前全部加載完畢
define('calculator', ['math'], function(math) {
return {
calculate: function(a, b, op) {
switch (op) {
case '+': return math.add(a, b);
case '-': return math.subtract(a, b);
default: return 0;
}
}
};
});
// app.js —— 入口文件,使用 RequireJS 加載
require(['calculator'], function(calculator) {
var result = calculator.calculate(10, 5, '+');
console.log(result); // 15
});
AMD 還支持 CommonJS 風格的寫法(也叫 Simplified CommonJS Wrapper):
// 動態加載模塊(按需加載)
define('dynamicModule', ['require', 'exports', 'module'], function(require, exports, module) {
// 可以像 CommonJS 一樣使用 require
var math = require('./math');
// 條件加載
if (window.needsAdvanced) {
var advanced = require('./advancedMath');
}
module.exports = {
compute: function(x) {
return math.add(x, 1);
}
};
});
AMD 的問題:語法比較繁瑣,依賴前置(所有依賴必須在回調之前聲明),而且隨着構建工具的發展,「先下載再執行」的優勢不再明顯。
UMD:兼容一切的通用方案
UMD(Universal Module Definition)不是一種新的模塊規範,而是一種兼容模式——讓同一個模塊在 AMD、CommonJS、瀏覽器全局變量環境下都能工作:
// utils.js —— UMD 格式
(function(root, factory) {
// 檢測環境,選擇合適的模塊加載方式
if (typeof define === 'function' && define.amd) {
// AMD 環境(RequireJS)
define('utils', [], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS 環境(Node.js / Browserify / Webpack)
module.exports = factory();
} else {
// 瀏覽器全局變量
root.MyUtils = factory();
}
}(typeof self !== 'undefined' ? self : this, function() {
// 模塊的真正實現
return {
formatDate: function(date) {
var d = new Date(date);
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
},
debounce: function(fn, delay) {
var timer = null;
return function() {
var context = this;
var args = arguments;
clearTimeout(timer);
timer = setTimeout(function() {
fn.apply(context, args);
}, delay);
};
}
};
}));
UMD 在 npm 包的 dist 目錄中非常常見。當我們引入一個第三方庫的 UMD 版本時,它在任何環境下都能正常工作。但 UMD 的問題是代碼冗餘——每個模塊都帶着一長串環境檢測代碼。
ES Modules:語言原生標準
ES6(ES2015)終於在語言層面提供了原生的模塊系統——ES Modules。它使用 import / export 語法,靜態分析友好,是現代前端的主流方案:
// math.js —— ES Module 導出
// 命名導出(named export):可以導出多個
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
// 導出常量
export const PI = 3.14159265;
// 也可以先定義,最後統一導出
const multiply = (a, b) => a * b;
const divide = (a, b) => b !== 0 ? a / b : null;
export { multiply, divide };
// logger.js —— 默認導出(default export):每個模塊只能有一個
export default class Logger {
constructor(prefix) {
this.prefix = prefix;
}
log(message) {
console.log(`[${this.prefix}] ${message}`);
}
error(message) {
console.error(`[${this.prefix}] ERROR: ${message}`);
}
}
// app.js —— ES Module 導入
// 導入命名導出
import { add, subtract, PI } from './math.js';
// 導入默認導出
import Logger from './logger.js';
// 導入整個模塊作為命名空間對象
import * as MathUtils from './math.js';
console.log(MathUtils.add(1, 2)); // 3
console.log(MathUtils.subtract(5, 3)); // 2
console.log(MathUtils.PI); // 3.14159265
// 使用
const logger = new Logger('App');
logger.log('應用啓動');
logger.log(`1 + 2 = ${add(1, 2)}`);
ES Modules 有幾個重要的特性是之前方案不具備的:
// 1. 靜態分析:import 必須在頂層,不能在條件語句或函數中
// 這使得構建工具可以在編譯時分析依賴關係
// ❌ 不合法的寫法
if (condition) {
import { something } from './module.js'; // SyntaxError
}
// 2. 導出是實時綁定(live binding),不是值拷貝
// counter.js
export let count = 0;
export function increment() {
count++;
}
// app.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
// count 會實時反映模塊內部的變化
console.log(count); // 1
// 3. 模塊默認是嚴格模式,不需要手動寫 'use strict'
// 4. 模塊有自己的作用域,頂層的 this 是 undefined
動態 import()
ES Modules 的 import 是靜態的,但實際場景中我們經常需要按條件加載模塊。ES2020 提案引入了 import() 函數:
// 場景一:路由懶加載
const routes = [
{
path: '/home',
component: () => import('./pages/Home.js')
},
{
path: '/about',
component: () => import('./pages/About.js')
},
{
path: '/dashboard',
// 只有登錄用户才加載 Dashboard
component: () => {
if (isLoggedIn()) {
return import('./pages/Dashboard.js');
}
return import('./pages/Login.js');
}
}
];
// 場景二:按需加載大型庫
async function exportToExcel(data) {
// 只在用户點擊「導出」時才加載 xlsx 庫
const XLSX = await import('xlsx');
const worksheet = XLSX.utils.json_to_sheet(data);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
XLSX.writeFile(workbook, 'export.xlsx');
}
// 場景三:錯誤重試
async function loadModuleWithRetry(modulePath, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const module = await import(modulePath);
return module;
} catch (error) {
console.warn(`加載失敗,第 ${i + 1} 次重試...`, error);
if (i === retries - 1) throw error;
}
}
}
// 場景四:條件導入不同的實現
async function getStorage() {
if (typeof window !== 'undefined' && window.indexedDB) {
const { IndexedDBStorage } = await import('./storage/indexeddb.js');
return new IndexedDBStorage();
} else {
const { LocalStorage } = await import('./storage/localstorage.js');
return new LocalStorage();
}
}
import() 返回一個 Promise,解析值是模塊的命名空間對象(包含所有命名導出和 default)。
Webpack 中的模塊打包
Webpack 是 2019 年最主流的模塊打包工具。它支持所有上述模塊規範,並將它們統一打包:
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'production',
entry: {
// 入口:從這裏開始分析依賴
main: './src/index.js',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
},
// 代碼分割配置
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
// 第三方庫單獨打包
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 10,
},
// 公共模塊提取
common: {
minChunks: 2,
priority: 5,
reuseExistingChunk: true,
},
},
},
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
}),
],
};
// src/index.js —— 項目入口
// Webpack 能識別所有模塊格式,但推薦使用 ES Modules
import { add } from './utils/math';
import('./pages/Home').then(({ default: Home }) => {
// 動態 import 會被 Webpack 自動分割為獨立的 chunk
const home = new Home();
home.render();
});
console.log(add(1, 2));
// src/utils/math.js
// Tree Shaking 的關鍵:使用 ES Modules 的命名導出
// Webpack 可以在構建時分析出哪些導出沒有被使用,然後剔除它們
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
// 如果項目中沒有使用 multiply,
// Webpack 的 tree shaking 會把它從最終產物中移除
export function multiply(a, b) {
return a * b;
}
// ⚠️ 注意:CommonJS 的 module.exports 不支持 tree shaking
// 因為它是運行時的動態賦值,構建工具無法靜態分析
// tree shaking 示例
// index.js
import { add } from './math';
// multiply 沒有被導入,webpack 會將它從產物中移除
// 最終產物中只包含 add 函數的代碼
// subtract 和 multiply 都不會被打包進來
各模塊方案對比
作用域隔離 異步加載 靜態分析 瀏覽器支持 Node支持 Tree Shaking
IIFE ✅ ❌ ❌ ✅ ✅ ❌
CommonJS ✅ ❌ ❌ ❌* ✅ ❌
AMD ✅ ✅ ❌ ✅ ❌ ❌
UMD ✅ ✅ ❌ ✅ ✅ ❌
ESM ✅ ✅ ✅ ✅** ✅*** ✅
* CommonJS 在瀏覽器端需要 Browserify/Webpack 轉換
** 現代瀏覽器原生支持 <script type="module">
*** Node.js 12+ 開始原生支持 ES Modules
<!-- 瀏覽器原生使用 ES Modules -->
<script type="module" src="./app.js"></script>
<!-- 注意:type="module" 默認就是 defer 的 -->
<!-- 也可以內聯 -->
<script type="module">
import { add } from './math.js';
console.log(add(1, 2));
</script>
<!-- 降級方案:用 nomodule 給不支持 ES Module 的舊瀏覽器提供打包版本 -->
<script nomodule src="./legacy-bundle.js"></script>
小結
- JavaScript 模塊化經歷了 IIFE → CommonJS → AMD → UMD → ES Modules 的演進,每次演進都是為了解決前一代方案的核心痛點
- IIFE 用函數作用域隔離變量,但依賴手動管理加載順序;CommonJS 是 Node.js 的同步方案,不適合瀏覽器直接使用
- AMD 為瀏覽器設計了異步加載,但語法繁瑣;UMD 兼容所有環境但代碼冗餘
- ES Modules 是語言原生標準,靜態分析友好,支持 tree shaking 和動態
import(),是現代前端的首選方案 - 實際項目中通過 Webpack 等構建工具打包,利用動態
import()實現代碼分割,ES Modules 的靜態特性讓 tree shaking 成為可能