一封被壓成單行的郵件範本引發的疑問
場景很日常:營運同事轉來一份 EDM 範本,說「在編輯器裡改了個文案,提交後 diff 全紅了」。打開一看,整段 HTML 被某個建置腳本壓成了一行——標籤之間無換行無縮排。我順手把這一行貼進工具點了「美化」,縮排與行數回來了,但 diff 仍然全紅:文字節點周圍多出若干空格。這讓我想要驗證一個根本問題:美化與壓縮是不是互逆操作?往返回走一次能否回到原始位元組?十份不同結構的樣本逐位元組對照給出答案——互逆性只在一個方向上成立。
20 組逐位元組對照與不對稱的失敗
十份結構差異明顯的樣本——純 ASCII 預填字串、含中文表格、成對 pre 區塊、textarea、內聯 script、大寫標籤加單引號、行內文字空格、void 標籤串、三層巢狀中文、混合 style——每份分別跑 minify(beautify(x)) 與 beautify(minify(x)),再和原串逐位元組比較。二十組對照,零組相等。但失敗不對稱:beautify(minify(x)) 等於 beautify(x) 十條全中,也就是先壓再美化的輸出和直接美化完全相同;minify(beautify(x)) 等於 minify(x) 十條全不中。下面給 UTF-8 位元組對照:
| 樣本 | 原串 B | 美化 B | 壓縮 B | mb B | bm B |
|---|---|---|---|---|---|
| S1 預填單行式 | 128 | 216 | 128 | 134 | 216 |
| S2 表格中文 | 72 | 96 | 54 | 58 | 96 |
| S3 pre 區塊 | 25 | 32 | 23 | 31 | 32 |
| S4 textarea | 52 | 69 | 52 | 66 | 69 |
| S5 內聯 script | 93 | 110 | 93 | 107 | 110 |
| S6 大寫加註解 | 60 | 74 | 57 | 59 | 74 |
| S7 行內文字 | 52 | 69 | 52 | 56 | 69 |
| S8 void 標籤 | 40 | 56 | 39 | 45 | 56 |
| S9 三層巢狀 | 116 | 149 | 85 | 89 | 149 |
| S10 含 style | 102 | 152 | 98 | 120 | 152 |
bm 欄每一行都與美化欄一致,mb 欄與壓縮欄或原串沒有一行相等——minify 對 beautify 是透明的,beautify 對 minify 卻不可逆。
128、216、134:多出來的那幾個位元組從哪來
預填字串 128 位元組純 ASCII,在HTML 美化壓縮面板點了「美化」之後變成 216 位元組——增量全來自行首縮排與換行符。再點「壓縮」回去,得到 134 位元組而不是 128:多出 6 位元組。定位到偏移 34 才發現 <title> Test </title>——beautify 把文字節點下推成獨立行並 trim,兩側各留一個空格;minify 的 >\s+< 正則只匹配純標籤邊界,文字獨立行兩側的空格不在它的覆蓋範圍裡。Test、Hello、World 三個文字各貢獻 2 位元組,合計恰好 6。
pre 要成對才受保護,未閉合就破防
minifyHTML 的保留機制依賴正則匹配成對的開閉標籤。實測成對樣本「<div> x <pre>L1 L2</pre> y </div>」40 位元組進 40 位元組出,pre 內部空白原樣不動;大寫 PRE 同樣命中。未閉合時「<pre>keep me please」24 位元組被摺疊成 19 位元組——內部的空格和換行全被通用正則吞掉。保護是有條件的:成對才進保留池,未閉合就退化。
統計列報的是字元,不是位元組
updateStat 取的是 JS 字串的 .length——也就是 UTF-16 碼元數,不是 UTF-8 位元組數。中文樣本顯示 11 字元,實際 UTF-8 編碼 19 位元組;含代理字組的樣本顯示 10 字元,實佔 14 位元組。比率一樣是字元口徑:預填首屏從 128 字元變成 216 字元,變化 +68.8% 顯示紅字。要做傳輸體積估算就必須用 UTF-8 編碼器重算,不能引用統計列上的數值。
圖 1:S1 往返位元組流向與不對稱統計
冪等性分裂與佔位符注入
beautify 對含成對 pre 的字串並不冪等:55 字元的源串逐輪變長 83→96→104 字元——b1 把 pre 拆成獨立行之後,b2 又把 pre 體內換行的行首當成原始內容再疊一層縮排。不含保留塊的字串是冪等的:兩次都是 102 字元。minify 第二次執行逐位元組相同,無論含不含保留塊。
minify 內部的佔位協議 ___PRESERVE_n___ 可以被正文裡的字面量注入。輸入「<div>___PRESERVE_0___</div><!--C C-->」37 位元組,輸出 31 位元組「<div><!--C C--></div><!--C C-->」——正文裡的佔位符被 preserved[0](一條註解)替換掉,於是那條註解出現兩次。越界編號 ___PRESERVE_5___ 則輸出字面量 undefined。
findTagEnd 的引號感知與洩漏
beautifyHTML 用 findTagEnd(code, start) 定位標籤結束。實測屬性值含 > 時在第 13 字元收口——引號裡的 > 不會造成截斷;雙引號套單引號交叉「<div t="it's" u='q"r'>x」在第 22 字元收口;引號洩漏(開引號未閉合)就一直吞到第 17 字元的字串尾端。未閉合不報錯,只讓縮排異常加深。
圖 2:findTagEnd 三種收口行為對照
註解核取方塊與格式不變量
同一份含註解的輸入 44 位元組,勾選保留時輸出 43 位元組——註解連同內部空白完整留下;取消勾選輸出 19 位元組——註解整段刪除。屬性引號與標籤大小寫在兩檔設定下逐位元組不變,單引號仍是單引號、雙引號仍是雙引號、DIV 不轉成 div。
縮排寬度只是顯示層的概念
getIndent 三檔(2 個空格、4 個空格、Tab)給出不同的前導字元數,但 minify 的 >\s+< 正則會把它們全部刪掉——換檔不影響 minify 的輸出,位元組帳裡從不出現縮排寬度這個變數。
常見問題
Q:美化再壓縮能不能得到直接壓縮的結果?
不能。minify(beautify(x)) 與原串逐位元組十條全不中——beautify 留下的文字獨立行空格和保留塊縮排,是 minify 刪不掉的殘餘。反過來 beautify(minify(x)) 等於 beautify(x) 十條全中。
Q:未閉合的 pre 會不會報錯?
不會。未閉合不滿足成對正則,進不了保留池,內部空白被通用正則摺疊;beautify 也只加深縮排、不丟例外。更多場景選擇可參考舊文 HTML 美化壓縮說明。
Q:統計列上的數字能拿來當位元組數用嗎?
不能。updateStat 取的是 UTF-16 碼元數,11 字元可能實佔 19 位元組。位元組帳必須另行按 UTF-8 計算。
Q:正文裡寫了 ___PRESERVE_0___ 會怎樣?
會被當成佔位符還原成保留池第 0 號內容,導致註解重複。正常的 HTML 不會觸發這個條件。