雜記 › 技術筆記 › HTML 美化與壓縮不是互逆:10 個樣本的往返位元組帳實測

HTML 美化與壓縮不是互逆:10 個樣本的往返位元組帳實測

十份 HTML 樣本往返逐位元組對照全不等;beautify 套 minify 等於 beautify 十條恆中、反向全不中。128 到 216 到 134 解釋殘餘空格,pre 要成對才保護,統計列報 UTF-16 字元而非位元組——失敗不對稱。

一封被壓成單行的郵件範本引發的疑問

場景很日常:營運同事轉來一份 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壓縮 Bmb Bbm B
S1 預填單行式128216128134216
S2 表格中文7296545896
S3 pre 區塊2532233132
S4 textarea5269526669
S5 內聯 script9311093107110
S6 大寫加註解6074575974
S7 行內文字5269525669
S8 void 標籤4056394556
S9 三層巢狀1161498589149
S10 含 style10215298120152

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 編碼器重算,不能引用統計列上的數值。

S1 預填串往返位元組流 128 B 216 B 134 B → → 原始 128 位元組 美化 +88 位元組 再壓回 +6 殘餘 bm==b 十條全中 mb==m 十條全不中

圖 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 字元的字串尾端。未閉合不報錯,只讓縮排異常加深。

findTagEnd 收口位置 引號內 > 正常截斷: 13 雙套單交叉截斷: 22 引號洩漏吞到串尾: 17 未閉合標籤不丟例外,縮排只增不減 成對 pre 40B 進 40B 出 24B→19B 破防

圖 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 不會觸發這個條件。

📎 相關文章

1 GB 文件由 1024 塊拼成:任意大小文件生成的分塊節奏、取消邊界與三種填充位元組 2026-11-04 文件名提取的三處邊界:結尾斜槓只兜一格、查詢串整塊留下、空行靜默消失 2026-11-04 encodeAll 一開,中文先變成 &amp;#20013;:HTML 實體表覆蓋與解碼容錯實測 2026-11-04 貼上 100 行分數,第 45 行被悄悄算成 1/4:約分這頁的負號、空行和大數各歸誰管 2026-11-03 把 3. 起步貼進 Markdown 轉 HTML,為什麼有序列表的編號只認第一個數 2026-11-03