jQuery小文件合并生成大文件过程
负责合并小文件到一个大文件的代码位于build/tasks/build.js中。build.js主要做下面三件事:
- 判断要合并那些模块
- 根据依赖关系,确定各模块小文件的合并先后顺序
- 对每个小文件内部的代码进行调整
除了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