去年我幫一個做技術部落格的朋友遷移舊站留言。舊庫裡的正文已經存成轉義過的形態,我按「純文字」處理,匯出腳本又整段編了一次碼。結果新站上一堆留言顯示的不是文字,而是一串字面符號:<p>、&amp;,讀者以為排版壞了。修這件事花的時間遠超預期,因為要一條一條判斷到底被編過幾次、該解一次還是兩次。從那以後我對轉義只信一個原則:先確定它發生在哪一層,再決定要不要動手。
尖括號是結構,不是內容
HTML 原始碼裡,小於號標誌著「這裡開始一個標籤」,大於號標誌著結束,和號標誌著「這裡開始一個實體引用」。所以當你希望這三個字元以文字的身分出現在頁面上,就必須換一種寫法告訴解析器別把它當結構。這就是編碼的全部理由——不是「更安全」這種籠統說法,而是「這個位置上的字元會被當成語法」。
反過來,在某個位置寫了實體,它會被先還原成字元再參與解析,屬性值裡就有這種微妙情況。我的判斷固定成兩句:這段內容是要顯示給人看,還是要被瀏覽器執行?前者尖括號引號要編碼,後者不能。
常見實體對照
下面這張表是我貼在螢幕邊上的那份。中間一列是給瀏覽器看的寫法,右邊一列是頁面最終顯示的字元。最後一列的碼點是十進位的字元編號,數字實體就照它寫,記不住名字時用碼點也能拼出來。
| 原字元 | 瀏覽器裡寫的實體(字面文字) | 頁面顯示成 | 碼點(十進位) |
|---|---|---|---|
< | < 或 < | < | 60 |
> | > 或 > | > | 62 |
& | & 或 & | & | 38 |
" | " 或 " | " | 34 |
' | ' | ' | 39 |
這張表裡最容易搞錯的是和號本身。它是「開啟一個實體」的那個字元,所以想讓頁面顯示一個和號,原始碼裡那位必須寫成 &;寫成裸和號再加字母,瀏覽器會把後面那段當成實體名吞進去,頁面出現莫名的字元遺失。
雙重轉義是怎麼攢出來的
遷移那次的現場就是這個。編碼是一次函式呼叫,多做一次就多穿一層衣服,而顯示只會脫一層。我按層數列一遍,就能倒推任何一條留言被編了幾次。
| 被編碼的次數 | 原始碼裡存的字面文字 | 頁面顯示出來的 |
|---|---|---|
| 0 次(本來該是這樣) | & | 和號被當成實體開頭,可能解析異常 |
| 1 次 | & | & |
| 2 次 | &amp; | & |
| 3 次 | &amp;amp; | &amp; |
規律就是每多編一次,字面文字前面就多出一段 amp;。所以看到頁面上有 &,說明原始碼裡的和號數量比該有的多一層;看到 <p> 這種字面標籤文字,說明標籤被編了兩次——原本 <p> 編一次得到 <p>,這一串再編一次,那個和號又變成 &,於是螢幕上出現 <p> 的字面形態。
修的時候我不做「整庫統一解一次碼」,而是按條判斷被編了幾次,只解掉多出來的層數,最後在輸出時統一編一次。儲存層放原始字元、輸出層負責轉義,這是不復發的唯一辦法;庫裡存轉義後的文字,等於把轉義的責任交給寫入時機,早晚重演。
什麼時候編碼,什麼時候解碼
我的判斷表很短。要作為文字顯示的程式碼片段、使用者輸入進 HTML 之前、拼接到樣板裡的第三方內容——編碼。從介面或 RSS 裡拿到一串帶 &、' 的文字,要落庫或者做字數統計——解碼。要存進資料庫給別的應用讀——存解碼後的原始字元。要在頁面上顯示——顯示的那一刻編一次。
屬性上下文是另一個容易翻車的地方。比如給一個 input 寫 title 屬性,值裡出現了裸的雙引號,形如 title="他說"你好"",第一個內部引號就把屬性提前閉合了,後面的內容被解析成一堆亂七八糟的屬性。這種位置把 " 寫成 " 才是對的,也是為什麼我用 HTML 實體編解碼 時會看它對引號的處理選項,尖括號和引號一起處理才敢直接塞進屬性。
還有一類是需要在 JavaScript 裡放一段 HTML。字串裡單引號雙引號換行混在一起,手寫轉義很容易出錯。工具提供把 HTML 轉成 JS 字串的用法(拼接形式或樣板字串形式),我用它生成內嵌提示框的片段,比自己數反斜線可靠。
別把中文也編掉
編碼選項裡通常有兩種口徑:只處理特殊字元,或者把所有非 ASCII 字元一併轉成實體。後者會把中文變成一串 &#xxxxx;,顯示一樣,但原始碼體積明顯漲、可讀性歸零,diff 完全沒法看。除非輸出鏈路真的只吃 ASCII,我只編特殊字元。要看碼點、要處理全形半形,那是另一件事,文字編碼轉換 做的是字元編號和寬度形態的轉換,跟「要不要被瀏覽器當語法解析」無關。
同樣容易混的還有 URL 編解碼。位址裡 & 是參數分隔符,要在參數值裡放一個真正的和號,用的是百分號寫法 %26,不是 &;把 HTML 實體塞進查詢字串,伺服器收到的就是字面的 & 那串字元。反過來 %3C 出現在 HTML 正文裡,瀏覽器只會原樣顯示 %3C,不會當成標籤。這兩套規則各管各的層,混用是我見過最常見的第二個坑。
問得比較多的幾件事
Q:頁面上出現了 &,我該解幾次碼?
按顯示的東西倒推:顯示 & 說明原始碼裡是兩次編碼的結果,解一次得到 & 形態的文字、再解一次才是原始的字元。我不靠猜,用一次解碼看結果——解一次後顯示 & 就是還剩一層,解一次後變成和號本身就到位了。
Q:為什麼我寫了   少個分號也照樣有效?
某些歷史遺留的實體名在特定上下文裡能被解析,但這不是可以依賴的行為。分號是實體結束的標誌,我一律寫全,尤其在和號後面緊跟字母的文字裡,省掉分號會讓解析器把後面的字母也吞進去。
Q:把使用者輸入編碼一次就夠了嗎?
取決於它最終落在哪個位置。落到 HTML 文字節點,編一次;落到屬性值,除了尖括號還要處理引號;如果這段內容還要拼進 URL 或 JS,那麼得按那一層各自的規則再做一次。只在一處編碼、其他層指望它通用,就會出問題。
Q:編輯器裡看到一堆實體,是文件壞了嗎?
不一定。很多富文字編輯器和 CMS 存的就是轉義形態,這是正常的儲存方式。真正的問題是「讀它的應用以為它是純文字」,那就會二次編碼。我一般會先確認儲存約定,再決定要不要解碼。
Q:解碼會不會把不該還原的東西還原了?
會。如果一段文字本意就是要把實體寫法本身顯示出來,比如頁面上要出現 < 這串字面文字,解碼後它變成 <,再被塞進 HTML 就可能被當標籤。所以解碼我只對自己確認「存的是轉義形態」的內容做,來源不明的一律先當文字處理。
回到那批留言。最後我按「庫裡存原始字元、渲染時編一次」重寫了匯出,把雙重編碼的條目按層數解回來,頁面上再沒出現字面實體。判斷標準也簡單了:看到多出來的符號先數層數,別急著再解一次;每次編碼之前先問那段文字最後落在哪一層——是給人讀的文字,還是會被解析成語法的位置。這個問題答對了,&amp; 就沒有出現的理由。