JavaScript 模組化解析
作者: ofollow,noindex">zhijs from 迅雷前端
原文地址: Javascript%25E6%25A8%25A1%25E5%259D%2597%25E5%258C%2596%25E8%25AF%25A6%25E8%25A7%25A3.md" rel="nofollow,noindex">JavaScript 模組化解析
隨著 JavasScript 語言逐漸發展,JavaScript 應用從簡單的表單驗證,到複雜的網站互動,再到服務端,移動端,PC 客戶端的語言支援。JavaScript 應用領域變的越來越廣泛,工程程式碼變得越來越龐大,程式碼的管理變得越來越困難,於是乎 JavaScript 模組化方案在社群中應聲而起,其中一些優秀的模組化方案,逐漸成為 JavaScript 的語言規範,下面我們就 JavaScript 模組化這個話題展開討論,本文的主要包含以幾部分內容。
- 什麼是模組
- 為什麼需要模組化
- JavaScript 模組化之 CommonJS
- JavaScript 模組化之 AMD
- JavaScript 模組化之 CMD
- JavaScript 模組化之 ES Module
- 總結
什麼是模組
模組,又稱構件,是能夠單獨命名並獨立地完成一定功能的程式語句的集合 (即程式程式碼和資料結構的集合體)。它具有兩個基本的特徵:外部特徵和內部特徵。外部特徵是指模組跟外部環境聯絡的介面 (即其他模組或程式呼叫該模組的方式,包括有輸入輸出引數、引用的全域性變數) 和模組的功能,內部特徵是指模組的內部環境具有的特點 (即該模組的區域性資料和程式程式碼)。簡而言之,模組就是一個具有獨立作用域,對外暴露特定功能介面的程式碼集合。
為什麼需要模組化
首先讓我們回到過去,看看原始 JavaScript 模組檔案的寫法。
// add.js function add(a, b) { return a + b; } // decrease.js function decrease(a, b) { return a - b; } // formula.js function square_difference(a, b) { return add(a, b) * decrease(a, b); } 複製程式碼
上面我們在三個 JavaScript 檔案裡面,實現了幾個功能函式。其中,第三個功能函式需要依賴第一個和第二個 JavaScript 檔案的功能函式,所以我們在使用的時候,一般會這樣寫:
<html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> </head> <body> <script src="add.js"></script> <script src="decrease.js"></script> <script src="formula.js"></script> <!--使用--> <script> var result = square_difference(3, 4); </script> </body> </html> 複製程式碼
這樣的管理方式會造成以下幾個問題:
- 模組的引入順序可能會出錯
- 會汙染全域性變數
- 模組之間的依賴關係不明顯
基於上述的原因,就有了對上述問題的解決方案,即是 JavaScript 模組化規範,目前主流的有 CommonJS,AMD,CMD,ES6 Module 這四種規範。
Javascript 模組化之 CommonJS
CommonJS 規範的主要內容有,一個單獨的檔案就是一個模組。每一個模組都是一個單獨的作用域,模組必須通過 module.exports 匯出對外的變數或介面,通過 require() 來匯入其他模組的輸出到當前模組作用域中,下面講述一下 NodeJs 中 CommonJS 的模組化機制。
使用方式
// 模組定義 add.js module.eports.add = function(a, b) { return a + b; }; // 模組定義 decrease.js module.exports.decrease = function(a, b) { return a - b; }; // formula.js,模組使用,利用 require() 方法載入模組,require 匯出的即是 module.exports 的內容 const add = require("./add.js").add; const decrease = require("./decrease.js").decrease; module.exports.square_difference = function(a, b) { return add(a, b) * decrease(a, b); }; 複製程式碼
exports 和 module.exports
exports 和 module.exports 是指向同一個東西的變數,即是 module.exports = exports = {},所以你也可以這樣匯出模組
//add.js exports.add = function(a, b) { return a + b; }; 複製程式碼
但是如果直接修改 exports 的指向是無效的,例如:
// add.js exports = function(a, b) { return a + b; }; // main.js var add = require("./add.js"); 複製程式碼
此時得到的 add 是一個空物件,因為 require 匯入的是,對應模組的 module.exports 的內容,在上面的程式碼中,雖然一開始 exports = module.exports,但是當執行如下程式碼的時候,其實就將 exports 指向了 function,而 module.exports 的內容並沒有改變,所以這個模組的匯出為空物件。
exports = function(a, b) { return a + b; }; 複製程式碼
CommonJS 在 NodeJs 中的模組載入機制
以下根據 NodeJs 中 CommonJS 模組載入原始碼 來分析 NodeJS 中模組的載入機制。
在 NodeJs 中引入模組 (require),需要經歷如下 3 個步驟:
- 路徑分析
- 檔案定位
- 編譯執行
與前端瀏覽器會快取靜態指令碼檔案以提高效能一樣,NodeJs 對引入過的模組都會進行快取,以減少二次引入時的開銷。不同的是,瀏覽器僅快取檔案,而在 NodeJs 中快取的是編譯和執行後的物件。
路徑分析 + 檔案定位
其流程如下圖所示:

模組編譯
在定位到檔案後,首先會檢查該檔案是否有快取,有的話直接讀取快取,否則,會新建立一個 Module 物件,其定義如下:
function Module(id, parent) { this.id = id; // 模組的識別符,通常是帶有絕對路徑的模組檔名。 this.exports = {}; // 表示模組對外輸出的值 this.parent = parent; // 返回一個物件,表示呼叫該模組的模組。 if (parent && parent.children) { this.parent.children.push(this); } this.filename = null; this.loaded = false; // 返回一個布林值,表示模組是否已經完成載入。 this.childrent = []; // 返回一個數組,表示該模組要用到的其他模組。 } 複製程式碼
require 操作程式碼如下所示:
Module.prototype.require = function(id) { // 檢查模組識別符號 if (typeof id !== "string") { throw new ERR_INVALID_ARG_TYPE("id", "string", id); } if (id === "") { throw new ERR_INVALID_ARG_VALUE("id", id, "must be a non-empty string"); } // 呼叫模組載入方法 return Module._load(id, this, /* isMain */ false); }; 複製程式碼
接下來是解析模組路徑,判斷是否有快取,然後生成 Module 物件:
Module._load = function(request, parent, isMain) { if (parent) { debug("Module._load REQUEST %s parent: %s", request, parent.id); } // 解析檔名 var filename = Module._resolveFilename(request, parent, isMain); var cachedModule = Module._cache[filename]; // 判斷是否有快取,有的話返回快取物件的 exports if (cachedModule) { updateChildren(parent, cachedModule, true); return cachedModule.exports; } // 判斷是否為原生核心模組,是的話從記憶體載入 if (NativeModule.nonInternalExists(filename)) { debug("load native module %s", request); return NativeModule.require(filename); } // 生成模組物件 var module = new Module(filename, parent); if (isMain) { process.mainModule = module; module.id = "."; } // 快取模組物件 Module._cache[filename] = module; // 載入模組 tryModuleLoad(module, filename); return module.exports; }; 複製程式碼
tryModuleLoad 的程式碼如下所示:
function tryModuleLoad(module, filename) { var threw = true; try { // 呼叫模組例項load方法 module.load(filename); threw = false; } finally { if (threw) { // 如果加載出錯,則刪除快取 delete Module._cache[filename]; } } } 複製程式碼
模組物件執行載入操作 module.load 程式碼如下所示:
Module.prototype.load = function(filename) { debug("load %j for module %j", filename, this.id); assert(!this.loaded); this.filename = filename; // 解析路徑 this.paths = Module._nodeModulePaths(path.dirname(filename)); // 判斷副檔名,並且預設為 .js 擴充套件 var extension = path.extname(filename) || ".js"; // 判斷是否有對應格式檔案的處理函式, 沒有的話,副檔名改為 .js if (!Module._extensions[extension]) extension = ".js"; // 呼叫相應的檔案處理方法,並傳入模組物件 Module._extensions[extension](this, filename); this.loaded = true; // 處理 ES Module if (experimentalModules) { if (asyncESM === undefined) lazyLoadESM(); const ESMLoader = asyncESM.ESMLoader; const url = pathToFileURL(filename); const urlString = `${url}`; const exports = this.exports; if (ESMLoader.moduleMap.has(urlString) !== true) { ESMLoader.moduleMap.set( urlString, new ModuleJob(ESMLoader, url, async () => { const ctx = createDynamicModule(["default"], url); ctx.reflect.exports.default.set(exports); return ctx; }) ); } else { const job = ESMLoader.moduleMap.get(urlString); if (job.reflect) job.reflect.exports.default.set(exports); } } }; 複製程式碼
在這裡同步讀取模組,再執行編譯操作:
Module._extensions[".js"] = function(module, filename) { // 同步讀取檔案 var content = fs.readFileSync(filename, "utf8"); // 編譯程式碼 module._compile(stripBOM(content), filename); }; 複製程式碼
編譯過程主要做了以下的操作:
- 將 JavaScript 程式碼用函式體包裝,隔離作用域,例如:
exports.add = (function(a, b) { return a + b; } 複製程式碼
會被轉換為
( function(exports, require, modules, __filename, __dirname) { exports.add = function(a, b) { return a + b; }; } ); 複製程式碼
-
執行函式,注入模組物件的 exports 屬性,require 全域性方法,以及物件例項,__filename, __dirname,然後執行模組的原始碼。
-
返回模組物件 exports 屬性。
JavaScript 模組化之 AMD
AMD, Asynchronous Module Definition,即非同步模組載入機制,它採用非同步方式載入模組,模組的載入不影響它後面語句的執行。所有依賴這個模組的語句都定義在一個回撥函式中,等到依賴載入完成之後,這個回撥函式才會執行。
AMD 的誕生,就是為了解決這兩個問題:
- 實現 JavaScript 檔案的非同步載入,避免網頁失去響應
- 管理模組之間的依賴性,便於程式碼的編寫和維護
// 模組定義 define(id?: String, dependencies?: String[], factory: Function|Object); 複製程式碼
id 是模組的名字,它是可選的引數。
dependencies 指定了所要依賴的模組列表,它是一個數組,也是可選的引數。每個依賴的模組的輸出都將作為引數一次傳入 factory 中。如果沒有指定 dependencies,那麼它的預設值是 ["require", "exports", "module"]。
factory 是最後一個引數,它包裹了模組的具體實現,它是一個函式或者物件。如果是函式,那麼它的返回值就是模組的輸出介面或值,如果是物件,此物件應該為模組的輸出值。
舉個例子:
// 模組定義,add.js define(function() { let add = function(a, b) { return a + b; }; return add; }); // 模組定義,decrease.js define(function() { let decrease = function(a, b) { return a - b; }; return decrease; }); // 模組定義,square.js define(["./add", "./decrease"], function(add, decrease) { let square = function(a, b) { return add(a, b) * decrease(a, b); }; return square; }); // 模組使用,主入口檔案 main.js require(["square"], function(math) { console.log(square(6, 3)); }); 複製程式碼
這裡用實現了 AMD 規範的 RequireJS 來分析,RequireJS 原始碼較為複雜,這裡只對非同步模組載入原理做一個分析。在載入模組的過程中, RequireJS 會呼叫如下函式:
/** * * @param {Object} context the require context to find state. * @param {String} moduleName the name of the module. * @param {Object} url the URL to the module. */ req.load = function(context, moduleName, url) { var config = (context && context.config) || {}, node; // 判斷是否為瀏覽器 if (isBrowser) { // 根據模組名稱和 url 建立一個 Script 標籤 node = req.createNode(config, moduleName, url); node.setAttribute("data-requirecontext", context.contextName); node.setAttribute("data-requiremodule", moduleName); // 對不同的瀏覽器 Script 標籤事件監聽做相容處理 if ( node.attachEvent && !( node.attachEvent.toString && node.attachEvent.toString().indexOf("[native code") < 0 ) && !isOpera ) { useInteractive = true; node.attachEvent("onreadystatechange", context.onScriptLoad); } else { node.addEventListener("load", context.onScriptLoad, false); node.addEventListener("error", context.onScriptError, false); } // 設定 Script 標籤的 src 屬性為模組路徑 node.src = url; if (config.onNodeCreated) { config.onNodeCreated(node, config, moduleName, url); } currentlyAddingScript = node; // 將 Script 標籤插入到頁面中 if (baseElement) { head.insertBefore(node, baseElement); } else { head.appendChild(node); } currentlyAddingScript = null; return node; } else if (isWebWorker) { try { //In a web worker, use importScripts. This is not a very //efficient use of importScripts, importScripts will block until //its script is downloaded and evaluated. However, if web workers //are in play, the expectation is that a build has been done so //that only one script needs to be loaded anyway. This may need //to be reevaluated if other use cases become common. // Post a task to the event loop to work around a bug in WebKit // where the worker gets garbage-collected after calling // importScripts(): https://webkit.org/b/153317 setTimeout(function() {}, 0); importScripts(url); //Account for anonymous modules context.completeLoad(moduleName); } catch (e) { context.onError( makeError( "importscripts", "importScripts failed for " + moduleName + " at " + url, e, [moduleName] ) ); } } }; // 建立非同步 Script 標籤 req.createNode = function(config, moduleName, url) { var node = config.xhtml ? document.createElementNS("http://www.w3.org/1999/xhtml", "html:script") : document.createElement("script"); node.type = config.scriptType || "text/javascript"; node.charset = "utf-8"; node.async = true; return node; }; 複製程式碼
可以看出,這裡主要是根據模組的 Url,建立了一個非同步的 Script 標籤,並將模組 id 名稱新增到的標籤的 data-requiremodule 上,再將這個 Script 標籤新增到了 html 頁面中。同時為 Script 標籤的 load 事件添加了處理函式,當該模組檔案被載入完畢的時候,就會觸發 context.onScriptLoad。我們在 onScriptLoad 新增斷點,可以看到頁面結構如下圖所示:

由圖可以看到,Html 中添加了一個 Script 標籤,這也就是非同步載入模組的原理。
JavaScript 模組化之 CMD
CMD (Common Module Definition) 通用模組定義,CMD 在瀏覽器端的實現有 SeaJS, 和 RequireJS 一樣,SeaJS 載入原理也是動態建立非同步 Script 標籤。二者的區別主要是依賴寫法上不同,AMD 推崇一開始就載入所有的依賴,而 CMD 則推崇在需要用的地方才進行依賴載入。
// ADM 在執行以下程式碼的時候,RequireJS 會首先分析依賴陣列,然後依次載入,直到所有載入完畢再執行回到函式 define(["add", "decrease"], function(add, decrease) { let result1 = add(9, 7); let result2 = decrease(9, 7); console.log(result1 * result2); }); // CMD 在執行以下程式碼的時候, SeaJS 會首先用正則匹配出程式碼裡面所有的 require 語句,拿到依賴,然後依次載入,載入完成再執行回撥函式 define(function(require) { let add = require("add"); let result1 = add(9, 7); let add = require("decrease"); let result2 = decrease(9, 7); console.log(result1 * result2); }); 複製程式碼
JavaScript 模組化之 ES Module
ES Module 是在 ECMAScript 6 中引入的模組化功能。模組功能主要由兩個命令構成,分別是 export 和 import。export 命令用於規定模組的對外介面,import 命令用於輸入其他模組提供的功能。
其使用方式如下:
// 模組定義 add.js export function add(a, b) { return a + b; } // 模組使用 main.js import { add } from "./add.js"; console.log(add(1, 2)); // 3 複製程式碼
下面講述幾個較為重要的點。
export 和 export default
在一個檔案或模組中,export 可以有多個,export default 僅有一個, export 類似於具名匯出,而 default 類似於匯出一個變數名為 default 的變數。同時在 import 的時候,對於 export 的變數,必須要用具名的物件去承接,而對於 default,則可以任意指定變數名,例如:
// a.js export var a = 2; export var b = 3 ; // main.js 在匯出的時候必須要用具名變數 a, b 且以解構的方式得到匯出變數 import {a, b} from 'a.js' // √ a= 2, b = 3 import a from 'a.js' // x // b.js export default 方式 const a = 3 export default a // 注意不能 export default const a = 3 ,因為這裡 default 就相當於一個變數名 // 匯出 import b form 'b.js' // √ import c form 'b.js' // √ 因為 b 模組匯出的是 default,對於匯出的default,可以用任意變數去承接 複製程式碼
ES Module 模組載入和匯出過程
以如下程式碼為例子:
// counter.js export let count = 5 // display.js export function render() { console.log('render') } // main.js import { counter } from './counter.js'; import { render } from './display.js' ......// more code 複製程式碼
在模組載入模組的過程中,主要經歷以下幾個步驟:
構建 (Construction)
這個過程執行查詢,下載,並將檔案轉化為模組記錄 (Module record)。所謂的模組記錄是指一個記錄了對應模組的語法樹,依賴資訊,以及各種屬性和方法 (這裡不是很明白)。同樣也是在這個過程對模組記錄進行了快取的操作,下圖是一個模組記錄表:

下圖是快取記錄表:

例項化 (Instantiation)
這個過程會在記憶體中開闢一個儲存空間 (此時還沒有填充值),然後將該模組所有的 export 和 import 了該模組的變數指向這個記憶體,這個過程叫做連結。其寫入 export 示意圖如下所示:

然後是連結 import,其示意圖如下所示:

賦值(Evaluation)
這個過程會執行模組程式碼,並用真實的值填充上一階段開闢的記憶體空間,此過程後 import 連結到的值就是 export 匯出的真實值。
根據上面的過程我們可以知道。ES Module 模組 export 和 import 其實指向的是同一塊記憶體,但有一個點需要注意的是,import 處不能對這塊記憶體的值進行修改,而 export 可以,其示意圖如下:

總結
本文主要對目前主流的 JavaScript 模組化方案 CommonJs,AMD,CMD, ES Module 進行了學習和了解,並對其中最有代表性的模組化實現 (NodeJs,RequireJS,SeaJS,ES6) 做了一個簡單的分析。對於服務端的模組而言,由於其模組都是儲存在本地的,模組載入方便,所以通常是採用同步讀取檔案的方式進行模組載入。而對於瀏覽器而言,其模組一般是儲存在遠端網路上的,模組的下載是一個十分耗時的過程,所以通常是採用動態非同步指令碼載入的方式載入模組檔案。另外,無論是客戶端還是服務端的 JavaScript 模組化實現,都會對模組進行快取,以此減少二次載入的開銷。