本文將詳細介紹 CC 的高級模式部分,更重要的是,闡述 CC 高級模式背後的思考' Y. T8 D; `- g: S2 N/ z
CC 是真正的編譯器
' ^7 H* e3 v; h4 c2 o; ~0 z# F Closure Compiler 和 YUICompressor 並不是同類產品,雖然 CC 和 YC 同樣產出壓縮後的 JS 文件,但是 YC 只做了詞法上的掃瞄,而 CC 並不只是一個 compressor 那麼簡單,器如其名,它是一個compiler。
/ _* D* z9 K# x' }; `4 L9 h 對於一個 compiler,一般地,它需要做到:
1 f( J! Y8 o4 _檢查源文本中語法、語義、語用上的錯誤;" r, T6 r$ ^: i
根據分析產出物(符號表、語法樹等)產出目標 / 中間代碼;
- W7 x* N# j O+ S. y* J優化。, k$ L/ R7 [( | {
代碼錯誤一般來自三個方面:
0 Y/ x! ^* n1 S7 D) i- d) \語法(Syntax)
& w& D5 }# t5 Y3 ]8 j9 x表示構成語言句子的各個記號之間的組合規律。大體上,parser / interpreter 在詞法分析和語法分析階段,產生符號表、語法樹等分析產出物,具體見編譯原理教科書……
$ A2 P9 Q$ ~2 P; w! }5 Z7 r: z語法上的錯誤,如:7 x8 A: v! x7 s5 D3 R
doSomething(;) // SyntaxError: Unexpected token ;
1 J3 w5 G; e7 ^6 I2 P0 r根據語法規則,在非 for 語句中的 ; 意義是分隔符,而分隔符前的 ( 並沒有配對),因此報錯。; v$ ^+ ]0 Q- _- e. |, p: S1 H
語義(Semantics)
7 @ W: Y8 J% k" R2 }表示各個記號的特定含義(各個記號和記號所表示的對象之間的關係)。compiler 需要根據語義分析產出中間代碼,對於不產生中間代碼的語言如 JS,則在運行時的解釋期間指出錯誤。8 w* A. N1 a" }7 Y
語義上的錯誤,如:
1 Z/ ^1 r9 s% ?/ h3 l. X3 ~/ u( s0 = {}; // ReferenceError: Invalid left-hand side in assignment0 t1 W; x3 |' I: k; \% V
根據賦值運算符 = 的意義,左操作數不能為字面量,所以雖然這個賦值語句包含了必需的左操作數、運算符、右操作數,仍然出錯。: w! k% w' f& {: X; l% S3 c
語用(Pragmatics)) s: {9 X' ]9 x) U; C( b" L
表示在各個記號所出現的行為中,它們的來源、使用和影響。, ?8 L1 o$ T6 q6 d
語用上的錯誤,如:7 F5 {: f5 f3 L
doSomething(); // ReferenceError: doSomething is not defined
. K5 ]7 { U- v- M2 V9 o1 i在這裡直接調用了一個未定義的函數,導致出錯。在一些其他場景中,雖然程序運行正確無誤,但是仍然可以優化(這種優化並不是技巧上的),比如:
6 p4 G- ^" E; O9 S9 Nfunction doSomethingElse() {}(function() { return; doSomethingElse(); // No Exception but Redundant: Unreachable code})();: C3 g" {: C( T0 C+ _& |( }
在這裡,doSomethingElse 函數之前由於有 return,因此這個函數調用將永遠不能執行,這種冗余代碼對整個程序來說毫無用處,可以去掉。0 b h2 R0 t0 l9 u
對於 Closure Compiler 來說,它處理的對象是 js,不需要產生其他中間代碼或彙編代碼 / 機器碼,因此輸出的還是 js,但是是經過分析的、優化後的 js;另外,它也可以選擇輸出 parse tree(使用–print_tree 參數),所以,CC 的確完成了一個編譯器需要實現的功能。
9 }/ q( i* s* k# `7 l CC 功能概述
+ i) T& G3 _: a- }+ p 在詳細討論 CC 的高級模式前,還是簡明介紹一下功能體系。" k7 {5 p5 D" a8 u ] Z
編譯級別
0 E% ^6 V, w# e2 P2 x# c CC 的 compilation_level 包括三個級別:
5 Y5 d- {) x* Y6 V* M3 vWHITESPACE_ONLY% X) j/ F/ ]9 Y1 k. J
只刪除空白、註釋。/ e, K2 c4 [& w, x! f. R1 L
SIMPLE_OPTIMIZATIONS& d \: ^4 i; `
在 WHITESPACE_ONLY 基礎上將局部變量和參數轉成短名稱。, V) h) i* g: D
ADVANCED_OPTIMIZATIONS
3 M8 X7 V6 C8 N更加激進的重命名、移除垃圾代碼、內聯函數。% h, M1 G# m X; p# G
可以看到,SIMPLE_OPTIMIZATIONS 級別的 CC,和 YC 無異,沒做什麼真正的編譯工作,所以說,使用了高級模式的 CC 才是四肢健全的 CC 。! c, B5 ?9 d) `9 e' a, w5 L3 w
約束條件
" f3 `2 \! ^: C8 m2 w" L. v! e 使用 CC 有一定約束條件,這影響到我們的編碼風格:( T* l$ D2 Y9 {% K, Y) \( Z
WHITESPACE_ONLY2 h$ p$ [% r3 u2 k; p8 i
不認可 JS 1.5 以上版本的語言特性' `2 \+ L4 b* Y7 E7 G
不保留註釋7 H9 T) p$ D5 Y) D; ^, Q
SIMPLE_OPTIMIZATIONS
8 w- g q- l" f o+ O( d) V完全禁用 with 和 eval
6 a( G5 M4 W1 C2 u7 b字符串中引用的函數名 / 參數名不會改動(CC 不改動所有字符串)- l2 |( u) I2 c
ADVANCED_OPTIMIZATIONS 模式下的約束放到下文詳述' }! x+ v' p' g" d5 J& W
註解
$ c, ~" L) [1 e' W Annotations 也是 CC 的重要組成部分,使用 JSDoc 風格,用以輔助高級模式下的編譯,下文詳述。
! m2 G7 B9 `9 a2 R/ W 使用 CC 高級模式
0 S1 e' k* Z2 T3 k 在 CC 下,啟用高級模式的方法是加入參數 --compilation_level ADVANCED_OPTIMIZATION。
# z4 J$ `5 S0 A! _0 C 作為一個 compiler,CC 的高級模式下,額外的優化政策是:
: x. |$ {6 W; `, e$ L更激進的重命名,如 obj.property 改為 a.b,將深度過高的命名空間平坦化等;
" G/ _+ n7 b: \2 u移除垃圾代碼,如刪除未被調用的方法定義,警告邏輯死角(return 後的語句等);
8 L" R2 P' Y' R! W* x將函數內聯,如 a call b, b call c,a(),那麼直接執行 c()。
6 @% K. U! p \& @- ]( k3 B 要達到高級模式的預期優化效果,開發者必須對自己做一些約束,因為 js 是弱類型、動態性的。否則
+ ]0 U# M) E! w/ @js 的這種靈活將使 compiler 無能為力。* @! e8 V2 v4 Z k
總體上,這種約束包括限定某些 js 編碼風格,以及使用相應的 JSDoc 註解。
. O3 T+ l/ S( s# U 以下詳述具體的約束以及代碼的檢查 / 優化效果:% [& c. e P9 Y9 |2 ?( Y
強類型的模擬( }, V/ d, a# c0 f, N8 }. @
@param 和 @type 中定義的類型會在編譯期間得到檢查,同樣避免了在運行時檢查,提高性能。8 S4 p) n, e6 ~+ A) R3 m
@const 標記常量,當常量被寫時會報錯。, M7 j9 G# r4 N3 a' B$ x
模擬枚舉,將同類可枚舉常量定義為一個對像字面量,使用 @enum 標記:' K \+ p7 L1 K. L6 b& j8 U& }
var STATUS = { LOADING: 3, COMPLETE: 4};
: ^- I* ]' q: e* Q編譯結果中 STATUS.LOADING 會被直接替換為 3,其實完全模擬了 C 等語言中的枚舉。! N1 [5 U: b! t! o: T8 O; w; Q h
使用 @constructor 標注函數為構造器,它僅能被實例化,而不可用作普通方法,甚至是工廠方法,CC 會確保構造器被合法使用,否則報錯。這樣確保開發者不必在運行時判斷,構造器函數到底以怎樣的形式被調用。* m# `3 e/ C5 P1 I
在表達式中也可以使用 @type 來限定類型,這對於 JSON 特別有用,如
; i; B: \: Y' r3 I" U9 tvar data = /** @type {UserModel} */({ firstName : 'foo', lastName : 'bar'});: l G; h$ v [3 z$ S
在這裡 UserModel 是個構造器,也可以使用 @typedef 來自定義複雜的數據類型。
0 q5 n9 T! k6 A4 \, Y6 g Q 域可見性的模擬* z: s8 s: B; g' V$ L/ {
使用 @private 標注私有域,私有域被外部引用會報錯。開發者也可以按照「國際慣例」給私有域加上_ 前綴或後綴,以提醒自己 / 協作者這是一個私有域,@private 註解用來告訴 CC;這樣,開發者可以不必使用諸如老道的「模塊模式」等技巧來真正地隱藏私有變量,將檢查工作丟給CC,讓開發盡可能樸實簡單。
- y) V5 e$ K+ H4 J7 u/ O類似有 @protect8 l! l8 I. P. Y! z; t
類系統的模擬; ? P9 r/ V* L" }3 b- f9 c
使用 @extends 標注繼承關係,繼承體系會被優化。& {) g' [+ f) X/ q2 d
使用 @interface 標注接口,接口是類似 function ThisIsAInterface(obj) {}的函數體為空的構造器定義,編譯後將移除其相關代碼。同時,標注 @implements 的構造器必須實現implemented 的接口的所有方法(正如其他 OO 語言一樣),否則,CC 報錯。這同樣簡化了接口 / 實現的約束,靠 CC 來保證實現關係的可靠性。
9 p6 N3 S* ]: ^9 U4 [! |+ X3 f 條件編譯的模擬/ [, N! ~8 V: p) A, h l" ~$ t+ G% N
使用 @define 標記狀態開關,適用於調試 logger 等 開發 / 發佈 狀態需要分離的模式。: ?3 R6 W4 j; {. [. ~) y1 N
可以在編譯時指定參數來標識 define 參數的狀態。這其實就是一個條件編譯,真給力……2 ]+ g; y l& S6 U
對像平坦化及屬性名縮減8 i( `$ h5 |$ W8 B3 h7 Y
對像屬性會被編譯為單變量,比如 foo.bar to foo$bar,這種標記方法看起來很像 java 中被編譯出來的內部類~~之後 foo$bar 被進一步縮短。對像之所以能被平坦化是因為在 js 中對象可以看做是一群引用 / 原始數據類型的容器。3 t: N% @/ q4 p+ t+ |. p
但是,js 對像實際上更複雜,所以被平坦化後會帶來一些副作用,比如如果在對像(字面量)中使用 this 指針,則編譯後的結果會導致 this 指向錯誤。所以 Google 建議僅在 constructor 和 prototype methods 中使用 this,這意味著,在所謂類單例(對像字面量)和類的靜態方法(綁定到constructor 上的函數)中都避免使用 this 指針。; s; ~& ?( [5 ]1 V8 M
在縮減對像屬性 / 方法的名稱長度時,有另外一個注意點,那就是必須始終使用 dot syntax(.運算符),而不使用 quoted string([] 運算符),除非索引名是一個變量。這是因為 CC 始終不處理字符串中的內容,所以,var o = { longName: 0 }; o["longName"] 會被翻譯為var a = { b: 0 }; a["longName"] 導致出錯。實在想使用 quoted string,則在定義的時候也要使用 quoted string。
; P0 [) U3 T+ g對於全局變量,如果出現以 window.property 的形式引用的,必須始終定義為 window.porperty 形式:
3 u/ b, e3 e, Y5 {1 l/ S3 @window.property = 1;var property = 1; // wrong!* E) W: N! ]3 e- n) f. l6 o8 {& h
否則也會杯具,CC 可不會 window.property 翻譯為 window.a。
6 ^* K2 \3 g" z 垃圾代碼的移除4 j# V2 X2 X( Y1 ]- @
一個函數聲明卻未被調用時,默認地,聲明體將被幹掉。7 r3 e" o* u* Y0 {, O& B6 P! E( M
在這種機制下,如果一個方法是以 for in 的形式調用的,那麼原方法也會被幹掉,因為這種動態特徵使得 CC 無法清楚方法是否確實在 for in 的時候被調用了。
: p4 H" Z/ L5 m. u7 H對於一些 unreachable 的代碼,CC 將報警告。7 O, c! j4 K) [& T; I. Q) d/ G
如果要產出一份被調用的公共接口,例如庫,使用稱作 export 的方法將函數導出,防止函數定義被
" w9 a1 ]: v3 w: k) T: `CC 回收。具體的做法是將函數綁定到某個容器,比如:0 f% o! d' V2 h6 h, ]7 |: G- N
function displayNoteTitle(note) { alert(note['myTitle']);}// Store the function in a global property referenced by a string:window['displayNoteTitle'] = displayNoteTitle;1 z9 D7 Z p8 D# h+ q6 M
對於需要 export 的函數,均使用 quoted string 風格。. X! Y9 c$ Q2 N$ G+ }5 T1 v
背後的思考7 n" b5 u$ r, L5 ]8 ^+ D! j3 m
根據以上高級模式優化的行為分析可知,CC 附加給開發者的約束主要有:: U& ~7 m3 z. M4 W, R0 u
強制以強類型的靜態語言風格編寫 js,將關注點從運行時的動態技巧轉移到組織代碼、編寫邏輯5 I8 x. s" Q0 u h2 J% b
本身。而可能由弱類型系統和動態特徵產生的問題和風險則交給 CC,即通過開發者與 CC 達成一種
7 B G9 g6 P: G編碼約定而規避掉。% C5 y( x6 W- ~, v9 |
嚴格要求區分面向開發者的代碼和面向機器的代碼。5 o" d+ o! n( v8 X" v$ X
雖然不像 C 等語言會編譯產生目標代碼,但是 CC 在一定程度上也生成了面向機器的 js,包括壓縮空白、縮減標識符、條件編譯和冗余代碼去除。這和第一點其實是一脈相承的,同樣要求開發者將關注點轉移到開發本身。$ r5 @1 ]& Y* ]& u2 g& j
使用規範化的接口方式。
4 v' F: O, T* s9 k7 g0 Z; T- q4 m這不僅包括要求開發者使用恰當的 annotation(extend, interface, …),同時也給整個 OO-JS 打下了一個框架,開發者必須使用同樣的模式進行 OO 編碼。另外,要求使用 export 技術統一導出公共接口更強化了這一點。總之,這一點進一步限定了開發者的編碼風格,但是帶來的好處是明顯的:可讀、可控、一致性。
, ^- B6 \3 n1 N' e 曾經有讀過 Closure Library 源碼的童鞋評論道:
2 W! l, H9 c5 p" p; e- w2 mGoogle 根本不懂怎麼寫 javascript!代碼裡面各種冗余,並且充滿了 java 的味道!0 ]7 e4 b' K3 v* o) H8 P
當時確實也有這種感覺,比如 Google 把 if(foo) 寫作 if(foo != undefined) 等等。
6 v n/ n: |( N9 | Javascript 固然充滿了豐富的動態特徵,而且很多特性非常優雅,能夠讓代碼簡潔精悍,或者構造出一些
# D: E& t( @ j, b1 U令人驚歎的技巧,但是也會產生一些副作用:- d0 S2 R9 y$ S3 O- I7 @
首要的問題是可讀性,靜態的東西容易一目瞭然,動態的東西需要經過一番運算才能得出結論。
' C* z$ l" e4 U; n, b- F5 B9 x$ ~比如 js 中的極晚綁定,再比如標識符運行時重寫。
- |2 Y- k d" b4 G4 Y其次的問題是執行性能。一個比較經典的眾 js 工程師都在使用的技巧就是「模擬函數重載」——
- X% a( W) @" r! ?在函數體內判斷 arguments 的特徵,從而對應給出不同的邏輯。由於缺乏強類型,js 本身不能具備
5 x6 d4 I# }' S) l8 l, @! g; }- V真正的重載,但是運行時的判斷在帶來靈活性的同時,必然會多出很多模擬重載的邏輯,降低性能。: u; R# i6 O8 A) r2 B) M8 q
在今年的 D2 大會上,Hedger 童鞋指出,大多數 js 開發者像是個 ninja(忍者),他們身懷絕技、神鬼莫測,單兵作戰還可以,但是一旦碰到 army(軍隊,比如 Google 團隊這樣的 )就是個悲劇。
6 C3 L. F. y6 O6 }) c) N& ?9 p 我比較欣賞這個比喻,大團隊要良好地協作,必需遵循一定的規範和限制,優先保證可讀性和一致性,與此同時
% Q1 ^4 k# `% @" G$ t+ f t失去的是奇技淫巧、自由靈活。所以採用何種編程風格、理念,需要具體問題具體分析…………
1 x9 z8 I$ q/ t 至少,目前 CC 提供了一個好的思路,它的高級模式推崇的編程風格也是很值得嘗試、借鑒的。
: }5 E: `( C* U, d' {4 W) @ 最後附上 CC 的常用命令選項……選項實在是有夠多……7 b# v" Q ?( ~, z, M
CC 常用命令選項
. b" r9 F! N# f% m% `) |–charset VAL 對所有文件定義的編碼格式
* B( l8 [6 o6 n: g9 A* P–compilationlevel [WHITESPACEONLY | SIMPLEOPTIMIZATIONS | ADVANCEDOPTIMIZATIONS]" Y9 A. K P% j) e6 W y! G
設定編譯級別
5 m( L# y6 t( a2 b4 d- [8 I3 U( \–debug 開啟 debug 選項
; T& {2 `; f$ w% M–define (–D, -D) VAL 設定文件中使用 @define 標注的開關值,即條件編譯( A$ m1 X: S& S9 G6 y# G) _7 [
–externs VAL 編譯代碼需要調用未編譯的代碼時,使用它6 m3 j' @. M Q. M4 ]/ F
–formatting [PRETTYPRINT | PRINTINPUT_DELIMITER] 格式化輸出
7 U6 Y# z: r* r/ m) ?. O–js VAL 輸入文件,多指定多個,將會被合併
) g2 W2 M( d- \–jsoutputfile VAL 輸出文件,如果不指定的話,直接輸出到 standard output 流
# m0 b5 w; [8 v5 a0 ~3 Z- [; g/ |–module VAL 定義模塊+ a% l! s6 w, [ c' u
–output_manifest VAL 打印編譯文件清單
2 i/ r9 C5 ^" X( X1 h5 I–print_tree 打印語法分析樹
, c) M0 x' i& ~" k1 l/ _# u–warning_level [QUIET | DEFAULT | VERBOSE] 設定報錯模式 |