JavaScript 模組化程式設計之載入器原理

NONO發表於2016-07-31

世面上有好多JavaScript的載入器,比如 sea.js, require.js, yui loader, labJs…., 載入器的使用範圍是一些比較大的專案, 個人感覺如果是小專案的話可以不用,  我用過seaJS和requireJS, 在專案中用過requireJS, requireJS是符合AMD,全稱是(Asynchronous Module Definition)即非同步模組載入機制 , seaJS是符合CMD規範的載入器。

AMD__和__CMD

AMD規範是依賴前置, CMD規範是依賴後置, AMD規範的載入器會把所有的JS中的依賴前置執行。 CMD是懶載入, 如果JS需要這個模組就載入, 否則就不載入, 導致的問題是符合AMD規範的載入器(requireJS), 可能第一次載入的時間會比較久, 因為他把所有依賴的JS全部一次性下載下來;

常識,jQuery是支援AMD規範,並不支援CMD規範,也就是說, 如果引入的是seaJS,想要使用jQuery,要用alias配置, 或者直接把 http://cdn.bootcss.com/jquery/2.1.4/jquery.js 直接引入頁面中;

//這是jQuery原始碼的最後幾行, jQuery到了1.7才支援模組化;
// Register as a named AMD module, since jQuery can be concatenated with other
// files that may use define, but not via a proper concatenation script that
// understands anonymous AMD modules. A named AMD is safest and most robust
// way to register. Lowercase jquery is used because AMD module names are
// derived from file names, and jQuery is normally delivered in a lowercase
// file name. Do this after creating the global so that if an AMD module wants
// to call noConflict to hide this version of jQuery, it will work.
// Note that for maximum portability, libraries that are not jQuery should
// declare themselves as anonymous modules, and avoid setting a global if an
// AMD loader is present. jQuery is a special case. For more information, see
// https://github.com/jrburke/requirejs/wiki/Updating-existing-libraries#wiki-anon
if ( typeof define === "function" && define.amd ) {
    define( "jquery", [], function() {
        return jQuery;
    });
};

使用方法

比如我們可以這樣定義一個模組:

//檔案所在的路徑地址為:http://localhost:63342/module/script/dir2/1.js
define(function() {
    return "!!!!";
});

也可以這樣定義一個模組:

//這個檔案的路徑為http://localhost:63342/module/main.js ,而且有一個依賴, 載入器會自動去載入這個依賴, 當依賴載入完畢以後, 會把這個依賴(就是script/dir2/1.js)執行的返回值作為這個函式的引數傳進去;
require(["script/dir2/1.js"], function(module1) {
    console.log(module1);
});
//實際上會列印出 "!!!!"

一般來說,一個模組只能寫一個define函式, define函式的傳參主要有兩種方式:

1:正常上可以是一個函式;

2:可以是一個陣列型別依賴的列表, 和一個函式;

如果一個模組寫了多個define會導致模組失靈, 先定義的模組被後定義的模組給覆蓋了 ( 當然了, 一般我們不那樣玩);

一個模組內可以寫多個require, 我們可以直接理解require為匿名的define模組, 一個define模組內可以有多個require, 而且require過的模組會被快取起來, 這個快取的變數一般是在閉包內, 而且名字多數叫modules什麼的…..;

我們通過載入器開發實現的模組化開發要遵守一種規範, 規範了一個模組為一個JS,那麼我們就可以新建幾個目錄為conroller,view, model, 也是為了後期更好的維護和解耦:

實現一個自己的載入器

使用的方式:

//這個模組依賴的四個模組,載入器會分別去載入這四個模組;
define(["依賴0","依賴1","依賴2","依賴3"], function(依賴0,依賴1,依賴2,依賴3){

});

//返回一個空物件
define(function(){
    return {};
});

//直接把require當作是define來用就好了;
require(["依賴0","依賴1","依賴2","依賴3"], function(依賴0,依賴1,依賴2,依賴3) {
    //執行依賴0;
    依賴0(依賴1,依賴2,依賴3);
});

//這個載入器define函式和require函式的區別是,define我們可以傳個name作為第一引數, 這個引數就是模組的名字, 好吧, 不管這些了.....;

以下為載入器的結構,因為程式碼量已經很少了, 所以每一函式都是必須的, 為了不影響全域性, 把程式碼放在匿名自執行函式內部:

(function() {
    定義一個區域性的difine;
    var define;
    //我偷偷加了個全域性變數,好除錯啊;
    window.modules = {
    };
    //通過一個名字獲取絕對路徑比如傳"xx.js"會變成"http://www.mm.com/"+ baseUrl + "xx.html";
    var getUrl = function(src) {};
    //動態載入js的模組;
    var loadScript = function(src) {};
    //獲取根路徑的方法, 一般來說我們可以通過config.baseUrl配置這個路徑;
    var getBasePath = function() {};
    //獲取當前正在載入的script標籤DOM節點;
    var getCurrentNode = function() {};
    //獲取當前script標籤的絕對src地址;
    var getCurrentPath = function() {};
    //載入define或者require中的依賴, 封裝了loadScript方法;
    var loadDpt = function(module) {};
    //這個是主要模組, 完成了載入依賴, 檢測依賴等比較重要的邏輯
    var checkDps = function() {};
    定義了define這個方法
    define = function(deps, fn, name) {};
    window.define = define;
    //require是封裝了define的方法, 就是多傳了一個引數而已;
    window.require = function() {
        //如果是require的話那麼模組的名字就是一個不重複的名字,避免和define重名;
        window.define.apply([], Array.prototype.slice.call(arguments).concat( "module|"+setTimeout(function() {},0) ));
    };
});

載入器原始碼實現(相容,chrome, FF, IE6 ==>> IE11), IE11沒有了readyState屬性, 也沒有currentScript屬性,坑爹啊,  無法獲取當前正在執行的JS路徑, 所以要用hack;

<!DOCTYPE html>
<html>
<head lang="en">
    <meta charset="UTF-8">
    <title></title>
    <script>
    (function() {
        var define;
        window.modules = {
        };
        var getUrl = function(src) {
            var scriptSrc = "";
            //判斷URL是否是
            // ./或者
            // /或者
            // 直接是以字串開頭
            // 或者是以http://開頭;
            if( src.indexOf("/") === 0 || src.indexOf("./") === 0 ) {
                scriptSrc = require.config.base + src.replace(/^\//,"").replace(/^\.\//,"");
            }else if( src.indexOf("http:") === 0 ) {
                scriptSrc = src;
            }else if( src.match(/^[a-zA-Z1-9]/) ){
                scriptSrc = require.config.base + src;
            }else if(true) {
                alert("src錯誤!");
            };
            if (scriptSrc.lastIndexOf(".js") === -1) {
                scriptSrc += ".js";
            };
            return scriptSrc;
        };

        var loadScript = function(src) {
            var scriptSrc = getUrl(src);
            var sc = document.createElement("script");
            var head = document.getElementsByTagName("head")[0];
            sc.src = scriptSrc;
            sc.onload = function() {
                console.log("script tag is load, the url is : " + src);
            };
            head.appendChild( sc );
        };

        var getBasePath = function() {
            var src = getCurrentPath();
            var index = src.lastIndexOf("/");
            return  src.substring(0,index+1);
        };

        var getCurrentNode = function() {
            if(document.currentScript) return document.currentScript;
            var arrScript = document.getElementsByTagName("script");
            var len = arrScript.length;
            for(var i= 0; i<len; i++) {
                if(arrScript[i].readyState === "interactive") {
                    return arrScript[i];
                };
            };

            //IE11的特殊處理;
            var path = getCurrentPath();
            for(var i= 0; i<len; i++) {
                if(path.indexOf(arrScript[i].src)!==-1) {
                    return arrScript[i];
                };
            };
            throw new Error("getCurrentNode error");
        };

        var getCurrentPath = function() {
            var repStr = function(str) {
                return (str || "").replace(/[\&\?]{1}[\w\W]+/g,"") || "";
            };
            if(document.currentScript) return repStr(document.currentScript.src);

            //IE11沒有了readyState屬性, 也沒有currentScript屬性;
            // 參考 https://github.com/samyk/jiagra/blob/master/jiagra.js
            var stack
            try {
                a.b.c() //強制報錯,以便捕獲e.stack
            } catch (e) { //safari的錯誤物件只有line,sourceId,sourceURL
                stack = e.stack
                if (!stack && window.opera) {
                    //opera 9沒有e.stack,但有e.Backtrace,但不能直接取得,需要對e物件轉字串進行抽取
                    stack = (String(e).match(/of linked script \S+/g) || []).join(" ")
                }
            }
            if (stack) {
                /**e.stack最後一行在所有支援的瀏覽器大致如下:
                 *chrome23:
                 * at http://113.93.50.63/data.js:4:1
                 *firefox17:
                 *@http://113.93.50.63/query.js:4
                 *opera12:http://www.oldapps.com/opera.php?system=Windows_XP
                 *@http://113.93.50.63/data.js:4
                 *IE10:
                 *  at Global code (http://113.93.50.63/data.js:4:1)
                 *  //firefox4+ 可以用document.currentScript
                 */
                stack = stack.split(/[@ ]/g).pop() //取得最後一行,最後一個空格或@之後的部分
                stack = stack[0] === "(" ? stack.slice(1, -1) : stack.replace(/\s/, "") //去掉換行符
                return stack.replace(/(:\d+)?:\d+$/i, "") //去掉行號與或許存在的出錯字元起始位置
            };

            //實在不行了就走這裡;
            var node = getCurrentNode();
            //IE>=8的直接通過src可以獲取,IE67要通過getAttriubte獲取src;
            return repStr(document.querySelector ? node.src : node.getAttribute("src", 4)) || "";

            throw new Error("getCurrentPath error!");
        };

        var loadDpt = function(module) {
            var dp = "";
            for(var p =0; p<module.dps.length; p++) {
                //獲取絕對的地址;
                var dp = getUrl(module.dps[p]);
                //如果依賴沒有載入就直接載入;
                if( !modules[dp] ) {
                    loadScript(dp);
                };
            };
        };

        //主要的模組, 檢測所有未載入的模組中未完成了的依賴是否載入完畢,如果載入完畢就載入模組, 如果載入過的話,而且所有依賴的模組載入完畢就執行該模組
        //而且此模組的exports為該模組的執行結果;
        var checkDps = function() {
            for(var key in modules ) {
                //初始化該模組需要的引數;
                var params = [];
                var module = modules[key];
                //載入完畢就什麼都不做;
                if( module.state === "complete" ) {
                    continue;
                };
                if( module.state === "initial" ) {
                    //如果依賴沒有載入就載入依賴並且modules沒有該module就載入這個模組;
                    loadDpt(module);
                    module.state = "loading";
                };
                if( module.state === "loading") {
                    //如果這個依賴載入完畢
                    for(var p =0; p<module.dps.length; p++) {
                        //獲取絕對的地址;
                        var dp = getUrl(module.dps[p]);
                        //如果依賴載入完成了, 而且狀態為complete;;
                        if( modules[dp] && modules[dp].state === "complete") {
                            params.push( modules[dp].exports );
                        };
                    };
                    //如果依賴全部載入完畢,就執行;
                    if( module.dps.length === params.length ) {
                        if(typeof module.exports === "function"){
                            module.exports = module.exports.apply(modules,params);
                            module.state = "complete";
                            //每一次有一個模組載入完畢就重新檢測modules,看看是否有未載入完畢的模組需要載入;
                            checkDps();
                        };
                    };
                };
            };
        };

        //[],fn; fn
        define = function(deps, fn, name) {
            if(typeof deps === "function") {
                fn = deps;
                deps = [];//我們要把陣列清空;
            };

            if( typeof deps !== "object" && typeof fn !== "function") {
                alert("引數錯誤")
            };

            var src = getCurrentPath();
            //沒有依賴, 沒有載入該模組就新建一個該模組;
            if( deps.length===0 ) {
                modules[ src ] = {
                    name : name || src,
                    src : src,
                    dps : [],
                    exports : (typeof fn === "function")&&fn(),
                    state : "complete"
                };
                return checkDps();
            }else{
                modules[ src ] = {
                    name : name || src,
                    src : src,
                    dps : deps,
                    exports : fn,
                    state : "initial"
                };
                return checkDps();
            }
        };

        window.define = define;
        window.require = function() {
            //如果是require的話那麼模組的名字就是一個不重複的名字,避免和define重名;
            window.define.apply([], Array.prototype.slice.call(arguments).concat( "module|"+setTimeout(function() {},0) ));
        };
        require.config = {
            base : getBasePath()
        };
        require.loadScript = loadScript;
        var loadDefaultJS = getCurrentNode().getAttribute("data-main");
        loadDefaultJS && loadScript(loadDefaultJS);
    })();
    </script>
</head>
<body>
</body>
</html>

從葉大大那邊偷的一個載入器, 這個載入器有點像jQuery中延遲物件($.Deferred)有關的方法when($.when)的實現;

<!DOCTYPE html>
<html>
<head lang="en">
    <meta charset="UTF-8">
    <title></title>
    <script>
        (function () {

            //儲存已經載入好的模組
            var moduleCache = {};

            var define = function (deps, callback) {
                var params = [];
                var depCount = 0;
                var i, len, isEmpty = false, modName;

                //獲取當前正在執行的js程式碼段,這個在onLoad事件之前執行
                modName = document.currentScript && document.currentScript.id || 'REQUIRE_MAIN';

                //簡單實現,這裡未做引數檢查,只考慮陣列的情況
                if (deps.length) {
                    for (i = 0, len = deps.length; i < len; i++) {
                        (function (i) {
                            //依賴加一
                            depCount++;
                            //這塊回撥很關鍵
                            loadMod(deps[i], function (param) {
                                params[i] = param;
                                depCount--;
                                if (depCount == 0) {
                                    saveModule(modName, params, callback);
                                }
                            });
                        })(i);
                    }
                } else {
                    isEmpty = true;
                }

                if (isEmpty) {
                    setTimeout(function () {
                        saveModule(modName, null, callback);
                    }, 0);
                }

            };

            //考慮最簡單邏輯即可
            var _getPathUrl = function (modName) {
                var url = modName;
                //不嚴謹
                if (url.indexOf('.js') == -1) url = url + '.js';
                return url;
            };

            //模組載入
            var loadMod = function (modName, callback) {
                var url = _getPathUrl(modName), fs, mod;

                //如果該模組已經被載入
                if (moduleCache[modName]) {
                    mod = moduleCache[modName];
                    if (mod.status == 'loaded') {
                        setTimeout(callback(this.params), 0);
                    } else {
                        //如果未到載入狀態直接往onLoad插入值,在依賴項載入好後會解除依賴
                        mod.onload.push(callback);
                    }
                } else {

                    /*
                     這裡重點說一下Module物件
                     status代表模組狀態
                     onLoad事實上對應requireJS的事件回撥,該模組被引用多少次變化執行多少次回撥,通知被依賴項解除依賴
                     */
                    mod = moduleCache[modName] = {
                        modName: modName,
                        status: 'loading',
                        export: null,
                        onload: [callback]
                    };

                    _script = document.createElement('script');
                    _script.id = modName;
                    _script.type = 'text/javascript';
                    _script.charset = 'utf-8';
                    _script.async = true;
                    _script.src = url;

                    //這段程式碼在這個場景中意義不大,註釋了
                    //      _script.onload = function (e) {};

                    fs = document.getElementsByTagName('script')[0];
                    fs.parentNode.insertBefore(_script, fs);

                }
            };

            var saveModule = function (modName, params, callback) {
                var mod, fn;

                if (moduleCache.hasOwnProperty(modName)) {
                    mod = moduleCache[modName];
                    mod.status = 'loaded';
                    //輸出項
                    mod.export = callback ? callback(params) : null;

                    //解除父類依賴,這裡事實上使用事件監聽較好
                    while (fn = mod.onload.shift()) {
                        fn(mod.export);
                    }
                } else {
                    callback && callback.apply(window, params);
                }
            };

            window.require = define;
            window.define = define;

        })();
    </script>
</head>
<body>
</body>
</html>

一個例子

寫一個MVC的小例子,程式碼簡單, 高手無視,  目錄結構如下:

我們把所有的事件放到了controller/mainController.js裡面,

define(["model/data","view/view0"],function(data, view) {
    var init = function() {
        var body = document.getElementsByTagName("body")[0];
        var aBtn = document.getElementsByTagName("button");
        for(var i=0; i< aBtn.length; i++) {
            aBtn[i].onclick = (function(i) {
                return function() {
                    body.appendChild( view.getView(data[i]) );
                };
            })(i);
        };
    };
    return {
        init : init
    };
});

把所有的資料放到了model/data.js裡面;

define(function() {
    return [
        {name : "qihao"},
        {name : "nono"},
        {name : "hehe"},
        {name : "gege"}
    ];
})

檢視的JS放到了view的目錄下,view0.js主要負責生成HTML字串或者DOM節點;

define(function() {
    return {
        getView : function(data) {
            var frag = document.createDocumentFragment();
                frag.appendChild( document.createTextNode( data.name + " ") );
            return frag;
        }
    }
});

入口是app.js,他和load.html是同級目錄:

require(["controller/mainController"],function( controller ) {
    controller.init();
});

load.html這個是主介面:

<!DOCTYPE html>
<html>
<head lang="en">
    <meta charset="UTF-8">
    <title></title></head>
<body>
<button>0</button>
<button>1</button>
<button>2</button>
<button>3</button>
<script src="require.js" data-main="app.js"></script>
</body>
</html>

例子的原始碼下載地址: 下載

相關文章