JavaScript压缩代码的重要性不言而喻,如今的压缩工具也有不少,例如YUI Compressor,Google Closure Compiler,以及现在比较红火的UglifyJS。UglifyJS的出名是由于它代替Closure Compiler成为jQuery项目的压缩工具。根据我的实测,jQuery Core的代码使用UglifyJS压缩后(节省62.5%)的确要比Closure Compiler压缩后(节省57.53%)更小一些。很显然,这是因为UglifyJS的压缩策略比Closure Compiler更“聪明”一些。我这里用了“聪明”而不是“激进”,是因为“激进”带上了一丝负面的意味——就好比Closure Compiler的“高级”优化方式。之前与UglifyJS相比的是Closure Compiler的“简单”优化方式,它们都是“安全”的,而Closure Compiler的“高级”优化几乎100%会破坏您的代码,因此它提出了各种“激进”的手段去“破坏”您的代码,以此达到压缩的目的。这种手段是把双刃剑,如果您能掌控它的压缩规则,则代码便可以压缩至极小。
我们先来看看的Closure Compiler的威力。例如我有这样一段代码:
var Jscex = (function () { /** * @constructor */ var CodeGenerator = function () { this.normalMode = false; } CodeGenerator.prototype.generate = function () { alert("Hello World"); } function compile() { return new CodeGenerator(); }; return { compile: compile }; })();
猜猜看,如果使用Closure Compiler的高级优化方式来压缩代码,会是什么情况呢?结果如下:
(function(){function a(){this.a=!1}return{compile:function(){return new a}}})();
目标代码很短,硬着头皮看看也无妨。首先,Jscex对象消失了,因为Closure Compiler认为其他地方并没有使用这个对象。其次,CodeGenerator的normalMode字段也被改名为a,因为这个名字更省空间。最后,generate方法也不见了,理由同第一项。您瞅瞅,这样的代码还能执行吗?这就是Closure Compiler激进的地方,它把输入文件作为一个完整的单元,并不会考虑对外的“接口”是否会变化。我读了Closure Compiler的文档,发现了它支持对源代码做标记。但是经过实验,这些标记似乎并不能影响编译后的结果,只是让编译器工作时多做一些“静态检查”。
当然,理论上说Closure Compiler提供了保持成员名称的机制,例如exports和extern。假设我要保持之前的Jscex对象,那么就必须这么做:
window["Jscex"] = (function () { ... })();
这样Closure Compiler生成的代码就会变成:
window.Jscex=(function(){ ... })();
为了“节省”空间,它把“索引”的访问方式又切换回“字段”的访问方式,何等蛋疼!此外,原本我以为Closure Compiler强迫我依赖浏览器环境,后来发现其实window也可以替换为其他名称,例如:
my_root["Jscex"] = (function () { /** * @constructor */ var CodeGenerator = function () { this["normalMode"] = false; } CodeGenerator.prototype["generate"] = function () { alert("Hello World"); } function compile() { return new CodeGenerator(); }; return { compile: compile }; })();
虽然从理论上说,使用这种方式可以告诉Closure Compiler哪些成员名称是可以压缩的而哪些不行,但我真心难以接受这种四处使用“索引”的写法。不过,其实这对我造成的影响其实不大,因为我很少使用那种“面向对象”的方式来对外公开接口,我一般也就是用“对象”加上“方法”的形式,例如上面的Jscex.compile方法,至于内部类型,如CodeGenerator,就随Closure Compiler压缩去吧。
话又说回来,其实如果您是从头开始编写JavaScript代码,并且遵守一定规则,那么Closure Compiler的确可以把您的代码压缩地很小。甚至您可以多写一点调试用的代码,但在最终压缩后的代码中去掉它们。这里最基本的原则可以归纳为:将压缩后不需要的代码抽取为独立的方法,然后在预处理阶段去除这些方法的调用代码,于是Closure Compiler便会将这些方法的定义一并删除,节省了相当多的空间。
以Jscex项目为例加以说明:Jscex的核心之一是根据AST生成JavaScript代码,在“调试”版本的实现中,我希望生成的代码能够美观、易读;而在“发布”版本中,我希望需要代码的体积越少越好。于是,对于某个表达式“是否需要添加括号”这样的场景,便需要详细斟酌了。我的策略是,在“调试”代码中,将判断是否需要增加括号的逻辑放置到needBracket方法中,然后编写这样的代码:
"dot": function (ast) { function needBracket() { /* ... */ } var nb = needBracket(); if (nb) { this._write("(") ._visit(ast[1]) ._write(").") ._write(ast[2]); } else { this._visit(ast[1]) ._write(".") ._write(ast[2]); } },
上面这个方法的作用是生成一个dot表达式的代码,其中定义了needBracket方法,我们可以在其中放置复杂而低效的逻辑,用来判断dot的左侧表达式是否需要添加括号。如果needBracket返回true,则生成括号,例如("abc" + "def").length;否则,便会生成更为简洁易读的代码,例如Jscex.Async.start,而不会是((Jscex).Async).start。但是在最终“发布”版本的代码中,nb变量被直接设置为true,于是Closure Compiler则会发现if的一个分支永远不会执行,则将其完全去除。在压缩后的代码中,以上方法只会是这样的:
dot:function(a){this.a("(").b(a[1]).a(").").a(a[2])},
可以看出,这段实现无论如何都会生成带括号的JavaScript代码,丑则丑矣,但对JavaScript引擎来说没有丝毫区别。目前jscex.js的压缩脚本其实是这样的:
# pre-processing for Closure Compiler sed -e 's/var Jscex =/my_temp_root["Jscex"] =/' -e 's/._writeLine(/._write(/g' -e 's/this._write();//g' -e 's/._write()//g' -e 's/this._writeIndents();//g' -e 's/._writeIndents()//g' -e 's/this._indentLevel = 0;//g' -e 's/this._indentLevel++;//g' -e 's/this._indentLevel--;//g' -e 's/checkBindArgs([^;]*;//g' -e 's/needBracket([^;]*;/true;/g' -e 's/throwUnsupportedError();//g' -e 's/_log([^;]*;//g' ../src/jscex.js > ../bin/jscex.tmp.js # use Closure Compiler to compress java -jar ../tools/compiler.jar --js ../bin/jscex.tmp.js --js_output_file ../bin/jscex.tmp.min.js --compilation_level ADVANCED_OPTIMIZATIONS # post-processing sed 's/my_temp_root.Jscex=/var Jscex=/' ../bin/jscex.tmp.min.js > ../bin/jscex.min.js # remove temp files rm ../bin/jscex.tmp*.js
我在使用Closure Compiler压缩代码之前,会先对脚本进行一下“预处理”,暂时为如下几项:
- 为了避免Jscex对象丢失,先将var Jscex替换成my_temp_root["Jscex"],压缩之后再将其替换回来。
- 将所有的writeLine方法调用替换成write,这样代码里便不会用到writeLine方法,Closure Compiler会去除该方法定义。
- 去除空的write方法调用,这一般是由writeLine替换为write而引起的。
- 去除与“缩进(indent)”相关的所有属性和方法,这样相关定义在压缩后也会一并消失。
- 去除各种错误检查,如checkBindArgs,throwUnsupportedError方法的调用。
- 去除日志输出,即_log方法调用,则_log方法本身也会消失不见。
- 将needBracket方法调用直接替换为true,强制输出带括号的代码。
使用这样的做法,我们可以充分利用Closure Compiler在“高级”优化级别下的激进压缩方式,同时得到正确、高效、体积还很小的代码(补充:后来发现其实某些情况下可以使用定义常量的方式来简化预处理的步骤)。就拿jscex.js和jQuery Core进行比较(事先都去除注释及空白字符):
- “简单”压缩的jQuery Core(安全压缩):减小30.83%体积(120.18KB => 83.13KB)。
- “高级”压缩的jQuery Core(非安全压缩,不可用):减小37.91%体积(120.18KB => 74.62KB)。
- “高级”压缩的Jscex.js(非安全压缩,可用):节省55.02%体积(12.14KB => 5.46KB)。
以上数据是从在线的Closure Compiler得出的结果,我也不知道为何效果不如本地明显。从本地压缩来看,jscex.js是25812字节,而jscex.min.js只有5585字节,有将近5倍的差距。
可惜的是,如果不是从代码编写及压缩一开始就考虑到Closure Compiler的诸多行为,我们只能使用“简单”的压缩方式来确保代码的正确性。如果要让一个现有的大段代码(例如jQuery)安全通过Closure Compiler的“高级”考验,这几乎是一件不可能的事情。