模塊化是一種將軟件功能抽離成獨立、可交互的軟件設計技術,能促進大型應用程序和系統的構建。
  Node.js內置了兩種模塊系統,分別是默認的[CommonJS模塊](https://nodejs.org/dist/latest-v18.x/docs/api/modules.html)和瀏覽器所支持的[ECMAScript模塊](https://nodejs.org/dist/latest-v18.x/docs/api/esm.html)。
  其中,ECMAScript模塊是在8.5.0版本中新增的,后面又經過了幾輪的迭代。本文若無特別說明,那么分析的都是CommonJS模塊。
  順便說一句,本系列分析的是[Node.js](https://github.com/nodejs/node)的最新版本18.0.0,在Github上下載源碼后,可以關注下面3個目錄。
~~~
├── deps 第三方依賴
├── lib 對外暴露的標準庫JavaScript源碼,例如path、fs等
├── src 支撐Node運行的C/C++ 源碼文件,例如HTTP解析、進程處理等
~~~
  本系列所有的示例源碼都已上傳至Github,[點擊此處](https://github.com/pwstrick/node)獲取。
  還有一點需要指出,Node.js的[官方說明文檔](https://nodejs.org/dist/latest-v18.x/docs/api/documentation.html),是我目前為止遇到的比較符合人類閱讀的文檔。
## 一、基礎語法
  先來分析一下CommonJS模塊的基礎語法,在Node.js中,可通過 module.exports 和 exports 來導出一個模塊,再通過 require() 來導入一個模塊。
  來看個簡單的示例,先在 1.js 文件中聲明 human 對象,然后使用 module.exports 導出,然后在 2.js 中導入 1.js 文件,打印輸出。
~~~
// 1.js
const human = {
name: 'strick'
}
module.exports = human;
// 2.js
const human = require('./1.js');
console.log(human); // { name: 'strick' }
~~~
  exports 是 module.exports 的快捷方式,但是不能對其直接賦值,像下面這樣導出的就是一個空對象。
~~~
// 3.js
exports = {
name: 'strick'
};
// 2.js
const human = require('./3.js');
console.log(human); // {}
~~~
  接下來換一種寫法,為 exports 添加一個屬性,這樣就能正確導出。
~~~
// 3.js
exports.human = {
name: 'strick'
};
// 2.js
const human = require('./3.js');
console.log(human); // { human: { name: 'strick' } }
~~~
  module.exports 導出了它所指向的對象,而 exports 導出的是對象的屬性。
## 二、CommonJS原理
  在Node.js中,可分成兩大類的模塊:核心模塊和第三方模塊。
  其中核心模塊又分成 built-in 模塊和 native 模塊,前者由C/C++編寫,存在于源碼的src目錄中;后者由JavaScript編寫,存在于lib目錄中。
  注意,在 lib/internal/modules 目錄中,可以查看兩種模塊系統的源碼。
  所有非Node.js自帶的模塊統稱為第三方模塊,也就是任意文件,大家自己寫的業務代碼以及依賴的第三方應用庫都屬于此范疇。
  Node.js會使用模塊封裝器(如下所示)將模塊中的代碼包裹,形成模塊作用域,這樣就能避免模塊之間的作用域污染。
~~~
(function(exports, require, module, __filename, __dirname) {
// 模塊代碼實際存在于此處
});
~~~
  \_\_filename可以得到當前模塊的絕對路徑加文件名。\_\_dirname表示當前模塊的目錄名,也包含絕對路徑,與 path.dirname() 相同。
~~~
console.log(__filename); // /Users/strick/code/web/node/01/4.js
console.log(__dirname); // /Users/strick/code/web/node/01
~~~
**1)require()**
  在[lib/internal/modules/cjs/loader.js](https://github.com/nodejs/node/blob/master/lib/internal/modules/cjs/loader.js)中聲明了 require() 函數,requireDepth 記載了模塊加載的深度。
~~~
Module.prototype.require = function(id) {
validateString(id, 'id'); // 判斷id變量是否是字符串類型
if (id === '') {
throw new ERR_INVALID_ARG_VALUE('id', id, 'must be a non-empty string');
}
requireDepth++;
try {
return Module._load(id, this, /* isMain */ false);
} finally {
requireDepth--;
}
};
~~~
  在 \_load() 中實現了主要的加載邏輯,源碼比較長,做了些刪減,只列出了關鍵部分。
~~~
Module._load = function(request, parent, isMain) {
// 解析模塊的路徑和名稱
const filename = Module._resolveFilename(request, parent, isMain);
// 核心模塊使用 node: 前綴,會繞過 require 緩存
if (StringPrototypeStartsWith(filename, 'node:')) {
const id = StringPrototypeSlice(filename, 5); // Slice 'node:' prefix
const module = loadNativeModule(id, request);
if (!module?.canBeRequiredByUsers) {
throw new ERR_UNKNOWN_BUILTIN_MODULE(filename);
}
return module.exports;
}
// 第一種情況:如果緩存中已經存在此模塊,那么返回模塊的 exports 屬性
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
if (!cachedModule.loaded) {
const parseCachedModule = cjsParseCache.get(cachedModule);
if (!parseCachedModule || parseCachedModule.loaded)
return getExportsForCircularRequire(cachedModule);
parseCachedModule.loaded = true;
} else {
return cachedModule.exports;
}
}
// 第二種情況:如果是核心模塊,那么調用 NativeModule.prototype.compileForPublicLoader() 返回模塊的 exports 屬性
const mod = loadNativeModule(filename, request);
if (mod?.canBeRequiredByUsers &&
NativeModule.canBeRequiredWithoutScheme(filename)) {
return mod.exports;
}
// 第三種情況:如果是第三方文件,那么創建一個新模塊并加載文件內容,再將其保存到緩存中
const module = cachedModule || new Module(filename, parent);
Module._cache[filename] = module;
return module.exports;
};
~~~
  在 \_load() 方法中,會先判斷 node: 前綴(在官方文檔的核心模塊中有過介紹),然后列出3種加載情況:
1. 如果緩存中已經存在此模塊,那么返回模塊的 exports 屬性。
2. 如果是核心模塊,那么調用 NativeModule.prototype.compileForPublicLoader() 返回模塊的 exports 屬性。
3. 如果是第三方文件,那么創建一個新模塊并加載文件內容,再將其保存到緩存中。
  Node.js在加載JS文件時,會先判斷是否有緩存,然后讀取文件內容,再調用 \_compile() 進行編譯,下面的源碼也做了刪減。
  還有另外兩種 .json 和 .node 后綴的文件加載過程在此省略。
~~~
Module._extensions['.js'] = function(module, filename) {
// 如果已經分析了源,那么它將被緩存
const cached = cjsParseCache.get(module);
let content;
if (cached?.source) {
content = cached.source;
cached.source = undefined;
} else {
content = fs.readFileSync(filename, 'utf8');
}
module._compile(content, filename);
};
~~~
  在 \_compile() 方法中會調用[vm模塊](https://nodejs.org/dist/latest-v18.x/docs/api/vm.html)創建沙盒,再執行函數代碼,源碼比較長,在此省略。
  注意,雖然 vm 可以在V8虛擬機的上下文中編譯和執行JavaScript代碼,但是它比[eval()](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/eval)更為安全,因為它運行的腳本無權訪問外部作用域。
**2)加載順序**
  經過上面的源碼分析,可知加載順序是先緩存,再核心模塊,最后第三方模塊,再詳細一點的話就是:
1. 緩存,模塊在第一次加載后被緩存,也就是說,解析相同的文件,會返回完全相同的對象,除非修改[require.cache](https://nodejs.org/dist/latest-v18.x/docs/api/modules.html#requirecache)。
2. 核心模塊,部分核心模塊已被編譯成二進制文件,加載到了內存中。
3. 文件模塊的加載過程如下:
1. 優先加載帶' /'、'./' 或 '../' 路徑前綴的模塊。
2. 若文件沒有后綴,則依次添加 .js、.json 和 .node 嘗試加載。
3. 若模塊沒有路徑來指示文件,則該模塊必須是核心模塊或從 node\_modules 目錄加載。
4. 再找不到就拋出 MODULE\_NOT\_FOUND 錯誤。
4. 目錄作為模塊的加載過程如下:
1. 先將目錄當成包來處理,查找 package.json 文件,讀取 main 字段描述的入口文件。
2. 若沒有 package.json,main 字段缺失或無法解析時,嘗試依次加載目錄中的 index.js、index.json 或 index.node 文件。
3. 如果這些嘗試都失敗,則拋出錯誤,Error: Cannot find module 'xx/xx.js'。
5. 從 node\_modules 目錄加載,若不是核心模塊并且沒有路徑前綴,那么從當前模塊的目錄向上查找,并添加 /node\_modules,直至根目錄為止。
例如,在'/Users/strick/code/tmp.js' 中調用require('test.js'),那么將按以下順序查找:
1. /Users/strick/code/node\_modules/test.js
2. /Users/strick/node\_modules/test.js
3. /Users/node\_modules/test.js
4. /node\_modules/test.js
6. 從全局目錄加載,一種官方不推薦的加載方式。
  如果 NODE\_PATH 環境變量設置為以冒號分隔的絕對路徑列表,則 Node.js 將在這些路徑中搜索模塊(如果它們在其他地方找不到)。
**3)循環引用**
  在Node.js中,當兩個模塊通過 require() 函數加載對方時,就形成了循環引用,但不會形成死循環。
  下面的示例來自于官網,對其做了些調整。
  先創建 a.js,在加載 b 模塊之前,done 是 false,并且聲明了一個 globalVar 變量,沒有為其添加任何聲明變量的關鍵字,在 b 模塊加載完成后,done 賦值為 true。
~~~
console.log('a starting');
exports.done = false;
globalVar = '全局變量'; // 在a模塊中聲明的全局變量
const b = require('./b.js');
console.log('在a模塊中, b.done = %j', b.done);
exports.done = true;
console.log('a done');
~~~
  再創建 b.js,在加載 a 模塊之前,done 也是 false,在 a 模塊加載完成之后,done 也賦值為 true。
~~~
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('在b模塊中, a.done = %j', a.done);
console.log('globalVar: ', globalVar);
exports.done = true;
console.log('b done');
~~~
  最后創建 main.js,再加載 b 模塊。
~~~
console.log('main starting');
const a = require('./a.js'); // 先導入a模塊
const b = require('./b.js'); // 再導入b模塊
console.log('在main模塊中, a.done = %j, b.done = %j', a.done, b.done);
~~~
  最終的打印順序如下所示,在 main.js 中,先加載 a 模塊,而在 a 模塊中會嘗試加載 b 模塊。那么在進入到 b 模塊后,為了防止無限死循環,會導出 a 模塊已執行完成的部分。
~~~
main starting
a starting
b starting
在b模塊中, a.done = false
globalVar: 全局變量
b done
在a模塊中, b.done = true
a done
在main模塊中, a.done = true, b.done = true
~~~
  在上述示例中,還涉及到另一個問題,那就是在 a 模塊中聲明的 globalVar 變量,能在 b 模塊中被成功打印。
  在上文中也曾提到過模塊封裝器,那么 globalVar 變量的聲明和打印,相當于下面這樣,如果在函數內聲明變量時省略 var 關鍵字,那么這個變量就會變成全局變量。
~~~
// a.js
(function (exports, require, module, __filename, __dirname) {
globalVar = '全局變量';
});
// b.js
(function (exports, require, module, __filename, __dirname) {
console.log(globalVar);
});
~~~
  若要避免污染全局作用域,那么可以聲明嚴格模式,禁止隱式的全局聲明,如下所示。
~~~
'use strict';
globalVar = '全局變量';
~~~
**5)與ECMAScript模塊的差異**
1. import 語句只允許在 ES 模塊中使用,但可以導入兩種模塊;而 CommonJS 的 require() 不能導入 ES 模塊。
2. ES 模塊的 import 是異步執行的;而 CommonJS 模塊的 require() 是同步執行的。
3. ES 模塊沒有 \_\_filename、\_\_dirname、require.cache、module.exports 等變量。
4. ES 模塊是編譯時輸出,可以靜態分析模塊依賴;而?CommonJS 是運行時加載。
5. ES 模塊輸出的是值引用;而 CommonJS 模塊輸出的是值副本。
  需要通過一個示例來理解第五點差異,首先創建 lib.mjs 文件,.mjs 是 Node.js 為 ES 模塊保留的后綴,在此類文件內可使用 export 和 import 語法。
  在 lib.mjs 文件中,聲明 digit 變量和 increase() 函數,在函數中對 digit 執行遞增,通過 export 將它們導出。
~~~
// lib.mjs
export let digit = 0;
export function increase() {
digit++;
}
~~~
  在 main.mjs 文件中,加載 lib.mjs,打印 digit 變量,值為 0,調用 increase() 函數,再打印,值變為 1。由此可知,外部可以修改模塊內部的值。
~~~
// main.mjs
import { digit, increase } from './lib.mjs';
console.log(digit); // 0
increase();
console.log(digit); // 1
~~~
  接下來創建 lib.js 文件,同樣是 digit 變量和 increase() 函數,通過 module.exports 將它們導出。
~~~
// lib.js
let digit = 0;
function increase() {
digit++;
}
module.exports.digit = digit;
module.exports.increase = increase;
~~~
  在 main.js 文件中,加載 lib.js,打印 digit 變量,值為 0,調用 increase() 函數,再打印,仍然是 0。由此可知,外部無法修改模塊內部的值。
~~~
// main.js
const lib = require('./lib');
console.log(lib.digit); // 0
lib.increase();
console.log(lib.digit); // 0
~~~
6. ES 模塊不管是否遇到循環引用,其 import 導入的變量都會成為一個指向被加載模塊的引用,而 CommonJS 模塊遇到循環引用只會導出模塊已執行完成的部分。
  這其實也是兩者加載機制的不同所導致的,參考第四點不同。
  CommonJS 對循環引用的處理過程在上文中已介紹,現在改造之前官網的示例,在 main.mjs 中導入 a 和 b 兩個模塊,并打印 a 和 b 的值。
~~~
// main.mjs
import a from './a.mjs';
import b from './b.mjs';
console.log('在main模塊中, a = %j, b = %j', a, b);
~~~
  在 a.mjs 中,會導入 b.mjs,并打印 b 的值。而在 b.mjs 中,會導入 a.mjs,并打印 a 的值,如此就形成了循環引用。
~~~
// a.mjs
import b from './b.mjs';
let done = false;
export default done;
console.log('在a模塊中, b = %j', b);
// b.mjs
import a from './a.mjs';
let done = false;
export default done;
console.log('在b模塊中, a = %j', a);
~~~
  運行 main.mjs,馬上就會報錯:ReferenceError: Cannot access 'a' before initialization。
  在 main.mjs 中讀取 a 的值時,會執行 a.mjs 并讀取 b 的值,而在 b.mjs 中,默認會認為 a 已存在,但在訪問的時候就會發現被欺騙,然后就報錯了。
參考資料:
[CommonJS模塊](https://nodejs.org/dist/latest-v18.x/docs/api/modules.html)
[ECMAScript模塊](https://nodejs.org/dist/latest-v18.x/docs/api/esm.html)
[使用 exports 從 Node.js 文件中公開功能](http://nodejs.cn/learn/expose-functionality-from-a-nodejs-file-using-exports)
[餓了么模塊題目](https://github.com/ElemeFE/node-interview/blob/master/sections/zh-cn/module.md)
[為什么 Node.js 不給每一個.js文件以獨立的上下文來避免作用域被污染??](https://www.zhihu.com/question/57375179)
[Node.js技術棧](https://www.nodejs.red/#/nodejs/module)
[深入理解Node.js:核心思想與源碼分析](https://yjhjstz.gitbooks.io/deep-into-node/content/chapter2/chapter2-2.html)
[Node.js 模塊系統源碼探微](https://www.zoo.team/article/node-module)
[Node.js VM 不完全指北](https://zhuanlan.zhihu.com/p/128090873)
[What’s the difference between CommonJS and ES6 modules?](https://www.mo4tech.com/whats-the-difference-between-commonjs-and-es6-modules-2.html)
[ECMAScript6入門之ES6模塊的循環加載](https://es6.ruanyifeng.com/?search=%E5%BE%AA%E7%8E%AF&x=0&y=0#docs/module-loader#ES6-%E6%A8%A1%E5%9D%97%E7%9A%84%E5%BE%AA%E7%8E%AF%E5%8A%A0%E8%BD%BD)
*****
> 原文出處:
[博客園-Node.js精進](https://www.cnblogs.com/strick/category/2154090.html)
[知乎專欄-前端性能精進](https://www.zhihu.com/column/c_1611672656142725120)
已建立一個微信前端交流群,如要進群,請先加微信號freedom20180706或掃描下面的二維碼,請求中需注明“看云加群”,在通過請求后就會把你拉進來。還搜集整理了一套[面試資料](https://github.com/pwstrick/daily),歡迎瀏覽。

推薦一款前端監控腳本:[shin-monitor](https://github.com/pwstrick/shin-monitor),不僅能監控前端的錯誤、通信、打印等行為,還能計算各類性能參數,包括 FMP、LCP、FP 等。
- ES6
- 1、let和const
- 2、擴展運算符和剩余參數
- 3、解構
- 4、模板字面量
- 5、對象字面量的擴展
- 6、Symbol
- 7、代碼模塊化
- 8、數字
- 9、字符串
- 10、正則表達式
- 11、對象
- 12、數組
- 13、類型化數組
- 14、函數
- 15、箭頭函數和尾調用優化
- 16、Set
- 17、Map
- 18、迭代器
- 19、生成器
- 20、類
- 21、類的繼承
- 22、Promise
- 23、Promise的靜態方法和應用
- 24、代理和反射
- HTML
- 1、SVG
- 2、WebRTC基礎實踐
- 3、WebRTC視頻通話
- 4、Web音視頻基礎
- CSS進階
- 1、CSS基礎拾遺
- 2、偽類和偽元素
- 3、CSS屬性拾遺
- 4、浮動形狀
- 5、漸變
- 6、濾鏡
- 7、合成
- 8、裁剪和遮罩
- 9、網格布局
- 10、CSS方法論
- 11、管理后臺響應式改造
- React
- 1、函數式編程
- 2、JSX
- 3、組件
- 4、生命周期
- 5、React和DOM
- 6、事件
- 7、表單
- 8、樣式
- 9、組件通信
- 10、高階組件
- 11、Redux基礎
- 12、Redux中間件
- 13、React Router
- 14、測試框架
- 15、React Hooks
- 16、React源碼分析
- 利器
- 1、npm
- 2、Babel
- 3、webpack基礎
- 4、webpack進階
- 5、Git
- 6、Fiddler
- 7、自制腳手架
- 8、VSCode插件研發
- 9、WebView中的頁面調試方法
- Vue.js
- 1、數據綁定
- 2、指令
- 3、樣式和表單
- 4、組件
- 5、組件通信
- 6、內容分發
- 7、渲染函數和JSX
- 8、Vue Router
- 9、Vuex
- TypeScript
- 1、數據類型
- 2、接口
- 3、類
- 4、泛型
- 5、類型兼容性
- 6、高級類型
- 7、命名空間
- 8、裝飾器
- Node.js
- 1、Buffer、流和EventEmitter
- 2、文件系統和網絡
- 3、命令行工具
- 4、自建前端監控系統
- 5、定時任務的調試
- 6、自制短鏈系統
- 7、定時任務的進化史
- 8、通用接口
- 9、微前端實踐
- 10、接口日志查詢
- 11、E2E測試
- 12、BFF
- 13、MySQL歸檔
- 14、壓力測試
- 15、活動規則引擎
- 16、活動配置化
- 17、UmiJS版本升級
- 18、半吊子的可視化搭建系統
- 19、KOA源碼分析(上)
- 20、KOA源碼分析(下)
- 21、花10分鐘入門Node.js
- 22、Node環境升級日志
- 23、Worker threads
- 24、低代碼
- 25、Web自動化測試
- 26、接口攔截和頁面回放實驗
- 27、接口管理
- 28、Cypress自動化測試實踐
- 29、基于Electron的開播助手
- Node.js精進
- 1、模塊化
- 2、異步編程
- 3、流
- 4、事件觸發器
- 5、HTTP
- 6、文件
- 7、日志
- 8、錯誤處理
- 9、性能監控(上)
- 10、性能監控(下)
- 11、Socket.IO
- 12、ElasticSearch
- 監控系統
- 1、SDK
- 2、存儲和分析
- 3、性能監控
- 4、內存泄漏
- 5、小程序
- 6、較長的白屏時間
- 7、頁面奔潰
- 8、shin-monitor源碼分析
- 前端性能精進
- 1、優化方法論之測量
- 2、優化方法論之分析
- 3、瀏覽器之圖像
- 4、瀏覽器之呈現
- 5、瀏覽器之JavaScript
- 6、網絡
- 7、構建
- 前端體驗優化
- 1、概述
- 2、基建
- 3、后端
- 4、數據
- 5、后臺
- Web優化
- 1、CSS優化
- 2、JavaScript優化
- 3、圖像和網絡
- 4、用戶體驗和工具
- 5、網站優化
- 6、優化閉環實踐
- 數據結構與算法
- 1、鏈表
- 2、棧、隊列、散列表和位運算
- 3、二叉樹
- 4、二分查找
- 5、回溯算法
- 6、貪心算法
- 7、分治算法
- 8、動態規劃
- 程序員之路
- 大學
- 2011年
- 2012年
- 2013年
- 2014年
- 項目反思
- 前端基礎學習分享
- 2015年
- 再一次項目反思
- 然并卵
- PC網站CSS分享
- 2016年
- 制造自己的榫卯
- PrimusUI
- 2017年
- 工匠精神
- 2018年
- 2019年
- 前端學習之路分享
- 2020年
- 2021年
- 2022年
- 2023年
- 2024年
- 日志
- 2020