做者: zhijs from 迅雷前端javascript
原文地址:JavaScript 模塊化解析 html
隨着 JavasScript 語言逐漸發展,JavaScript 應用從簡單的表單驗證,到複雜的網站交互,再到服務端,移動端,PC 客戶端的語言支持。JavaScript 應用領域變的愈來愈普遍,工程代碼變得愈來愈龐大,代碼的管理變得愈來愈困難,因而乎 JavaScript 模塊化方案在社區中應聲而起,其中一些優秀的模塊化方案,逐漸成爲 JavaScript 的語言規範,下面咱們就 JavaScript 模塊化這個話題展開討論,本文的主要包含以幾部份內容。前端
模塊,又稱構件,是可以單獨命名並獨立地完成必定功能的程序語句的集合 (即程序代碼和數據結構的集合體)。它具備兩個基本的特徵:外部特徵和內部特徵。外部特徵是指模塊跟外部環境聯繫的接口 (即其餘模塊或程序調用該模塊的方式,包括有輸入輸出參數、引用的全局變量) 和模塊的功能,內部特徵是指模塊的內部環境具備的特色 (即該模塊的局部數據和程序代碼)。簡而言之,模塊就是一個具備獨立做用域,對外暴露特定功能接口的代碼集合。java
首先讓咱們回到過去,看看原始 JavaScript 模塊文件的寫法。node
// 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 文件的功能函數,因此咱們在使用的時候,通常會這樣寫:git
<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>
複製代碼
這樣的管理方式會形成如下幾個問題:github
基於上述的緣由,就有了對上述問題的解決方案,便是 JavaScript 模塊化規範,目前主流的有 CommonJS,AMD,CMD,ES6 Module 這四種規範。web
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 是指向同一個東西的變量,便是 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;
};
複製代碼
如下根據 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);
};
複製代碼
編譯過程主要作了如下的操做:
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 屬性。
AMD, Asynchronous Module Definition,即異步模塊加載機制,它採用異步方式加載模塊,模塊的加載不影響它後面語句的運行。全部依賴這個模塊的語句都定義在一個回調函數中,等到依賴加載完成以後,這個回調函數纔會運行。
AMD 的誕生,就是爲了解決這兩個問題:
// 模塊定義
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 標籤,這也就是異步加載模塊的原理。
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);
});
複製代碼
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 相似於具名導出,而 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,能夠用任意變量去承接
複製代碼
以以下代碼爲例子:
// 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
複製代碼
在模塊加載模塊的過程當中,主要經歷如下幾個步驟:
這個過程執行查找,下載,並將文件轉化爲模塊記錄 (Module record)。所謂的模塊記錄是指一個記錄了對應模塊的語法樹,依賴信息,以及各類屬性和方法 (這裏不是很明白)。一樣也是在這個過程對模塊記錄進行了緩存的操做,下圖是一個模塊記錄表:
下圖是緩存記錄表:
這個過程會在內存中開闢一個存儲空間 (此時尚未填充值),而後將該模塊全部的 export 和 import 了該模塊的變量指向這個內存,這個過程叫作連接。其寫入 export 示意圖以下所示:
而後是連接 import,其示意圖以下所示:
這個過程會執行模塊代碼,並用真實的值填充上一階段開闢的內存空間,此過程後 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