作者: zhijs from 迅雷前端
原文地址: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 模組化實現,都會對模組進行快取,以此減少二次載入的開銷。
參考文章: ES modules: A cartoon deep-dive