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的“高級”考驗,這幾乎是一件不可能的事情。