Hello World
Spiga

使用Google Closure Compiler全力压缩代码

2011-04-07 09:30 by 老赵, 16372 visits

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的“高级”考验,这几乎是一件不可能的事情。

广告时间:第四届nBazaar技术交流会将于2011年4月23日举行。根据部分用户反馈,第四届交流会的形式将略作改变:除了三场演讲之外,本次活动设有嘉宾互动环节,您将有机会和嘉宾就某些话题进行探讨。我们将在会前收集部分话题,也希望大家踊跃提问,具体方式详见 http://nbazaar.org/

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

19 条回复

  1. ......
    116.228.220.*
    链接

    ...... 2011-04-07 09:44:32

    一般情况下都是合并。。。

  2. 老赵
    admin
    链接

    老赵 2011-04-07 09:45:44

    @......

    合并跟压缩又不矛盾。

  3. ......
    116.228.220.*
    链接

    ...... 2011-04-07 09:55:54

    @老赵

    嗯,不矛盾,也尝试过各种压缩,用JQUERY框架写的JS;也许是前期没有考虑到,写的JS代码不是很规范。导致最后的失败,不能运行;执行起来不容易;

    nBazaar技术交流会,一定会去的。

  4. leeight的马甲
    175.41.162.*
    链接

    leeight的马甲 2011-04-07 09:58:38

    可以添加@export这个annonation,然后添加命令行参数--generate_exports,就可以保留你需要的哪些变量名。

  5. 链接

    jee.chang.sh 2011-04-07 09:58:46

    我不知道为何老赵如此纠结于这点大小,一张图片的压缩与否,一次http请求的优化都优胜于这点大小。尤其带着风险做事,我实在是不大愿意冒此险。如果老赵那边的项目已经优化到了没地方可以优化了。那我只能说老赵你太精益求精了

  6. avlee
    119.62.12.*
    链接

    avlee 2011-04-07 09:58:49

    UglifyJS确实有很多独到之处,不过Google Closure Compiler很快就吸纳了一些,比如true,false变为!0,!1等,就我测试大部分压缩率差距都来源于此。

  7. avlee
    119.62.12.*
    链接

    avlee 2011-04-07 10:19:33

    @jee.chang.sh

    对于JQuery、Jscex来说,图片压缩与否、http请求优化、缓存神马的,都已经是另外的事情了。

  8. 老赵
    admin
    链接

    老赵 2011-04-07 10:39:28

    @jee.chang.sh

    哪儿有风险了,我说的就是从头开始准备的话就不会有风险。如果是普通的压缩,Simple模式也足够了,我又没让你为了Advance模式重写代码。

  9. 老赵
    admin
    链接

    老赵 2011-04-07 10:40:08

    @leeight的马甲: 可以添加@export这个annonation,然后添加命令行参数--generate_exports,就可以保留你需要的哪些变量名。

    我想起来我试过了,但是不行。比如这样:

    /**
     * @export
     */
    var Jscex = (function () {
        /**
         * @constructor
         */
        var CodeGenerator = function () {
            /**
             * @export
             */
            this.normalMode = false;
        }
        /**
         * @export
         */
        CodeGenerator.prototype.generate = function () {
            alert(this.normalMode);
        }
    
        function compile() { return new CodeGenerator(); };
    
        return { compile: compile };
    
    })();
    

    首先除了Jscex的都不能加@export(只能加在Global对象上),其次这样生成的代码是:

    var b=function(){...};goog.a("Jscex",b);
    
  10. 链接

    延伟 2011-04-07 13:05:47

    一站式登陆不错!

  11. czy1121
    58.253.107.*
    链接

    czy1121 2011-04-08 23:05:13

    如果让JS能加载二进制模块,而JQUERY等稳定的产品编译成二进制是不是能更小,更快?

  12. 老赵
    admin
    链接

    老赵 2011-04-09 20:26:16

    @czy1121

    这是一定的,只可惜这种方式是没法推广的。

  13. 链接

    管宇 2011-04-29 09:03:29

    曾经在使用 Closure Compiler 简单压缩。

    现在写js少了。

  14. xmchyabi
    222.76.154.*
    链接

    xmchyabi 2011-08-02 12:43:38

    遇到一个问题 如果使用jscex.async.min.js,会有脚本错误:Uncaught TypeError: Object # has no method 'Delay' 换成未压缩的版本就没问题。

  15. 老赵
    admin
    链接

    老赵 2011-08-02 14:52:55

    @xmchyabi

    现在压缩版本的问题比较多,暂缓一下?如果实在需要的话,可以用安全模式自己压一下,或者及时通知我。

  16. williamxhero
    124.74.27.*
    链接

    williamxhero 2011-09-16 16:40:43

    既然叫 compiler, 而不叫 compressor,说明他面向的是发布,只要输入输出不变,就没有任何问题。如果要发布库的话,compiler显然不合适。

  17. 老赵
    admin
    链接

    老赵 2011-11-13 17:02:29

    @williamxhero

    没听懂你是什么意思。

  18. 宅小歪
    140.224.78.*
    链接

    宅小歪 2014-05-27 00:54:25

    我用.net封装了个自动压缩合并的基于GCC的工具,欢迎老赵围观小下 http://www.zhaixiaowai.com/JSCompress/

  19. Allen
    50.158.56.*
    链接

    Allen 2014-08-20 11:18:03

    Mark!!!!

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我