解讀js模塊化方案modJS

寫在前面

因爲筆者所在的團隊使用fis3打包工具搭配modJS來解決js模塊化,而且最近也在研究js模塊化方案,故寫下這篇文章來解讀modJS的實現細節。javascript

限於筆者水平,若是有錯誤或不嚴謹的地方,請給予指正,十分感謝。css

1、JS中的模塊規範(AMD/CMD/CommonJS/ES6)


  1. CommonJS
  • 使用的是同步風格的require
  • node.js的模塊系統,是參照CommonJS規範定義的
  1. AMD(Asynchronous Module Definition)異步模塊定義
  • AMD規範 https://github.com/amdjs/amdjs-api/wiki/AMD-(%E4%B8%AD%E6%96%87%E7%89%88)
  • 使用的是回調風格的支持異步
  • 以RequireJS 爲表明
  1. CMD (Common Module Definition) 通用模塊定義
  • CMD規範 https://github.com/seajs/seajs/issues/242
  • 以SeaJS爲表明
  1. ES6模塊化
  • ES6 模塊化是歐洲計算機制造聯合會 ECMA 提出的 JavaScript 模塊化規範,它在語言的層面上實現了模塊化。瀏覽器廠商和 Node.js 都宣佈要原生支持該規範。它將逐漸取代 CommonJS 和 AMD 規範,成爲瀏覽器和服務器通用的模塊解決方案
  • 缺點在於目前沒法直接運行在大部分 JavaScript 運行環境下,必須經過工具轉換成標準的 ES5 後才能正常運行

2、js模塊化方案 modJS的使用


首先,來看一下modJS的簡介以及使用方法:html

  1. modJS簡介

modJS是一個精簡版的AMD/CMD規範,並不徹底遵照AMD/CMD規範,目的在於但願給使用者提供一個相似nodeJS同樣的開發體驗,同時具有很好的線上性能。java

  1. 使用
  • 使用defined(id,factory)來定義一個模塊

在日常開發中,只需寫factory中的代碼便可,無需手動定義模塊。打包工具fis3會自動將模塊代碼嵌入factory的閉包裏。 factory提供了3個參數:require, exports, module,用於模塊的引用和導出。node

典型的例子git

// 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能夠當即返回該模塊。github

典型的例子json

// 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)。api

使用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。

3、modJS的實現細節


從modJS的使用上能夠看出,modJS暴露了兩個全局變量define、require,如今跟隨modJS源代碼研究一下實現細節。 點擊查看modJS的倉庫地址

  1. 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>
複製代碼
  1. 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)
複製代碼
  1. 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); // 使用函數包裹,避免污染全局變量
複製代碼

限於筆者水平,若是有錯誤或不嚴謹的地方,請給予指正,十分感謝。

參考:
相關文章
相關標籤/搜索