1. 程式人生 > 程式設計 >分享一款超好用的JavaScript 打包壓縮工具

分享一款超好用的JavaScript 打包壓縮工具

背景

平時大家在開發 Js 專案的時候,可能已經離不開 webpack 等打包工具了。而 webpack 打包速度大概就是“能用“的水平。大概去年開始,我就開始在構想,如果能寫一個極速的打包工具,功能未必需要很強,可能對小專案非常有用。去年我用 C++ 寫完 parser 之後,便沒什麼動力寫下去了。但是最近發現有這個想法的不止我一個,Figma 的 CTO 業餘之際寫了一個打包器 https://github.com/evanw/esbuild ,可以說完完全全實現了我想象中的需求,不過他是用 Go 語言實現的。我看到這個專案時心裡一想,這不是我去年就想做的事嗎,這 push 我趕緊把打包壓縮部分完成。

程式碼

Github 地址: https://github.com/vincentdchan/jetpack.js

優化思路

並行 Parsing

毫無疑問,每一個 js 檔案的 parsing 可以在不同執行緒完成,這就需要支援並行的語言。由於 parsing 的結果是 AST,所以需要可以共享記憶體的語言(排除通過 messeage parsing 實現多執行緒的語言)。滿足以上兩個要求的語言不多。 Evan 選擇了 Go,我選擇了 C++。

減少遍歷次數

要想速度快,就要減少 AST 的遍歷次數。最好就是隻遍歷一次來生成程式碼,在 Parsing 構建 AST 的時候就收集足夠的資訊。但是這也意味著只能做比較淺層次的優化,不能做深層次的壓縮(死程式碼消除,tree shaking 都做不了)。

架構

由上述思路我總結出了以下打包的架構:

  1. 並行 parse 檔案
  2. 作用域提升、生成框架程式碼、重新命名變數
  3. 並行生成程式碼
  4. 合併輸出檔案

流程圖如下:

分享一款超好用的JavaScript 打包壓縮工具

打包壓縮原理

本章節主要講如何“最簡單“地壓縮 Js 程式碼。本章節假設讀者對編譯原理有一定了解,知道什麼是 AST。如果不懂請直接跳到下文「效能」章節。

字面量替換

字面替換最簡單。規則有一下幾個:

  • undefined 替換為 void 0
  • true 替換為 !0,false 替換為 !1

:warning: 注意:在 ES 中,undefined 是識別符號(Identifier),而不是關鍵字,也就是說你可以定義一個叫 undefined 的變數,所以這個時候不能簡單地替換為 void 0

常量摺疊

計算簡單的運算:

var two = 1 + 1;
var foobar = 'foo' + 'bar';

轉換成

var two = 2;
var foobar = 'foobar';

:warning: 注意:這裡要注意實現的平臺和 js 的差異,比如在 C++ 裡面大整數相加可能會溢位,而在 Js 會自動轉換成 bigint. 加法問題就如此,其他運算子問題更多。如果要完整實現常量摺疊,可能要部分實現 js 引擎。

變數別名

別名就是要給變數重新賦予比較短的變數名。從字母一直排上去,abcd,一個字母用完了用兩個字母。實現起來也很簡單,用一個計數器,一直加上去就可。最後每個變數分配一個數字,把這個數字對映到相應的英文字母上,有點像 36 進位制轉換成字母的面試題。不過這裡有一點值得注意的是,變數名第一個字母不能是數字,第二個字母開始可以是數字,要考慮到這一點,才能儘可能“壓榨”變數名。

為了儘可能地“壓榨”變數名,同一級的作用域裡面的變數名是可以使用相同的變數名。到下一級的時候,對子作用域進行合併。

舉個例子:

function Mother() {
	var e = 'capture'; // d 不能使用跟子作用域同樣的變數名,不然子作用域無法捕獲這個變數
	function A(a,b,c,d) {
 console.log(e);
	}
	
	function B(a,c) { // B 跟 A 函式同級,分配同樣的變數名
	 // ...
	}
}

上述例子中,A 和 B 都沒有子作用域了,變數名從 0 開始分配。到給 Mother 下 e 分配變數名時,找到子作用域最大的計數器。分配最多的子作用域 A 分配了 4 個,所以 B 計數器從 5 開始分配,所以給 e 分配了5,所以 e 就得到了這個名字。

所以變數別名就是從 AST 的葉子開始向上構造,一直分配到根結點把所有作用域都分配完為止。

小技巧

這裡 esbuild 採用了比較聰明的技巧。它統計了所有變數的引用次數,然後進行排序,引用次數最多的變數分配到的名字就是儘量短的,這樣也可以減少編譯出來 js 的體積。我在寫 jetpack 打包的時候,也借鑑了這種做法。

模組合併

模組合併的辦法有很多。webpack 採用的是用 function 把每個函式包起來,放到了一個長長的數組裡面,然後實現了自己的 require,esbuild 也採用了類似的方法。

Rollup.js 實現的方法則是作用域提升(Scope hoisting),把模組都放到根作用域。這裡我採用的方法也是作用域提升。

假設有 a.js 檔案:

export function A() {
 console.log('a');
}

然後有 main.js 檔案:

import { A as ExternalA } from './a';

function A() {
 console.log('local A');
}

export function main() {
 A() + ExternalA();
}

使用 jetpack 打包完的結果:

// a.js
function A() {
 console.log('a');
}

// main.js
function A_0() {
 console.log('local A');
}

function main() {
 A_0() + A();
}

export { main };

難點在於作用域合併。實際上在 ES modules 裡面不同 modules 之間引用是一個圖結構。

C++ 的優化

除了策略上的優化,C++ 還提供了諸多基礎資料結構/記憶體方面的優化。

shared_ptr

AST 的結點全部使用 shared_ptr,有人可能認為這是一個很大的開銷。但是早期的時候我實現過一個裸指標版本(不釋放記憶體),並沒有測出有明顯差距。

使用 shared ptr 很重要一個原因是,一個子樹可能被其他類擁有(打包模組,Scope,ES Module 管理器)。這個時候如果用 unique ptr 的話就會 gg。只能說 GC 大法好。

對於 C++ 這種沒有 GC 的語言有一個毛病就是:析構 AST 非常耗時。AST 夠大的話能耗上十幾 ms(這個時間跟 gc 比有何優勢?),所以因此我也能想出了一個辦法: 不釋放記憶體 ……。

最後說一句: GC 大法好

robin hood hashing

由於打包器中大量使用雜湊表,所以提高雜湊錶速度尤其重要,這裡我使用了 robin hood hashing

參見: https://martin.ankerl.com/2019/04/01/hashmap-benchmarks-01-overview/

在 hash 方面我有一個設想,就是像 Lua 一樣,對於短字元,在字串建立的時候把 hash 記下來,這樣在多次使用雜湊表的時候可以節省 hash 的時間(但是要求字串是 immutable 的)。為此我專門寫了個 String 類,最後的結果是總體速度慢了 2-3x,測出來是 immutable 字串拼接耗時太多,最後放棄了這個方案。

jemalloc

Parsing 過程中需要大量分配 node,大家都知道很明顯 C++ 的 new 並不夠快。經過測試在 macOS 下使用 jemalloc 會讓 parsing 速度提升 1 倍。使 用系統 malloc 會導致 parsing 速度比 Go 慢 1x 左右,慢在 new 。

當然了,記憶體池我也試過的,測出來速度基本和 jemalloc 一樣,所以就直接用 jemalloc 了。

效能

分享一款超好用的JavaScript 打包壓縮工具

總結

寫編譯器需要快速大量產生 node 結點,大量樹和圖的結構,這一方面的運算 C++ 並沒有什麼優勢可言。

不得不承認,使用 C++ 你要思考很多東西,做很多很多額外的工作,才能獲得比 Go 還快的速度(什麼都不想做出來只會比 Go 還慢)。另一方面使用 C++ 會讓你額外考慮很多和業務無關的東西,大大降低開發速度,而對於打包器這個場景 C++ 在這一塊本身不能提供很大優勢。

到此這篇關於寫一個飛快的 JavaScript 打包壓縮工具的文章就介紹到這了,更多相關JavaScript 打包壓縮工具內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!