頁面裡該顯示的 <div> 被吞了,中文又變成一串數字
兩次事故都不是「轉義沒做」。一次是把舊文件正文貼回頁面,本該原樣顯示 <div> 的那行被瀏覽器當真標籤吃掉;一次是從舊庫匯出評論,正文裡每個中文都成了 &#20013; 這種 12 字元長串,看著像字元集壞了。修完只留一條規矩:先數清被編了幾層,再決定動不動手。
這一輪把 HTML實體編解碼 模板裡的 htmlEncode、htmlDecode、html2js 三個函式逐字抽出來一條條餵。要量清三件事:全量檔的產物長什麼樣、實體名表覆蓋到第幾名、只差一個字元的串會走哪條路。
encodeAll 一開,中文先進的是雙重轉義
不勾時它只動那五個特殊字元,其餘一律透傳,控制字元都不碰。勾上後多出的那段把所有非 ASCII 的 UTF-16 單元換成數字實體,順序在這裡翻了車:非 ASCII 的替換排在和號轉義之前,剛拼出來的實體裡那個和號又被下一輪全域替換編了一次。於是「中」的產物是字面 &#20013;,12 個字元,不是很多人以為的 中 單層形態。
那串符號不是數字實體本身,而是實體外面又套了一層轉義。中文區碼點全是五位,這檔下每個漢字固定佔 12 個字元。站內那篇 HTML實體編解碼的坑:引號與尖括號 記的「庫裡存轉義形態、渲染時再編一次」反覆發作,根源就在這個順序上。
實體名表字面 17 個鍵,命名分支真正走得到的只有 16 名
還原表字面寫著 17 個鍵,命名那段的正則卻只認「和號加純字母再加分號」。表裡 ' 這個鍵帶數字,永遠輪不到命名路徑,它是由十進制那段還原的——命名表實際覆蓋 16 名。除基礎五件套外逐條餵一遍:
- 解成半形空格,碼點 32——模板字面量寫的就是普通空格,與標準裡那個 160 不是同一字元;
- ©、®、™、' 照常還原;
- 兩個破折號、省略號、四個引號方向全部命中。
這條最容易吃虧:解完看著正常,實際把「不換行」的屬性丟了。反向用 encodeAll 編一個不換行空格,產出正好是 &#160;,兩邊對不上才知道 160 才是本尊。
容錯清單:差一個字元就整段不認,而且不報錯
| 輸入(字面) | 這一頁解出來 | 為什麼 |
|---|---|---|
&、<(缺分號) | 原樣 | 三段正則都要分號收尾,少一個字元整段不認 |
<、A | 原樣 | 十六進制正則只認小寫 x,命名正則不含 # |
&、< | 原樣 | 正則命中,但查表區分大小寫,查無就回填 |
&foo;、&amp; | 原樣 | 字母段貪婪吞成 ampamp,查無此名 |
&& | && | 第一個和號後面不是字母,第二個照常還原 |
共同點是安靜:不報錯、不提示,符號原樣留在頁面上。
三段替換是串行的:一次呼叫能吃掉兩層
htmlDecode 依序跑命名、十進制、十六進制三段,每段都會掃前一段剛拼出來的新串。把 &#60;(9 個字元)餵進去,一次就得到 <:命名段還原成和號、拼出 <,十進制段緊跟著吞成尖括號。對照 &lt; 只剝一層就停,因為命名段不回看自己拼出的串。
數字實體開頭的串因此比命名實體的多怕一次解碼,往返就在這破口:12 個本來就長得像實體的串先編後解,11 個逐字元回到原樣,唯一反例正是 <;缺分號的 < 反倒穩穩回來。
圖 1:往返恆等數與唯一反例。
雙重轉義的位元組帳:每多編一輪淨增 4 個字元
膨脹只在實體裡的和號上:每編一輪它從 1 個字元變成 5 個,淨增 4。從缺分號的串起手最乾淨:< 是 3 個字元,編一次 &lt 是 7 個,編兩次 &amp;lt 是 11 個。反方向是等差減法:&amp;amp; 這種 13 字元的串,解一次剩 9、兩次剩 5、三次剩 1,每次恰剝一層命名實體,第四次結果不再變——一個孤零零的和號就是解碼的不動點。
圖 2:編碼方向與解碼方向都等差。
統計行報的是 UTF-16 單元,不是位元組
下面那行統計取的是 JS 串長,也就是 UTF-16 code unit。兩行輸入 中文abc 與 line2 報原始字元 12,同一串的 UTF-8 實位元組是 16;一個 emoji 按 2 個單元計。編號也跟著漂:勾全量後那個 emoji 被拆成 &#55357;&#56832; 兩段——正則不帶 u 旗標,取的是代理值不是碼點。
越界的數字實體不報錯,它靜默給你一個錯字元
十進制段不做範圍校驗,parseInt 的結果直接交給按 65536 取模的建構函式:😀 解出碼點 62976 的私用區字元,並不是那個 emoji;� 解出碼點 0 的空字元。不報錯也不原樣保留:長度看著對,內容已換。閉環只在自家手裡:那兩段代理編號再解一次能拼回原字元,交給嚴格的標準解析器就是不合法引用。
HTML 轉 JS:實測三種輸出,列表頁文案只寫了兩種
同一段兩行輸入(含插值、單引號、反斜線、反引號,64 個 UTF-16 單元)在三個 type 下分別是 81、73、95 字元:模板那檔最短,陣列 join 最長。差別在轉義面:模板檔把插值符號防住,陣列檔不碰美元符與大括號,也完全不處理回車,回車被原樣帶進第一行的字串字面量。下拉裡三種都在,說明文案只提了拼接與模板兩種。
遇到一串符號怎麼下手
順序是先數層再動手:把字面串複製進解碼框,解一次看剩下什麼,還剩一層就再解一次,剩下一個孤零零的和號就該停——< 那種一次吃兩層的另說。存的時候放回原始字元、只在輸出那刻編一次,這兩次事故才沒再復發。
常見疑問
Q:勾了「編碼所有非 ASCII」,頁面為什麼顯示一串 &#20013; 而不是中文?
替換順序的問題:數字實體裡那個和號又被編了一次,產出天生是雙層形態。
Q:  少一個分號為什麼不還原,也不報錯?
命名那段的正則要求分號收尾,缺了就整段不認、原樣回填。
Q:解完之後為什麼不換行空格撐不住了?
這一頁把它還原成半形空格,碼點 32,不是標準裡的 160;空格看著還在,屬性沒了。
Q:12 個實體形輸入先編後解,為什麼不全部回得來?
11 個恆等,唯一反例是數字實體開頭那位:三段替換串行,一次呼叫連剝兩層。