[NodeJS] JavaScript模塊化
JavaScript誕生于1995年,一開始只是用于編寫簡單的腳本。
隨著前端開發任務越來越復雜,JavaScript代碼也越來越復雜,全局變量沖突、依賴管理混亂等問題變得十分突出,模塊化成為一個必不可少的功能。
模塊化發展史與方案對比
YUI 與 JQuery
2006年,雅虎開源了組件庫YUI Library,使用類似于Java的命名空間的方式來管理模塊:
YUI.util.module.doSomthing();
同年,JQuery發布,使用IIFE和閉包的方法創建私有作用域,避免全局變量污染,這種管理模塊的方法流行了一段時間。
(function(root) {
// 模塊內部變量和函數
var data = 'Module Data';
function getData() {
return data;
}
// 將模塊接口掛載到全局對象上
root.myModule = {
getData: getData
};
})(window);
// 使用模塊
console.log(myModule.getData()); // 輸出: Module Data
當一個模塊依賴于其它模塊時:(ModuleB依賴于ModuleA和ModuleC)
(function(root, moduleA, moduleC) {
// 模塊B代碼
})(window, window.moduleA, window.moduleC);
這種方法有以下缺點:
- 模塊掛載到了全局對象上,仍然可能存在沖突;
- 缺乏標準化,不同程序員對這種方案的實現可能不同;
- 依賴管理困難,當依賴模塊量比較大時,手動傳遞參數容易出錯。
ServerJS, CommonJS 和 Node.js
2009年1月,Mozilla 的工程師制定了一套JavaScript模塊化的標準規范,取名為ServerJS,規范版本為Modules/0.1。
同年4月,ServerJS更名為CommonJS。
ServerJS最初用于服務端的JS模塊化,用于輔助自動化測試工作。
在Node.js出現之前也有運行于服務端的JS,叫做Netscape Enterprise Server,它并不像Node.js,后者擁有訪問操作系統文件系統,以及網絡I/O等能力;而前者更像是早期的php,只是用于在服務端填充模板。更詳細的介紹可以看??Server-side JavaScript a decade before Node.js with Netscape LiveWire - DEV Community
下面這段代碼摘自鏈接里的文章。<!-- Welcome to mid-90s HTML. Tags are SCREAMED, because everybody is very excited about THE INTERNET. --> <HTML> <HEAD> <TITLE>My awesome web app</TITLE> </HEAD> <BODY> <H1> <SERVER> /* This tag and its content will be processed on the server side, and replaced by whatever is passed to `write()` before being sent to the client. */ if(client.firstname != null) { write("Hello " + client.firstname + " !") } else { write("What is your name?") } </SERVER> </H1> <FORM METHOD="post" ACTION="app.html"> <P> <LABEL FOR="firstname">Your name</LABEL> <INPUT TYPE="text" NAME="firstname"/> </P> <P> <INPUT TYPE="submit" VALUE="Send"/> </P> </FORM> </BODY> </HTML>
同年8月,Node.js閃亮登場,但是還沒有包管理器,外部依賴需要手動下載到項目文件夾中。
而包管理器的設計則需要考慮到使用什么模塊化方案。Node.js的作者最終選擇了同年剛提出的CommonJS作為npm的模塊化方案,此時的CommonJS已經采用了Modules/1.0版本的標準規范:Modules/1.0 - CommonJS Spec Wiki
需要注意到此時的CommonJS模塊化方案是針對運行在服務端的Node.js準備的,瀏覽器的JS出于以下原因并不能使用CommonJS:
- 同步加載模塊:CommonJS使用同步的
require函數加載依賴模塊,但是在瀏覽器環境中,網絡請求加載模塊文件是異步操作,無法同步執行; - 缺乏模塊依賴管理:Node.js可以直接從本地文件系統或
node_modules文件夾加載模塊,而瀏覽器無法自動處理模塊的查找和加載; - 沒有內置模塊加載器:Node.js有內置的模塊加載器,可以處理模塊的解析、加載和緩存,但是瀏覽器沒有類似的內置機制。
社區意識到為了可以在瀏覽器環境中使用CommonJS,必須制訂新的標準規范,但是這個時候社區人員的思路出現了分歧,開始出現了不同的流派,也是從這個時候開始,出現了很多不同的模塊化方案。
方向1:browserify
社區的其中一種思路是在打包代碼的時候將CommonJS的模塊語法轉換成瀏覽器可以運行的代碼。
相關的規范是 Modules/Transport規范,用于規定模塊如何轉譯。
這個方向的著名產物是 Browserify。
方向2:AMD
這種思路是:由于CommonJS的require函數是同步加載模塊的,沒辦法在瀏覽器環境應用,那么我們就異步地加載模塊!
AMD是 Async Module Definition的簡稱,即異步模塊定義。
James Burke在2009年9月開發了RequireJS這個模塊加載器,可以異步地加載依賴。
這個方向產生了AMD標準規范:Modules/AsynchronousDefinition - CommonJS Spec Wiki
除了使用require引入依賴,還需要一個新的全局函數:define來注冊依賴。
使用方法大致如下:
// module1.js
define(function() {
return {
data: 'Module 1 Data',
getData: function() {
return this.data;
}
};
});
// main.js
require(['module1'], function(module1) {
console.log(module1.getData()); // 輸出: Module 1 Data
});
方向3:CMD
2011年4月,阿里巴巴的玉伯借鑒了CommonJS、AMD等模塊化方案后,寫出了SeaJS,在此基礎上,提出了 Common Module Definition 簡稱CMD規范。
CMD規范的主要內容和AMD規范差不多,主要差別是CMD保留了CommonJS中最重要的延遲加載和就近聲明特性。
延遲加載:
CMD 模塊依賴是延遲加載的,只有在需要時才會加載依賴的模塊。而 AMD 在定義模塊的時候就需要加載依賴的模塊了。
就近聲明:
CMD在
define的回調函數中使用require函數聲明依賴,而AMD中模塊的依賴是在函數參數列表中顯式聲明的。
AMD示例:
define(['dep1', 'dep2'], function(dep1, dep2) { dep1.doSomething(); dep2.doSomething(); });CMD示例:
define(function(require, exports, module) { // 依賴在使用時才加載 var dep1 = require('./dep1'); var dep2 = require('./dep2'); exports.foo = function() { dep1.doSomething(); dep2.doSomething(); }; });
嘗試統一:UMD
2014年9月,UMD被提出(Universal Module Definition),本質上不是模塊化方案,只是將CommonJS和AMD進行結合。
通過檢查全局作用域下是否存在exports、define方法以及全局對象上是否定義了依賴,來決定采用哪一種方法加載模塊。
官方方案:ES6
到了2016年,ES6標準發布,引入了import和export兩個關鍵字,提供了 ES Module 模塊化方案。
ESM可以在編譯時進行靜態解析和優化,并且在后續版本中支持了動態導入(異步)。
時間線圖

總結
| 模塊方案 | 特點 | 優點 | 缺點 |
|---|---|---|---|
| IIFE + 閉包 | IIFE 結合閉包,實現模塊化 | 簡單直接,不依賴特定標準或工具 | 依賴管理復雜,易產生全局變量沖突,代碼維護性和可讀性較差 |
| CommonJS | 用于服務器端,同步加載 | 簡單直觀,廣泛支持 | 不適合瀏覽器,需要工具轉換 |
| AMD | 設計用于瀏覽器,異步加載 | 提高性能,依賴并行加載 | 語法復雜,代碼冗長 |
| CMD | 類似 AMD,更靈活,支持延遲加載 | 靈活性高,依賴就近聲明和延遲加載 | 使用較少,需要工具支持 |
| UMD | 兼容 CommonJS 和 AMD,通用模塊定義 | 兼容性強,可在不同環境中使用 | 代碼復雜,處理環境差異 |
| ESM | ES6 標準模塊系統,靜態分析 | 原生支持,靜態分析,語法簡潔 | 無? |
目前主流的方案是CommonJS和ESM,前者是因為歷史原因,使用范圍廣,至今仍有許多代碼使用CommonJS;而后者是官方給出的方案,簡潔高效。
現在有許多轉譯工具例如:babel、typescript等等,可以將CommonJS轉換成瀏覽器可以執行的形式,因此在前端項目中使用哪一種方案已經不是特別重要了。
Node.js 中的 CommonJS
在Node.js中每個文件都被看做一個模塊,內部成員不會污染全局作用域,可以使用require函數引入其它模塊,也可以使用module.exports導出。
模塊類型
- Node.js核心模塊:
fs,http,net等等; - 開發者模塊:本地的文件;
- 第三方模塊:通常是使用 npm 安裝到
node_modules文件夾下的模塊。
Node.js只會將.js,.json和.node文件視為模塊,其它文件格式需要額外安裝其它依賴進行轉換,比如.ts。
require執行過程
解析文件路徑 Resolve
require作為一個函數,傳入一個字符串,首先要解析這個字符串代表哪一個模塊。
這個解析的過程是調用了require.resolve()函數。
解析過程如下:
- 嘗試查詢該名稱的
Node.js核心模塊; - 如果以
./或者../開頭,那么嘗試解析URL,加載開發者模塊(即查詢文件); - 如果找不到 2. 的文件,那么嘗試將字符串視為文件夾,查找文件夾內部的
index.js; - 如果還沒找到,則去
node_modules文件夾內部查找指定模塊并加載; - 如果還沒找到,則拋出異常。
對于同名但不同格式的文件,匹配順序是:js > json > node。
對于node_modules里的第三方模塊(文件夾),是先去查找package.json里的main字段配置的入口文件,如果沒有package.json文件或者沒有配置main字段,則找文件夾內部的index。
其實還有很多細節這里忽略了,可以去官網看詳細的匹配規則偽代碼(很長):Modules: CommonJS modules | Node.js v22.4.1 Documentation (nodejs.org)
包裝 Wrapping
經過上一個步驟我們已經查找到了模塊對應的文件。
眾所周知,代碼文件都是文本文件,可以將代碼讀出得到一個字符串。
在這一步,模塊的代碼會被包裝成一個函數,因此可以訪問特殊對象(注入)。
(function(exports, require, module, __filename, __dirname){
// ... Module Code
})();
好處:模塊的頂層代碼不會泄露。
我們可以寫一個簡單的小案例來測試這個包裝步驟:
// module.js
console.log(arguments);
// ========================================
// index.js
require('./module');
我們在index.js中引入module.js這個模塊,而在module.js中,我們只做一件很簡單的事情,那就是直接輸出arguments對象。
執行node index.js,得到以下結果:
這五個對象分別就是exports,require,module,__filename和__dirname。
這個文件目前沒有導出內容,所以可以看到第一個對象,和第三個對象module.exports是空對象。
接下來我們嘗試導出點內容:
// module.js
exports.msg = 'msg from exports'
module.exports = {
msg: 'msg from module.exports'
}
console.log(arguments);
//==============================================
// index.js
const foo = require('./module');
console.log('index.js receive: ' + foo.msg);
在模塊加載的時候,exports默認指向module.exports,也就是說我們要導出內容的時候,既可以通過exports,也可以通過module.exports。最終require返回的是module.exports。
通常在這個環節如果發生了意想不到的情況,大部分問題都是由賦值操作導致的。
exports如果被重新賦值,那么就和module.exports不是同一個引用了。
exports和module.exports的使用注意事項
| 特性 | module.exports | exports |
|---|---|---|
| 初始化 | 默認為一個空對象 {},可以被賦值為任何其他對象或值。 | 在模塊加載時,默認與 module.exports 相同,可以通過賦值語句修改其引用。 |
| 賦值方式 | 直接賦值為需要導出的對象或值,可以是任何有效的 JavaScript 對象或函數。 | 通過 exports 的屬性進行賦值,例如 exports.foo = ...。 |
| 重新賦值影響 | 若直接賦值新對象給 module.exports,會覆蓋原有的導出對象。 | 若重新賦值新對象給 exports,只是修改了 exports 的引用,不會影響 module.exports。 |
| 適用場景 | 適合導出單個對象、函數或類。 | 適合在導出多個方法或屬性時的簡便語法。 |
| 注意事項 | 賦值給 module.exports 必須在模塊加載時立即完成,不能在回調中完成賦值操作。 | 賦值給 exports 只是修改了 exports 變量的引用,而不會改變 module.exports 的引用,因此需要注意不要混淆或誤用。 |
require對象
require是一個函數,當然我們都知道在JS中函數也是對象,require攜帶了一些很重要的屬性:
-
resolve一個函數,require就是使用這個函數來將模塊字符串解析為文件的具體路徑的; -
main:我們知道每一個.js文件既可以被視為模塊,又可以被視為主程序使用node指令啟動,那么被node xxx.js執行的這個xxx.js的相關信息,就被記錄到了main這個對象里,在這個案例里是index.js; -
extensions:以前用來將非js模塊加載到Node.js中,已廢棄,不要使用,否則可能導致bug或者性能下降; -
cache:模塊的緩存記錄,這里有個細節,當前模塊還沒有執行完成,但是已經存在一條緩存記錄了,只不過當前的緩存對象可能不是最終結果(取決于console.log和module.exports的執行順序),這個現象在下文的循環依賴問題會重點討論。注意:緩存的
key是文件路徑,而不是傳給require的那個字符串,因此每次調用require引入已緩存的模塊仍需要走一次resolve環節獲得文件的路徑。
執行 Execution
由Node.js運行時執行模塊代碼。
返回 Exports
require函數返回值是module.exports。
緩存 Cache
對于一個模塊來說,包裝和執行過程只會被執行一次,然后會被緩存。
后續對相同模塊的require只會走一個解析文件路徑的環節,然后就返回緩存。
循環依賴問題 Cycles
如圖,模塊a和模塊b相互依賴,為了避免無窮循環,Node.js使用其緩存機制來處理這種問題。
官方文檔案例:
a.js:
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done'); COPY
b.js:
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done'); COPY
main.js:
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done); COPY
輸出順序:
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true
解析:
- 當
main.js執行require('./a.js')的時候,就已經創建一個Module對象并添加到緩存里了,只不過此時a的Module對象的exports還是空的; - 接下來執行
a.js的模塊代碼,exports.done賦值為false; - 執行到
require('./b.js'),發現沒有相應的緩存,創建b的Module對象并添加到緩存,此時b的exports還是空對象; - 執行
b.js的模塊代碼,exports.done賦值為false; - 執行到
require('./a.js'),發現有緩存,直接返回模塊a的module.exports對象,此時的done是false; - 注意,此時是直接返回模塊a的
module.exports對象,沒有去重新執行a.js的代碼,因此避免了循環。 - 后續就是
b.js完成了模塊代碼執行,a.js完成剩余代碼執行,main.js完成剩余代碼執行,程序結束。
參考文章
[1] 《編程時間簡史系列》JavaScript 模塊化的歷史進程 - 編程時間簡史 - SegmentFault 思否
[2] Modules: CommonJS modules | Node.js v22.4.1 Documentation (nodejs.org)
[3] javascript - 深入Node.js的模塊加載機制,手寫require函數 - 進擊的大前端 - SegmentFault 思否

浙公網安備 33010602011771號