file

改編(mangling)是 JavaScript 語法壓縮上的其中一種方法,其作用就是去把原本較長的變數常數名稱(或甚至物件成員名稱)一致地改成比較短而不具可閱讀性的名稱,例如把原本的 RiverComponent 全部改名為 _J 之類的。這樣做有兩個用意,一個是減少檔案大小以便傳輸上可以更快,另一個則是故意要讓程式碼難以被閱讀、以便例如減少被針對性地攻擊的機會。目前 Terser 是我個人最推薦的 JavaScript 壓縮器,而它也有提供改編的功能。本篇中我來談一些使用 Terser 來進行改編的心得。

變數改編 vs 成員改編

如同前述,改編有兩種模式:

  1. 只針對變數和常數進行改編。
    這種改編一般來說很安全,尤其如果不要去改編全域變數的話,基本上改編之後的程式在執行上是不會發生錯誤,因此 Terser 預設的選項也是如此。改編全域變數則比較危險,因為有可能會有別的 script 會去使用那些變數,名稱改了的話就會導致那些 script 出錯。
  2. 同時也改編物件成員名稱。
    這種改編危險程度更高,主要原因跟前一點一樣,當要改編的成員並非僅在 script 內部使用的時候就會出問題;這包括別的 script 來存取我們程式中的物件成員,或是我們的程式去呼叫外部定義的物件成員(尤其例如 DOM)。另一點則是因為成員名稱呼叫上比起變數名稱使用要有較多的變化型,例如若程式碼中有使用到像是 obj[propName] 這種以傳入字串的方式去呼叫成員的作法,那麼改編之後的程式通常是會出錯的,因為傳入的字串基本上都會是改編前的名稱。

基於這個緣故,如果我們真的想要改編物件成員,應該要很確定:1. 這個成員的定義和使用都只有在我們自己的 script 內部會用到,至少原則上並沒有開放讓別的 script 來存取;2. 從頭到尾程式都不會用字串的方式去呼叫它,永遠只會直名呼叫(要確定這一點,可以快速做一個搜尋看看全部的程式碼對於 in 關鍵字以及 [...] 語法的使用概況)。確定了之後,我個人的方法是在該成員的命名前面加上 $_ 符號來標注這是一個可以被改編的成員,然後再利用 Terser 的如下選項來設定我只允許那樣命名的成員是要改編的:

{
    mangle: {
        properties: {
            regex: /^[$_]/
        }
    }
}

TypeScript 裝飾器

然而,對 BPS 的應用來說,馬上就遇到了一個小問題:Terser 的成員改編機制並不支援 TypeScript 的裝飾器(decorator)。假設我們有這樣的一段 TypeScript 語法:

class A {
    @myDecorator public $prop: string;
}

則我們會發現產生出來的 JavaScript 語法中會有這樣的一段:

__decorate([
    myDecorator
], A.prototype, "$prop", void 0);

也就是說,TypeScript 實作裝飾器的方法是透過字串名稱去設定類別的原型物件的,這就跟我們稍早說不能用字串呼叫成員是牴觸的,因此直接這樣進行 Terser 改編的話程式一定會出錯。

不過,其實解決的方法倒也滿單純的,多一道手續就可以了。以我的 Gulp 建置流程來說,我只要在執行 Terser 的前後做一個 gulp-replace 的變換即可:

let replace = require('gulp-replace');
let terser = require('gulp-terser');
let terserOption = {
    mangle: {
        properties: {
            regex: /^[$_]/
        }
    }
};

...
.pipe(replace(/("[$_][a-z0-9]+")/gi, '$$$$$$$$[$1]'))
.pipe(terser(terserOption))
.pipe(replace(/\$\$\$\$\.([a-z$_][a-z$_0-9]*)/gi, '"$1"'))
...

這個的原理是把所有類似 "$prop" 的字串先轉換成 $$$$["$prop"] 的格式讓 Terser 知道這是一個屬性(Terser 能夠認得常數字串的屬性呼叫),等到 Terser 改編完之後再把長得像 $$$$.xyz 這樣的東西還原成 "xyz" 即可。因為我的程式碼裡面本來絕對沒有連續四個 $ 這種東西存在,這樣做就可以搞定 TypeScript 裝飾器的需求了。

效益比較

當然,為了要做到這種成員改編也並非全然沒有代價的;因為在我的程式碼裡面,絕大多數的成員都符合要被改編的條件、只有很少數是會被別的 script 存取的,結果就變成我大部分的成員都要在前面加上 $_ 來識別之 1,這樣其實多少有一點不清爽,寫程式碼的時候也會多了那麼一點點的麻煩,所以我當然會想要知道這樣做到底有沒有足夠的實質好處。

之前在 是否應該要 minify HTML? 一篇中我比較過幾種不同組合的大小,這邊我一樣類似地用 BPS 的主程式做了類似的測試,其結果如下:

明文傳輸 gzip 傳輸
原始程式碼 281637 58219
純 minify、不改編 174628 48345
變數改編 142079 42255
變數+成員改編 117902 38764

從這張表可以看得出來,額外進行成員改編確實是可以再省下一些大小沒錯,所以就純粹的節省用意來說這絕對有其價值。至於這樣算不算是「值得」,這就是很主觀的問題了,不過我倒是可以在這邊再提出一個使用 $_ 前綴的合理化:這樣的前綴可以順便提醒我自己「這樣的成員是可以安全地被重構命名的」,因為這樣的成員絕對不會被外部組件所存取,一定只有當前的組件自己會用到 2。而一旦我想要重新命名一個沒有加上那些前綴的的成員時,至少我會知道此時我應該去檢視 BPS 中的其它組件、並且要順便跟著改名那些有使用到的地方。這樣的一則自我提醒在維護有規模的專案時是滿重要的。


  1. 或問,那為什麼我不乾脆反過來?因為我當然不希望程式的公開 API 的成員名稱是有那些符號的。 

  2. 這就很像是在 C# 裡面把東西宣告成 internal 那樣,只有當前的組件自己可以取用。可惜目前為止在 TypeScript 中只有 publicprotectedprivate 三個存取層級,沒有那麼細的劃分。 


分享此頁至:
最後修改日期: 2021/07/02

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。