解讀js模組化方案modJS

莫修發表於2018-07-21

寫在前面

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

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

一、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 後才能正常執行

二、js模組化方案 modJS的使用


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

  1. modJS簡介

modJS是一個精簡版的AMD/CMD規範,並不完全遵守AMD/CMD規範,目的在於希望給使用者提供一個類似nodeJS一樣的開發體驗,同時具備很好的線上效能。

  1. 使用
  • 使用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的倉庫地址

  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); // 使用函式包裹,避免汙染全域性變數
複製程式碼

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

參考:

相關文章