近年来,前端变的越来越重,页面中的javascript代码量上升了一个量级,为了便于维护和团队协作,模块化是必经之路。针对javascript模块化,业界逐渐产出两种方案AMD和CMD,它们有什么区别呢?看看大牛玉帛在知乎上的回答:http://www.zhihu.com/question/20342350/answer/14828786

其中最重要的区别是AMD是提前执行,CMD是延迟执行。

//by foio.github.io
//CMD 推崇依赖就近,代表为seajs
define(function(require,exports,module){
    var a = require('./a'); 
    a.doSomething();
    var b = require('./b');
    b.doSomething();
})

//AMD 推崇依赖前置,代表为requireJs
//by foio.github.io
define(['./a','./b'],function(a,b){
    a.doSomething();    
    b.doSomething();
});

本篇文章我们实现一个自己的requreJS,我并不打算在源码中处理浏览器兼容性等细节,过于关注细节,反而会影响我们从宏观上理解代码机构。本文的目的是实现一个基本可用的javascript模块加载系统,带领读者理解要实现一个模块加载系统需要的知识结构。了解几种在网页中异步加载javascript的方案(可以参考我的另一篇博客),对理解本文有很大帮助。我提出如下两个基本问题:

(1).RequireJS是如何异步加载js模块
(2).RequireJS是如何处理模块间的依赖关系的(包括如何处理循环依赖)

接下来我就来分别分析这两个问题。在开始之前,我们先看一下本文中javascript代码的层次结构。

require structure

从图中可以看出,模块加载系统的require和define的实现,主要借助于几个子函数(矩形表示)和两个基本数据结构(椭圆形表示),其中蓝色矩形表示存在递归调用。下文主要围绕着这个结构图展开,阅读过程中请随时参阅此图。


1. RequireJS是如何异步加载js模块

如果你看过这篇专门讲解如何异步加载javascript模块的文章,就会发现RequireJS用的就是其中的script dom element的方法。下面是具体实现的伪代码。

//by foio.github.io
//foioRequireJS的js加载函数 
foioRequireJS.loadJS = function(url,callback){
    //创建script节点
    var node = document.createElement("script");
    node.type="text/javascript";
    //监听脚本加载完成事件,针对符合W3C标准的浏览器监听onload事件即可
    node.onload = function(){
        if(callback){
            callback();
        }
    };
    //监听onerror事件处理javascript加载失败的情况
    node.onerror = function(){
        throw Error('load script:'+url+" failed!"); 
    }
    node.src=url;
    //插入到head中
    var head = document.getElementsByTagName("head")[0];
    head.appendChild(node);
}

2. RequireJS如何按顺序加载模块

用过RequireJS都知道,它主要就是两个函数require和define。其中define用于定义模块,require用于执行模块。

//by foio.github.io
//c.js
define(id,['a','b'],function(a,b){
    return function(){
        a.doSomething();    
        b.doSomething();    
        //doSomething();
    }   
});

//logic.js
require(id,['c'],function(c){
    return function(){
        c.doSomething();
        //doSomething();
    }   
});

要保证javascript模块的执行顺序,首先必须组织好依赖关系。

(1)组织依赖关系

为了组织RquireJS需要哪些数据结构呢?看起来无从下手,我们可以对问题进行拆分。

[1].模块放在哪里,如何标记模块的加载状态?

moudules存储了所有已经开始加载的模块,包括加载状态信息、依赖模块信息、模块的回调函数、以及回调函数callback返回的结果。

//by foio.github.io
modules = {
    ...
    id:{
        state: 1,//模块的加载状态    
        deps:[],//模块的依赖关系
        factory: callback,//模块的回调函数
        exportds: {},//本模块回调函数callback的返回结果,供依赖于该模块的其他模块使用
    }   
    ... 
}

[2].正在加载但是还没有加载完成的模块id列表

每个脚本加载完成事件onload触发时,都需要检查loading队列,确认哪些模块的依赖已经加载完成,是否可以执行

//by foio.github.io
loadings = [
    ...
    id,
    ...
]

(2). define函数的基本实现

再次强调,本文的目的是理解结构,而不是具体实现,因此代码中不会考虑浏览器兼容性,也不会考虑逻辑的完整性。define函数主要目的 是将模块注册到factorys列表中,方便require可以找到。同时必须处理循环依赖问题。

//by foio.github.io
foioRequireJS.define = function(deps, callback){
    //根据模块名获取模块的url
    var id = foioRequireJS.getCurrentJs();
    //将依赖中的name转换为id,id其实是模块javascript文件的全路径
    var depsId = []; 
    deps.map(function(name){
        depsId.push(foioRequireJS.getScriptId(name));
    });
    //如果模块没有注册,就将模块加入modules列表
    if(!modules[id]){
            modules[id] = {
                id: id, 
                state: 1,//模块的加载状态   
                deps:depsId,//模块的依赖关系
                callback: callback,//模块的回调函数
                exports: null,//本模块回调函数callback的返回结果,供依赖于该模块的其他模块使用
                color: 0,
            };
    }
};

(3). require函数的基本实现

require函数的实现是相当复杂的,我们先确立程序的基本框架,再逐步深入到具体细节。 其实require函数主要的逻辑就是将main模块放入modules和loadings队列。然后就开始调用loadDepsModule加载main模块的依赖模块。 下面我们来看loadDepsModule的实现。

//by foio.github.io
foioRequireJS.require = function(deps,callback){
    //获取主模块的id
    id = foioRequireJS.getCurrentJs();

    //将主模块main注册到modules中
    if(!modules[id]){

        //将主模块main依赖中的name转换为id,id其实是模块的对应javascript文件的全路径
        var depsId = []; 
        deps.map(function(name){
            depsId.push(foioRequireJS.getScriptId(name));
        });

        //将主模块main注册到modules列表中
        modules[id]  = {
            id: id, 
            state: 1,//模块的加载状态   
            deps:depsId,//模块的依赖关系
            callback: callback,//模块的回调函数
            exports: null,//本模块回调函数callback的返回结果,供依赖于该模块的其他模块使用
            color:0,
        };
        //这里为main入口函数,需要将它的id也加入loadings列表,以便触发回调
        loadings.unshift(id);                       
    }
    //加载依赖模块
    foioRequireJS.loadDepsModule(id);
}

可以说loadDepsModule是模块加载系统中最重要的函数了。 loadDepsModule函数主要是递归的加载一个模块的依赖模块,通过loadJS在dom结构中插入script元素来完成js文件的载入和执行。这里loadJS的callback函数也很值得研究。 每一个模块都是通过define函数定义的,由于callback函数在模块加载完成后才会执行,所以callback函数执行时模块已经存在于modules中了。相应的,我们也要将该模块放入loadings队列以便检查执行情况;同时递归的调用 loadDepsModule加载该模块的依赖模块。loadJS的在浏览器的onload事件触发时执行,这是整个模块加载系统的驱动力。

//by foio.github.io
foioRequireJS.loadDepsModule = function(id){
    //依次处理本模块的依赖关系
    modules[id].deps.map(function(el){
        //如果模块还没开始加载,则加载模块所在的js文件
        if(!modules[el]){
            foioRequireJS.loadJS(el,function(){
                //模块开始加载时,放入加载队列,以便检测加载情况
                loadings.unshift(el);                       
                //递归的调用loadModule函数加载依赖模块
                foioRequireJS.loadDepsModule(el);
                //加载完成后执行依赖检查,如果依赖全部加载完成就执行callback函数
                foioRequireJS.checkDeps();  
            });
        }
    });
}   

下面我们再来分析一下checkDeps函数,该函数在每次onload事件触发时执行,检查模块列表中是否已经有满足执行条件的模块,然后开始执行。checkDeps也有一个小技巧,就是当存在满足执行条件的模块时会触发一次递归,因为该模块执行完成后,可能使得依赖于该模块的其他模块也满足了执行条件。

//检测模块的依赖关系是否处理完毕,该函数在每一次js的onload事件都会触发一次
foioRequireJS.checkDeps = function(){
    //遍历加载列表
    for(var i = loadings.length, id; id = loadings[--i];){
        var obj = modules[id], deps = obj.deps, allloaded = true;                                   
        //遍历每一个模块的加载
        foioRequireJS.checkCycle(deps,id,colorbase++);
        for(var key in deps){
            //如果存在未加载完的模块,则退出内层循环
            if(!modules[deps[key]] || modules[deps[key]].state !== 2){
                allloaded = false;
                break;
            }
        }

        //如果所有模块已经加载完成
        if(allloaded){
            loadings.splice(i,1); //从loadings列表中移除已经加载完成的模块                          
            //执行模块的callback函数
            foioRequireJS.fireFactory(obj.id, obj.deps, obj.callback);
            //该模块执行完成后可能使其他模块也满足执行条件了,继续检查,直到没有模块满足allloaded条件
            foioRequireJS.checkDeps();
        }
    }       
}   

最后我们分析一下,具体的执行函数fireFactory。我们知道,无论是require函数还是define函数,都有一个参数列表,fireFactory首先处理的问题就是收集各个依赖模块的返回值,构建callback函数的参数列表;然后调用callback函数,同时记录模块的返回值,以便其他依赖于该模块的模块作为参数使用。

//fireFactory的工作是从各个依赖模块收集返回值,然后调用该模块的后调函数
foioRequireJS.fireFactory = function(id,deps,callback){
    var params = [];
    //遍历id模块的依赖,为calllback准备参数
    for (var i = 0, d; d = deps[i++];) {
        params.push(modules[d].exports);
    };
    //在context对象上调用callback方法
    var ret = callback.apply(global,params);    
    //记录模块的返回结果,本模块的返回结果可能作为依赖该模块的其他模块的回调函数的参数
    if(ret != void 0){
        modules[id].exports = ret;
    }
    modules[id].state = 2; //标志模块已经加载并执行完成
    return ret;
}

这些内容是我用将近一周的业余时间的研究心得,希望对你有帮助。当然,这些代码都只是javascript加载系统的基本框架,如果有考虑疏忽的地方,还请你指正。文中的代码只是片段,完整的代码在我的github:https://github.com/foio/MyRequireJS

如果你认真的读到这里,我有理由相信你是一个对技术有追求的人,我和你是同类人。 本文是禁止转载的,希望我们互相尊重大家的劳动成果。

前端工程师都知道script标签会阻塞网页上其他资源的加载。有时候这种阻塞是必要的,因为javascript可能会改变页面结构,进而对后续的资源(css,js)的作用产生影响。但是,当我们能够识别那些对页面结构不产生影响的javascript并且不希望阻塞其他资源时,我们就需要认真的研究一下,javascript异步加载的方式了。


1. 并行的下载脚本

(1) XHR eval

通过XHR技术我们也可以异步地获取js脚本,并通过eval()执行。

var xhrobj = getXHROject();
getXHROject.onreadystatechange = function(){
    if(xhrObj.readyState ==4 && 200 == xhrObj.status){
      eval(xhrObj.responseText);
  }
};
xhrObj.open(GET,A.js,true)
xhrOjb.send(‘’);

由于XHR请求不能跨域,所以脚本必须和主页部署在相同的域中,脚本可并行下载,而且不阻塞其他资源,但是无法保证多个脚本的执行顺序。

(2) script dom element

我们也可以直接在浏览器中插入script dom节点。

var scriptElem = document.createElement(script);
document.getElementsByTagName(head)[0].appendChild(scriptElem);

这种方式允许跨域加载js脚本,不阻塞其他资源下载。只有 Firefox 和 Opera 保证脚本按文档中出现的顺序执行,其他浏览器需要工程师自己在代码层面实现执行顺序的控制。Requirjs就是这样实现的。

(3) document write script tag

document.write("<script type="text/javascript" src='A.js'></script>");

注意script Tag和script dom的区别,scritp Tag可以保证多个脚本并行加载,但是会阻塞其他资源并行下载。这种方式可以保证脚本按文档中出现的顺序执行

(4) defer和async属性

目前大多数浏览器已经defer和async属性。

1. 如果 async="async":脚本相对于页面的其余部分异步地执行(当页面继续进行解析时,脚本将被执行)
2. 如果不使用 async 且 defer="defer":脚本将在页面完成解析时执行
3. 如果既不使用 async 也不使用 defer:在浏览器继续解析页面之前,立即读取并执行脚本

async="async"不会阻塞其他资源,但是无法保证脚本的执行顺序。defer="defer"阻塞其他资源的加载,并且可以保证脚本的执行顺序,但是要到页面解析完成后才开始执行脚本。

这么多种异步加载javascript脚本的方式,各有利弊,接下来研究一下如何控制脚本之间执行的顺序。

2.脚本的执行顺序

(1)保证行内脚本和外部脚本的执行顺序

当外部脚本按常规方式加载时,它会阻塞行内脚本的执行,可以保证顺序。但是脚本通过上述的几种方式异步加载时,就无法保证行内脚本和异步脚本之间的顺序。下面就讲解一下保证行内脚本和外部脚本保证执行顺序的技术。

[1].硬编码回调

如果web开发者能够控制外部脚本,可以在外部脚本回调行内脚本。

[2]onlode事件

添加script dom节点时,监听加载事件,当脚本成功加载时调用callback(外部脚本)函数。

//行内函数
function callback(){
    Console.log(calllback);
}

//异步加载函数
function loadScript(url, callback){
    var script = document.createElement ("script")
    script.type = "text/javascript";
    if (script.readyState){ //IE
        script.onreadystatechange = function(){
            if (script.readyState == "loaded" || script.readyState == "complete"){
                script.onreadystatechange = null;
                callback();
            }
        };
    } else { //Others
        script.onload = function(){
            callback();
        };
    }
    script.src = url;
    document.getElementsByTagName("head")[0].appendChild(script);
}

//控制行内脚本和外部脚本的执行顺序
loadScript('a.js',callback);
</script>
[3]定时器

通过定时检查外部脚本的相应变量是否定义,可以判断外部脚本是否加载并执行成功。

<script src="MyJs.js"></script>
<script>
function callback(){
}

function checkMyJs(){
    if(undefined===typeof(MyJs)){
        setTimeout(checkMyJs, 300)
    }else{
        callback();
    }
}
</script>

这三种方法都可以保证行内脚本和外部脚本之间的执行顺序。其实最难的是保证多个外部脚本之间的执行顺序,这也是我们接下来要看的内容。

(2)保证多个外部脚本之间的执行顺序

[1]. 同域中的脚本

对于同域中的多个外部脚本,可以使用XHR的方式加载脚本,并通过一个队列来控制脚本的执行顺序。

<script>
    ScriptLoader.Script = {
        //脚本队列
        queueScripts = [];
        loadScriptXhrInjection: function(url,onload,bOrder){
            var iQ = ScriptLoader.Script.queueScripts.length;
            if(bOrder){
                var qScript = {response: null, onload: onload, done: false}; 
                ScriptLoader.Script.queueScripts[iQ] = qScript;
            }
            var xhrObj = ScriptLoader.Script.getXHROject();
            xhrObj.onreadystatechange = function(){
                if(xhrObj.readyState == 4){
                    //有顺序要求的脚本需要添加的队列,按添加顺序执行
                    if(bOrder){
                        //有顺序要求的脚本需要设置加载和执行状态
                        ScriptLoader.Script.queueScripts[iQ].response = xhrObj.responseText;
                        //执行脚本队列
                        ScriptLoader.Script.injectScripts();
                    }else{//没有顺序要求的脚本可直接执行
                        eval(xhrObj.responseText);
                        if(onload){
                            onload();
                        }
                    }
                }
            }
        }


        injectScripts: function(){
            var len = ScriptLoader.Script.queueScripts.length;
            //按顺序执行队列中的脚本
            for (var i = 0; i < len; i++) {
                var qScript = ScriptLoader.Script.queueScripts[i];
                //没有执行
                if(!qScript.done){
                    //没有加载完成
                    if(!qScript.response){
                        //停止,等待加载完成, 由于脚本是按顺序添加到队列的,因此这里保证了脚本的执行顺序
                        break;
                    }else{//已经加载完成了
                        eval(qScript.response);
                        if(qScript.onload){
                            qScript.onload(); 
                        }
                        qScript.done = true;
                    }
                }
            };
        },

        getXHROject: function(){
            //创建XMLHttpRequest对象
        }
    }

    ScriptLoader.Script.loadScriptXhrInjection('A.js',null,false);
    ScriptLoader.Script.loadScriptXhrInjection('B.js',InitB,true);
    ScriptLoader.Script.loadScriptXhrInjection('C.js',InitC,true);
</script>
[2]对于不同域的脚本

script dom element 可以异步脚本脚本,不阻塞其他资源,并且在firefox和opera可以保证执行顺序;而document write script 可以异步加载脚本,会阻塞其他资源,在所有浏览器都可以保证执行顺序。因此我们可以根据浏览器选择以上两种方案来控制 不同域的脚本的执行顺序。

<script>
    ScriptLoader.script{
       loadScriptDomElement:function(url, onload){
            var script = document.createElement ("script")
            script.type = "text/javascript";
            if (script.readyState){ //IE
                script.onreadystatechange = function(){
                    if (script.readyState == "loaded" || script.readyState == "complete"){
                        script.onreadystatechange = null;
                        onload();
                    }
                };
            } else { //Others
                script.onload = function(){
                    onload();
                };
            }
            script.src = url;
            document.getElementsByTagName("head")[0].appendChild(script);
        }    

        loadScriptDomWrite: function(url,onload){
            document.write('<script  src="'+url+'" type="text/javascript"></script>');  
            if(onload){
                if(elem.addEventListener){//others
                    elem.addEventListener(window,'load',onload);
                }else if(elem.attachEvent){ //IE
                    elem.addEventListener(window,'onload',onload);
                }
            }
        } 

        //根据浏览器选择浏览器加载js的方式
        loadScript: function(url,onload){
                if(-1 != navigator.userAgent.idexOf('Firefox') ||
                   -1 != navigator.userAgent.indexOf('Opera')){
                    //当浏览器为firefox和opera时通过Script Dom Element 保证脚本执行顺序
                        DomTag.script.loadScriptDomElement(url,onload); 
                }else{
                    //当为其他浏览器时,通过document write Script保证脚本执行顺序。此时脚本的加载会阻塞其他资源,这是一种折衷
                        DomTag.script.loadScriptDomWrite(url,onload);
                }
        }  
}
ScriptLoader.script.loadScript('A.js',initA);
ScriptLoader.script.loadScript('B.js',initB);
</script>

夜深了,城市的灯光污染,我无法看到今夜的月。