寫在前面
由於筆者所在的團隊使用fis3打包工具搭配modJS來解決js模組化,並且最近也在研究js模組化方案,故寫下這篇文章來解讀modJS的實現細節。
限於筆者水平,如果有錯誤或不嚴謹的地方,請給予指正,十分感謝。
一、JS中的模組規範(AMD/CMD/CommonJS/ES6)
- CommonJS
- 使用的是同步風格的require
- node.js的模組系統,是參照CommonJS規範定義的
- AMD(Asynchronous Module Definition)非同步模組定義
- AMD規範 https://github.com/amdjs/amdjs-api/wiki/AMD-(%E4%B8%AD%E6%96%87%E7%89%88)
- 使用的是回撥風格的支援非同步
- 以RequireJS 為代表
- CMD (Common Module Definition) 通用模組定義
- CMD規範 https://github.com/seajs/seajs/issues/242
- 以SeaJS為代表
- ES6模組化
- ES6 模組化是歐洲計算機制造聯合會 ECMA 提出的 JavaScript 模組化規範,它在語言的層面上實現了模組化。瀏覽器廠商和 Node.js 都宣佈要原生支援該規範。它將逐漸取代 CommonJS 和 AMD 規範,成為瀏覽器和伺服器通用的模組解決方案
- 缺點在於目前無法直接執行在大部分 JavaScript 執行環境下,必須通過工具轉換成標準的 ES5 後才能正常執行
二、js模組化方案 modJS的使用
首先,來看一下modJS的簡介以及使用方法:
- modJS簡介
modJS是一個精簡版的AMD/CMD規範,並不完全遵守AMD/CMD規範,目的在於希望給使用者提供一個類似nodeJS一樣的開發體驗,同時具備很好的線上效能。
- 使用
- 使用defined(id,factory)來定義一個模組
在平常開發中,只需寫factory中的程式碼即可,無需手動定義模組。打包工具fis3會自動將模組程式碼嵌入factory的閉包裡。 factory提供了3個引數:require, exports, module,用於模組的引用和匯出。
典型的例子
// a.js 檔案
define('js/a', function(require, exports, module) {
function init() {
console.log('模組a被引用')
}
return { init: init }
// or
// exports.init = init
// or
// modules.exports = { init : init }
})
複製程式碼
- 使用require (id) 來引用已預先載入完成的模組
和NodeJS裡獲取模組的方式一樣,非常簡單。因為所需的模組都已預先載入,因此require可以立即返回該模組。
典型的例子
// index.html 檔案
<script src="./mod.js" type="text/javascript"></script>
<script src="./js/a.js" type="text/javascript"></script>
<script type="text/javascript">
require('js/a').init();
</script>
複製程式碼
- 使用require.async (ids, onload, onerror) 來引用非同步載入的模組
考慮到有些模組無需在啟動時載入,因此modJS提供了可以在執行時非同步載入模組的介面。ids可以是一個模組名,或者是陣列形式的模組名列表。當所有都載入都完成時,onload被呼叫,ids對應的所有模組例項將作為引數傳入。如果載入錯誤或者網路超時,onerror將被觸發。超時時間通過require.timeout設定,預設為5000(ms)。
使用require.async獲取的模組不會被打包工具安排在預載入中,因此在完成回撥之前require將會丟擲模組未定義錯誤。
典型的例子:
// index.html 檔案
<script src="./mod.js" type="text/javascript"></script>
<script type="text/javascript">
require.async('js/a',function(mod){
mod.init()
},function(id){
console.error("模組" + id + "載入失敗")
});
</script>
複製程式碼
- 使用require.resourceMap(obj) 解析模組依賴樹
通過require.resourceMap(obj) 解析模組依賴樹,並獲取模組對應的url。由打包工具自動完成。
典型的例子
// resource_map.js 檔案
require.resourceMap({
"pkg": {},
"res": {
"js/a": {
"url": "js/a.js",
"type": "js"
},
"js/b": {
"url": "js/b.js",
"type": "js"
},
"js/c": {
"url": "js/c.js",
"type": "js",
"deps": ["js/a", "js/b"]
}
}
})
複製程式碼
- require.loadJs (url)
非同步載入指令碼檔案,不做任何回撥
- require.loadCss ({url: cssfile})
非同步載入CSS檔案,並新增到頁面
- require.loadCss ({content: csstext})
建立一個樣式列表並將css內容寫入
在這篇文章中,只討論js的模組化方案,故,不討論require.loadCss以及沒有回撥的require.loadJs。
三、modJS的實現細節
從modJS的使用上可以看出,modJS暴露了兩個全域性變數define、require,現在跟隨modJS原始碼研究一下實現細節。 點選檢視modJS的倉庫地址
- define(id,factory)
用define函式包裹js模組來完成模組的定義,包裹操作由打包工具fis3自動完成。
// mod.js 檔案
var require, define;
(function(global) {
if (require) return; // 避免重複載入mod.js而導致已定義模組丟失
var factoryMap = {},
modulesMap = {},
loadingMap = {},
resMap = {},
pkgMap = {};
/**
* @desc 定義js模組, 用define函式包裹模組,由打包工具自動完成
* @param {String} id 模組唯一標識
* @param {Function} factory 工廠函式,接受三個引數require、exports、modules,其中exports只是modules.exports的引用
* @return void
* @example define('js/a',function(require,exports,module){ return { init: function init(){} } })
*/
define = function(id, factory) {
id = alias(id);
factoryMap[id] = factory;
var queue = loadingMap[id]; // 非同步載入模組,回撥函式依次執行
if (queue) {
for (var i = 0, len = queue.length; i < len; i++) {
queue[i]()
}
delete loadingMap[id]; // 從正在載入中移除
}
}
function alias(id) {
return id.replace(/\.js$/i, '');
}
})(this) // 使用函式包裹,避免汙染全域性變數
複製程式碼
比如,當我們有一個js檔案a.js,檔案內容如下:
// a.js 檔案
console.log('模組a');
function init() {
console.log('模組a被引用')
}
return { init: init }
// or
// exports.init=init
// or
// modules.exports={init:init}
複製程式碼
用打包工具進行define函式包裹後,a.js檔案就變成了如下內容,此時我們就完成了對一個標識為“js/a”的模組的包裹:
// a.js 檔案
define('js/a', function(require, exports, module) {
console.log('模組a');
function init() {
console.log('模組a被引用')
}
return { init: init }
// or
// exports.init=init
// or
// modules.exports={init:init}
})
複製程式碼
當檢測到模組被引用,打包工具會將該模組對應的srcipt標籤自動嵌入HTML文件中進行預載入,載入完成後瀏覽器會立即執行,這樣就完成了一個模組的定義。
// index.html 檔案
<script src="./mod.js" type="text/javascript"></script>
<script src="./js/a.js" type="text/javascript"></script>
複製程式碼
- require(id)
在上一步操作中,完成了對模組標識為“js/a”的模組的定義,現在可以通過require(id)對已定義的模組進行引用了。 require(id)所需要做的就是初始化factory。
// mod.js 檔案
var require, define;
(function(global) {
/** 此處省略部分程式碼 **/
/**
* @desc 同步引用已定義的js模組,若該模組未定義,則丟擲 “Can not find module”錯誤
* @param {String} id 模組唯一標識
* @return {Object|String} 返回模組內部執行的return語句,如果模組內部沒有執行return,則返回模組內部呼叫的 moduls.exoprts; return 優先順序高於 module.exports
* @example require('js/a')
*/
require = function(id) {
id = alias(id);
var module = modulesMap[id];
// 避免重複初始化factory
if (module) {
return module.exports
}
// 初始化factory
var factory = factoryMap[id];
if (!factory) {
throw "Can not find module `" + id + "`";
}
module = modulesMap[id] = { exports: {} };
var result = typeof factory === "function" ? factory.apply(module, [require, module.exports, module]) : factory;
if (result) { // return 優先順序高於 module.exports
module.exports = result;
}
return module.exports
}
function alias(id) {
return id.replace(/\.js$/i, '');
}
})(this)
複製程式碼
- requier.asyn(ids,onload,onerror)
在上面的介紹中,我們知道:通過define(id,factory)函式包裹一個模組,並使用打包工具fis3自動將該模組對應的script內嵌至HTML文件中完成模組的預載入,然後require(id)函式再引用已經預載入好的模組。 但考慮到有些模組無需在啟動時載入,所以需要通過requier.async(ids,onload,onerror)進行執行時非同步載入模組
那麼,執行時非同步載入模組需要解決那些問題呢?
- 模組內部依賴解析
- 模組資源定位
- 通過DOM操作動態的往HTML head標籤裡插入HTML script標籤來非同步載入模組
- 模組及模組內部依賴非同步載入完成後的執行onload回撥,如果載入失敗或超時執行onerror回撥
對於模組內部依賴解析和模組資源定位這個兩個問題,modJS是通過require.resourceMap函式解析打包工具fis3生成的rerource_map物件實現的。
比如,js目錄下有三個js檔案a.js、b.js、c.js,c.js引用了a.js和b.js,那麼打包工具就會解析檔案之間的依賴關係以及資源定位,生成一個json物件:
"pkg": {},
"res": {
"js/a": {
"url": "js/a.js",
"type": "js"
},
"js/b": {
"url": "js/b.js",
"type": "js"
},
"js/c": {
"url": "js/c.js",
"type": "js",
"deps": ["js/a", "js/b"]
}
}
複製程式碼
再使用require.resourceMap(obj)函式進行包裹,生成一個resource_map.js檔案,內嵌至HTML文件中,瀏覽器載入完resource_map.js檔案後,執行require.resourceMap函式就完成了模組內部依賴解析以及模組資源定位
// resource_map.js 檔案
require.resourceMap({
"pkg": {},
"res": {
"js/a": {
"url": "js/a.js",
"type": "js"
},
"js/b": {
"url": "js/b.js",
"type": "js"
},
"js/c": {
"url": "js/c.js",
"type": "js",
"deps": ["js/a", "js/b"]
}
}
})
// mod.js 檔案
var require, define;
(function(global) {
/** 此處省略部分程式碼 **/
/**
* @desc js模組依賴解析
* @param {Object} obj js模組依賴物件: { pkg: {}, res: { 'js/a': { url: 'js/a.js', type: 'js' }, 'js/b': { url: 'js/b.js', type: 'js', deps: ['js/a'] } } }
* @return void
*/
require.resourceMap = function(obj) {
var k, col;
// merge `res` & `pkg` fields
col = obj.res;
for (k in col) {
if (col.hasOwnProperty(k)) {
resMap[k] = col[k];
}
}
col = obj.pkg;
for (k in col) {
if (col.hasOwnProperty(k)) {
pkgMap[k] = col[k];
}
}
}
})(this)
// index.html
<script src="./mod.js" type="text/javascript"></script>
<script src="./resource_map.js" type="text/javascript"></script>
<script type="text/javascript">
require.async('js/c', function(mod) {
mod.init()
});
</script>
複製程式碼
現在,解決了模組內部依賴解析和資源定位的問題,就可以通過DOM操作動態的往HTML head標籤裡插入HTML script標籤來非同步載入模組,並在模組及模組內部依賴非同步載入完成後的執行onload回撥,如果非同步載入失敗或超時的執行onerror回撥,非同步載入超時時間,modJS通過require.timeout來設定,預設為5s
// mod.js 檔案
var require, define;
(function(global) {
/** 此處省略部分程式碼 **/
var head = document.getElementsByTagName('head')[0];
/**
* @desc 非同步載入js模組
* @param {String} id 模組唯一標識
* @param {Function} onload 所有的模組(包括模組內部依賴)都載入完成後執行回撥函式
* @param {Function} onerror 模組載入錯誤或超時時執行的回撥函式,超時時間通過require.timeout設定,預設5s
* @example require.async(id,onload,onerror)
* @example require.async([id1,id2,...],onload,onerror)
* @tips 先非同步載入該模組,再非同步載入該模組的依賴,為什麼這種順序不會出現問題? 因為會等待所有的非同步模組載入完畢之後才會執行onload函式
*/
require.async = function(ids, onload, onerror) {
if (typeof ids === 'string') {
ids = [ids]
}
var needMap = {},
needNum = 0;
function findDependence(depArr) {
for (var i = 0, len = depArr.length; i < len; i++) {
var dep = alias(depArr[i]);
if (dep in factoryMap) { // skip loaded
var child = resMap[dep] || resMap[dep + '.js']
if (child && 'deps' in child) { // 通過resource_map.js檢查模組是否存在內部依賴,若存在,且不依賴本身,則遞迴內部依賴
(child.deps !== depArr) && findDependence(child.deps)
}
continue;
}
if (dep in needMap) { // skip loading
continue;
}
needMap[dep] = 1;
needNum++;
loadScript(dep, updateNeed, onerror) // 動態載入指令碼。 updateNeed函式有權訪問外部函式的變數(needNum,ids,onload),並只能得到這些變數的最後一個值(閉包)
var child = resMap[dep] || resMap[dep + '.js']
if (child && 'deps' in child) { // 通過resource_map.js檢查模組是否存在內部依賴,若存在,且不依賴本身,則遞迴內部依賴
(child.deps !== depArr) && findDependence(child.deps)
}
}
}
function updateNeed() {
if (0 == needNum--) { // 等待所有的模組以及模組的內部依賴載入成功,再執行回撥函式onload
var args = [];
for (var i = 0, n = ids.length; i < n; i++) {
args[i] = require(ids[i]); // 將載入完成的模組作為引數傳遞給onload回撥函式,如果有模組為載入成功,將丟擲Can not find module異常
}
typeof onload === 'function' && onload.apply(global, args) // onload函式的作用域指向全域性
}
}
findDependence(ids);
updateNeed();
}
/**
* @desc 載入非同步js指令碼超時時間,預設5s
*/
require.timeout = 5000;
/**
* @desc 通過script標籤動態載入指令碼
* @param {String} id 模組唯一標識
* @param {Function} calback js模組loaded的回撥函式
* @param {Function} onerror: js模組errored的回撥函式
* @return void
*/
function loadScript(id, callback, onerror) {
var queue = loadingMap[id] || (loadingMap[id] = []);
queue.push(callback)
var res = resMap[id] || resMap[id + ".jd"]; // 通過resource_map.js獲取模組對應的url
var pkg = res.pkg;
if (!res.url) return;
if (pkg) {
url = pkgMap[pkg].url;
} else {
url = res.url || id;
}
createScript(url, onerror && function() {
onerror(id)
});
}
function createScript(url, onerror) {
var script = document.createElement('script');
if (onerror) {
var tid = setTimeout(onerror, require.timeout); // 超時執行onerror
function onload() {
clearTimeout(tid) // loaded 清除定時器
}
if ('onload' in script) {
script.onload = onload
} else {
script.onreadystatechange = function() {
if (this.readyState === 'loaded' || this.readyState === 'complete') {
onload();
}
}
}
script.onerror = function() {
clearTimeout(tid); // errored 清除定時器
onerror()
};
}
script.src = url;
script.type = "text/javascript";
head.appendChild(script);
return script;
}
})(this);
複製程式碼
四:總結
通過以上,可以總結出modJS實現js模組化解決方案的6個要點:
- define(id,factory),定義模組,對模組進行define函式包裹,由打包工具完成。
- require(id),同步載入已定義的js模組,若該模組未定義,則丟擲 “Can not find module”錯誤。
- require.resourceMap,通過resource_map.js 解析js模組依賴樹,以及模組的資源定位,resource_map.js由打包工具解析檔案依賴和資源定位幷包裹require.resourceMap函式完成。
- require.timeout,設定非同步載入模組的超時時間,預設5s。
- require.async(ids,onload,onerror),通過DOM操作動態的往HTML head標籤裡插入HTML script標籤來非同步載入模組以及模組的內部依賴,script標籤的src通過resourceMap取得。
- 非同步載入模組以及模組的內部依賴完成後,通過require引入該模組,並作為引數傳遞給require.async的回撥函式onload;非同步載入失敗或超時,執行onerror回撥。
五:附上完整的帶註釋modJS程式碼
var require, define;
(function(global) {
if (require) return; // 避免重複載入mod.js而導致已定義模組丟失
var factoryMap = {},
modulesMap = {},
loadingMap = {},
resMap = {},
pkgMap = {},
head = document.getElementsByTagName('head')[0];
/**
* @desc 定義js模組, 用define函式包裹模組,由打包工具自動完成
* @param {String} id 模組唯一標識
* @param {Function} factory 工廠函式,接受三個引數require、exports、modules,其中exports只是modules.exports的引用
* @return void
* @example define('js/a',function(require,exports,module){ return { init: function init(){} } })
*/
define = function(id, factory) {
id = alias(id);
factoryMap[id] = factory;
var queue = loadingMap[id]; // 非同步載入,回撥函式依次執行
if (queue) {
for (var i = 0, len = queue.length; i < len; i++) {
queue[i]()
}
delete loadingMap[id]; // 從正在載入中移除
}
}
/**
* @desc 同步載入已定義的js模組,若該模組未定義,則丟擲 “Can not find module”錯誤
* @param {String} id 模組唯一標識
* @return {Object|String} 返回模組內部執行的return語句,如果模組內部沒有執行return,則返回模組內部呼叫的 moduls.exoprts; return 優先順序高於 module.exports
* @example require('js/a')
*/
require = function(id) {
id = alias(id);
var module = modulesMap[id];
// 避免重複初始化factory
if (module) {
return module.exports
}
// 初始化factory
var factory = factoryMap[id];
if (!factory) {
throw "Can not find module `" + id + "`";
}
module = modulesMap[id] = { exports: {} };
var result = typeof factory === "function" ? factory.apply(module, [require, module.exports, module]) : factory;
if (result) { // return 優先順序高於 module.exports
module.exports = result;
}
return module.exports
}
/**
* @desc 非同步載入js模組
* @param {String|Array} ids 模組唯一標識
* @param {Function} onload 所有的模組(包括模組內部依賴)都載入完畢後執行回撥函式
* @param {Function} onerror 模組載入錯誤或超時時執行的回撥函式,超時時間通過require.timeout設定,預設5s
* @example require.async(id,onload,onerror)
* @example require.async([id1,id2,...],onload,onerror)
* @tips 先非同步載入該模組,再非同步載入該模組的依賴,為什麼這種順序不會出現問題? 因為會等待所有的非同步模組載入完畢之後才會執行onload函式
*/
require.async = function(ids, onload, onerror) {
if (typeof ids === 'string') {
ids = [ids]
}
var needMap = {},
needNum = 0;
function findDependence(depArr) {
for (var i = 0, len = depArr.length; i < len; i++) {
var dep = alias(depArr[i]);
if (dep in factoryMap) { // skip loaded
var child = resMap[dep] || resMap[dep + '.js']
if (child && 'deps' in child) { // 通過resource_map.js檢查模組是否存在內部依賴,若存在,且不依賴本身,則遞迴內部依賴
(child.deps !== depArr) && findDependence(child.deps)
}
continue;
}
if (dep in needMap) { // skip loading
continue;
}
needMap[dep] = 1;
needNum++;
loadScript(dep, updateNeed, onerror) // 動態載入指令碼。 updateNeed函式有權訪問外部函式的變數(needNum,ids,onload),並只能得到這些變數的最後一個值(閉包)
var child = resMap[dep] || resMap[dep + '.js']
if (child && 'deps' in child) { // 通過resource_map.js檢查模組是否存在內部依賴,若存在,且不依賴本身,則遞迴內部依賴
(child.deps !== depArr) && findDependence(child.deps)
}
}
}
function updateNeed() {
if (0 == needNum--) { // 等待所有的模組以及模組的內部依賴載入完成,再執行回撥函式onload
var args = [];
for (var i = 0, n = ids.length; i < n; i++) {
args[i] = require(ids[i]); // 將載入完成的模組作為引數傳遞給onload回撥函式,如果有模組未載入成功,將丟擲Can not find module異常
}
typeof onload === 'function' && onload.apply(global, args) // onload函式的作用域指向全域性
}
}
findDependence(ids);
updateNeed();
}
/**
* @desc 載入非同步js指令碼超時時間,預設5s
*/
require.timeout = 5000;
/**
* @desc js模組依賴解析
* @param {Object} obj js模組依賴物件: { pkg: {}, res: { 'js/a': { url: 'js/a.js', type: 'js' }, 'js/b': { url: 'js/b.js', type: 'js', deps: ['js/a'] } } }
* @return void
*/
require.resourceMap = function(obj) {
var k, col;
// merge `res` & `pkg` fields
col = obj.res;
for (k in col) {
if (col.hasOwnProperty(k)) {
resMap[k] = col[k];
}
}
col = obj.pkg;
for (k in col) {
if (col.hasOwnProperty(k)) {
pkgMap[k] = col[k];
}
}
}
/**
* @desc 通過script標籤動態載入指令碼
* @param {String} id 模組唯一標識
* @param {Function} calback js模組loaded的回撥函式
* @param {Function} onerror: js模組errored的回撥函式
* @return void
*/
function loadScript(id, callback, onerror) {
var queue = loadingMap[id] || (loadingMap[id] = []);
queue.push(callback)
var res = resMap[id] || resMap[id + ".jd"]; // 通過resource_map.js獲取模組對應的url
var pkg = res.pkg;
if (!res.url) return;
if (pkg) {
url = pkgMap[pkg].url;
} else {
url = res.url || id;
}
createScript(url, onerror && function() {
onerror(id)
});
}
function createScript(url, onerror) {
var script = document.createElement('script');
if (onerror) {
var tid = setTimeout(onerror, require.timeout); // 超時執行onerror
function onload() {
clearTimeout(tid) // loaded 清除定時器
}
if ('onload' in script) {
script.onload = onload
} else {
script.onreadystatechange = function() {
if (this.readyState === 'loaded' || this.readyState === 'complete') {
onload();
}
}
}
script.onerror = function() {
clearTimeout(tid); // errored 清除定時器
onerror()
};
}
script.src = url;
script.type = "text/javascript";
head.appendChild(script);
return script;
}
function alias(id) {
return id.replace(/\.js$/i, '');
}
})(this); // 使用函式包裹,避免汙染全域性變數
複製程式碼
限於筆者水平,如果有錯誤或不嚴謹的地方,請給予指正,十分感謝。