17 February 2014

负责合并小文件到一个大文件的代码位于build/tasks/build.js中。build.js主要做下面三件事:

  1. 判断要合并那些模块
  2. 根据依赖关系,确定各模块小文件的合并先后顺序
  3. 对每个小文件内部的代码进行调整

除了core和selector必须合并外,其他模块都可以剔除或顺带剔除(声明在Gruntfile.js的build任务配置中)。要剔除一个模块,比如sizzle和ajax/xhr,直接运行grunt custom:-sizzle,-ajax/xhr即可。关于jQuery的定制,可以参考jQuery Github主页关于Modules的说明。

依赖关系和顺序由requirejs的打包工具r.js计算,r.js同时会给小文件的define调用函数添加模块Id作为第一个参数。

在第3步中,build.js会把每个小文件的define函数调用的wrapper给删掉,只保留define最后一个参数factory函数的函数体,以便合并之后的大文件能脱离requirejs独立运行。不同的模块的有不同的具体处理方法。下面是小文件处理策略(convert函数):

1. 路径中包含var的小文件

比如,var/arr.js文件,

define(function() {
    return [];
});

处理之后就变成:

var arr = [];

就是把return后面的内容(空数组[])赋值给以模块ID(var/arr)中var/之后字符串为名的变量(arr)。 这样arr变量对于所有依赖他的模块的factory代码来说,都是可见的了。jQuery的依赖var/xxx的小模块的define声明方式也很独特,以var/slice为例,它依赖var/arr:

define([
    "./arr"
    ], function( arr ) {
        return arr.slice;
});

var/arr模块的factory函数的依赖变量名是arr,正好是处理之后声明的变量名,等同于路径中var/之后的部分。合并后的大文件dist/jquery.js中头部有一坨var变量声明语句,都是这种方法处理生成的,每一个var xxx声明对应一个var/xxx模块。如下:

var arr = [];

var slice = arr.slice;

var concat = arr.concat;

var push = arr.push;

var indexOf = arr.indexOf;

var class2type = {};

var toString = class2type.toString;

var hasOwn = class2type.hasOwnProperty;

var trim = "".trim;

var support = {};

2. sizzle.js处理方法

sizzle.js中代码大致如下

(function(window){
    // sizzle source here
    // EXPOSE
    if ( typeof define === "function" && define.amd ) {
        define(function() { return Sizzle; });
        // Sizzle requires that there be a global window in Common-JS like environments
    } else if ( typeof module !== "undefined" && module.exports ) {
        module.exports = Sizzle;
    } else {
        window.Sizzle = Sizzle;
    }
    // EXPOSE
})(window);

处理之后的代码变成:

var Sizzle = (function(window){
    //sizzle source here
    return Sizzle;
})(window);

处理方法就是把闭包的返回值赋给Sizzle变量,并且把EXPOSE之间的内容用return Sizzle;替换掉。

其实sizzle小模块这样组织代码,即支持脱离requirejs单独运行,又可以方便地融入到jQuery模块化体系中,同时还很方便合并到大文件中。

3. 其他非jquery.js小文件的处理方法

删除末尾的return语句和exports.xxx=yyy语句,去掉开头和结尾的define warpper。例如ajax/parseJSON小文件:

define([
    "../core"
    ], function( jQuery ) {

        // Support: Android 2.3
        // Workaround failure to string-cast null input
        jQuery.parseJSON = function( data ) {
                return JSON.parse( data + "" );
        };

        return jQuery.parseJSON;

});

上面是源文件,第2步处理的过程中会添加模块id作为define的第一个参数,如下:

define('ajax/parseJSON',[
    "../core"
    ], function( jQuery ) {

        // Support: Android 2.3
        // Workaround failure to string-cast null input
        jQuery.parseJSON = function( data ) {
                return JSON.parse( data + "" );
        };

        return jQuery.parseJSON;

});

第3步删除最后的return jQuery.parseJSON;语句,删除开头的define和末尾的}),如下:

// Support: Android 2.3
// Workaround failure to string-cast null input
jQuery.parseJSON = function( data ) {
        return JSON.parse( data + "" );
};

以上就是ajax/parseJSON小文件最终合并大大文件jQuery.js中的内容。

4. 入口文件jQuery.js的处理策略

jQuery.js文件作为入口模块,它依赖其他所有的模块,这样r.js就会把所有的小模块按顺序给组装在一起。前面提到了,可以剔除大部分的模块。这里就有一个玄机:被剔除的模块没有合并在大文件中,但是jQuery.js这个小模块依赖被剔除的模块。这样在浏览器端运行到jQuery.js这个小模块的时候,由于依赖的模块找不到,requirejs不就会报错吗?即便删除define wrapper,不需要requirejs了,但是在执行入口模块jQuery的factory代码的时候,如果引用了被踢出的模块exports的内容,运行时不就会出错吗?

这确实是个问题。看一下jQuery.js小模块的代码:

define([
    "./core",
    "./selector",
    "./traversing",
    "./callbacks",
    "./deferred",
    "./core/ready",
    "./data",
    "./queue",
    "./queue/delay",
    "./attributes",
    "./event",
    "./event/alias",
    "./manipulation",
    "./manipulation/_evalUrl",
    "./wrap",
    "./css",
    "./css/hiddenVisibleSelectors",
    "./serialize",
    "./ajax",
    "./ajax/xhr",
    "./ajax/script",
    "./ajax/jsonp",
    "./ajax/load",
    "./effects",
    "./effects/animatedSelector",
    "./offset",
    "./dimensions",
    "./deprecated",
    "./exports/amd",
    "./exports/global"
], function( jQuery ) {

return jQuery;

});

对于这个小模块,第2步中r.js只会给它加上模块ID,然后就进行第3步的处理,build.js对于jQuery.js小模块的处理方法是:直接删除前后的define wrapper。最后和合并到大文件时,这个模块就变成了:


return jQuery;

所以合并后的大文件中的最后一条语句就是上面的return jQuery; 由于这只是一个空的return语句,并没有引用它所声明的依赖模块对外所exports的任何变量。所以运行大文件到这一步时并不会报错。

总结

以上就是主要的合并过程了。可以看出,jquery的合并逻辑有很大的技巧性,这与模块的拆分方式有关。模块拆分直接导致合并逻辑复杂了。但好处也是明显的:各个模块可以单独加载运行,单独进行单元测试,同时支持根据不同的需求进行模块的删减定制。能实现这些益处,靠的是对模块内代码和文件目录的合理组织, 而代码和文件/目录的组织,正是前端工程的本质所在。

当然jQuery.js的模块拆分方案并不算完美,比如src目录下既有data.js又有data目录,按常理,data.js应该放在data目录里面,还有var目录,这个纯粹是为了方便合并才这样组织文件。

以上是以jQuery 2.1.0为例进行的分析,jQuery 1.x的合并逻辑与2.x的类似,只是小模块的内容有所不同。

补充

晚上和寒星在麦当劳吃饭的时候,讨论了下jquery小文件合并逻辑的不利之处:直接限制了代码和目录的组织方式,特别是ID中包含"var/"的模块(只能放在var目录下面),而且依赖这些模块的模块的factory函数的参数名称只能是这些模块ID中"var/"之后的部分。他提供的解决方案是,把小文件的factory函数都放在一个闭包里面立即执行,闭包的返回值赋给一个key为模块ID的公共变量hash中,立即执行函数传入的参数为key为依赖模块ID的公共变量hash的值。比如还是以刚才的模块var/arr和var/slice为例:

//模块var/arr小文件代码
define(function() {
        return [];
});

//模块var/slice小文件代码,依赖模块var/arr
define([
    "./arr"
    ], function( arr ) {
        return arr.slice;
});

// -------------------分割线---------------------//

/* build/tasks/build.js的合并逻辑:
    把return 的内容赋值给模块ID中"var/"之后的名称
*/
var arr = []; //模块var/arr
var slice = arr.slice; //模块var/slice

//--------------------分割线---------------------//

/* 寒星建议的合并逻辑:
    把factory函数传入参数立即执行,并赋给外部公共变量
*/

var MODULES = {}; //只需要声明一次,在所有其他小模块之前

//var/arr小文件被合并之后的代码
MODULES["var/arr"] = (function(){
    return []   ·
})();//没有依赖其他模块,不传入参数

//var/slice小文件被合并之后的代码,依赖模块var/arr
MODULES["var/slice"] = (function(arr){
    return arr.slice;
})(MODULES["var/arr"]);//所依赖的模块依次通过MODULES[依赖的模块ID]传入

这种方法确实能免去var目录和factory函数参数名称的限制,而且能达到合并之后的大文件能脱离requirejs独立运行的目的。我的观点是:这样会造成合并之后的大文件dist/jquery.js代码阅读起来不自然!大文件中充斥着非jquery自身相关的逻辑!这些逻辑的作用就相当于被替换了的requirejs:模块加载器!

当然,寒星也理解我的观点。虽然jQuery提供了模块化的源代码供大家阅读,但是大部分人都是拿大文件来学习研究jQuery的!凡是未压缩混淆过的代码,都是面人的,要考虑阅读代码时的体验。



blog comments powered by Disqus