Sanyanggae LogoSanyanggae

💻 Web前端開發

Unicode 與 UTF-8 有何不同?韓文亂碼、位元組、字串長度與正規化

比較韓文和表情符號的碼位、UTF-8 位元組與 JavaScript 字串長度,區分錯誤解碼、字型問題、正規化與原文保留。

사냥개 (Sanyanggae)發布 更新 6 分鐘閱讀

本文譯自韓文原文。 韓文原文 · 翻譯檢核: AI檢核

說明字元與碼位、UTF-8 位元組、顯示與正規化關係的三欄示意圖
Sanyanggae 製作的說明圖,區分 Unicode 字元編號、UTF-8 儲存位元組及顯示與比較規則。不是實際故障截圖或測量結果。

1. 字元的編號與儲存的位元組是兩回事

假設匯出一份韓文檔案後重新開啟,標題卻顯示成了奇怪的字元。輸入者的語言並沒有改變,問題可能是讀取已儲存數字時採用的規則不一致。Unicode 是給各種文字和符號分配碼位編號的標準,UTF-8 則是一種把相應的值表示為位元組序列的編碼。例如,韓文音節‘가’的碼位是 U+AC00。U+ 後面的內容採用十六進位表示,不是它在檔案中的位置,也不是記憶體位址。這個編號並不指定字型的字形或螢幕上的像素位置。同一碼位在不同字型和排版環境中,也可能呈現不同的外觀。因此,是否支援 Unicode、是否以 UTF-8 儲存、是否安裝韓文字型,是相互關聯卻需要分別檢查的事項。Unicode 對 UTF 的說明可以幫助我們先區分字元與儲存格式。

在 UTF-8 中,‘가’被表示為十六進位的 EA B0 80,共三個位元組。英文字母 A 的碼位則是 U+0041,在 UTF-8 中只需一個位元組 41。一個位元組包含八個位元,所以同一檔案中不同字元的位元組長度也可能不同。僅憑 .txt 或 .csv 副檔名,並不能確定檔案實際採用的編碼。UTF-8 用一到四個位元組表示有效的 Unicode 純量值,因此不能把螢幕上的字元數乘以某個固定數,就準確得到檔案大小。也不能把這個韓文音節的例子推廣到所有與韓文有關的符號或所有語言。編碼是把字元資訊轉成位元組的規則,不是壓縮,也不是加密。同一句話的總大小還可能受到檔案格式的附加資訊、換行方式和傳輸壓縮的影響。把 WHATWG Encoding Standard所定義的位元組與字元轉換,與整個檔案的容量和保密功能區分開,才能更準確地定位問題。

2. 位元組數、編碼單元與看得見的字元數

JavaScript 字串的 length 傳回 UTF-16 編碼單元的數量,不是 UTF-8 檔案的位元組數,也不等於人所理解的字元數。比如笑臉‘😀’只有一個碼位 U+1F600,卻需要兩個 UTF-16 編碼單元,所以 '😀'.length 的結果是 2。編碼為 UTF-8 後,它是 F0 9F 98 80,共四個位元組。codePointAt(0) 傳回的十進位數 128512,與 U+1F600 表示的是同一個編號,不是字元數量或檔案大小。‘가’按這三種標準分別是一個碼位、一個 UTF-16 編碼單元和三個 UTF-8 位元組。同一字串得到不同數字,是因為測量單位不同。MDN 的字串長度說明明確說明了 length 的單位。僅憑這個數,也不能斷言 JavaScript 引擎實際佔用了多少位元組的記憶體。

即使逐個統計碼位,也不總等於使用者看到的字元數量,因為基本字元可能附帶組合標記,多個碼位也可能構成一個表情符號。Unicode 文字分割規則說明了處理這些組合的延伸字素叢集邊界。像 Array.from(text) 這樣按碼位展開字串的方法,並不會自動把多個碼位構成的組合算作一個單位。如果要在瀏覽器裡統計,可以先確認支援情況,再考慮把 Intl.Segmenter 的 granularity 設為 'grapheme'。但字素叢集並不保證與字型繪出的一個圖形一一對應,不同語言對編輯操作的預期也可能不同。簡介輸入框應採用使用者能理解的字元計數;儲存和通信限制則應另行計算位元組數。如果介面寫著‘20 個字元’,內部卻直接按 UTF-16 編碼單元截斷,就可能從中間切斷表情符號或組合字元。

3. 韓文亂碼要分清儲存、傳輸與顯示

寫入位元組時使用的編碼,與讀取時使用的解碼規則不同,就可能產生亂碼。UTF-8 檔案被按另一種編碼解釋時,即使檔案本身沒有變化,螢幕也可能出現錯誤字元。在 HTML 中,需要一起檢查實際儲存格式、HTTP 回應 Content-Type 中的 charset,以及檔案前部的 meta charset 宣告。HTML 的 lang='ko' 是告訴系統內容使用什麼語言的屬性,與選擇如何讀取韓文位元組的 charset 職責不同。W3C 的 HTML 編碼宣告指南要求不僅把宣告改成 UTF-8,還要實際以 UTF-8 儲存檔案,並讓伺服器宣告與檔案宣告一致。編碼簽名還可能影響判定的優先級,因此不能認為一個 meta 宣告始終覆蓋其他資訊。如果直接開啟檔案與通過伺服器存取的結果不同,傳輸環節的設定就是重要線索。

字型缺少所需字形,導致文字顯示為方框,與錯誤解碼可能是不同的問題。如果字元資料正確,只是找不到相應字形,修改儲存編碼並不能解決;反過來,已經被解讀成其他字元的內容,也不會換一個字型就恢復。調查時應保留問題檔案的副本,依次比較編輯器選擇的編碼、伺服器回應宣告、瀏覽器解釋出的字元以及實際使用的字型。不要只憑外觀確定原因,還要查看複製同一段內容後得到的碼位。記錄問題是否只在某個瀏覽器出現、其他編輯器是否也讀錯同一檔案,可以幫助區分顯示問題和資料變更。為了找到正常顯示的文字而反覆儲存,可能使原始位元組丟失。如果頁面宣告均正確,卻只有特定文章異常,還要追查內容進入資料庫之前,究竟在哪一步發生過轉換。

4. 用 TextEncoder 和 TextDecoder 核對同一組值

簡單實驗可以從自己編寫的短字串開始。TextEncoder把 JavaScript 字串轉換為 UTF-8 位元組陣列。new TextEncoder().encode('가') 的結果按十六進位讀取是 EA B0 80,陣列的 length 為 3。開發工具也可能以十進位顯示 234、176、128;這不是另一組資料,只是數字進位方式不同。與同一字串普通 length 為 1 比較,就能看出單位的區別。用 new TextDecoder('utf-8').decode(bytes) 讀取這個陣列,會得到‘가’,這裡的 bytes 指前面生成的位元組陣列。這個編碼器不是可以任意選擇舊編碼的通用儲存轉換器,而是生成 UTF-8 的工具。調查實際檔案時,應使用事先取得的原始位元組。把螢幕上已經錯誤解碼的句子再次編碼成 UTF-8,不能證明最初檔案原本有哪些位元組。

為 TextDecoder 建構函式指定 'utf-8' 和 { fatal: true },就能在遇到不符合規則的輸入時拋出錯誤,而不是靜默替換。例如,單個位元組 FF 不是有效的 UTF-8 輸入,因此這種模式的解碼會失敗。預設的替換模式可能在錯誤位置顯示 U+FFFD 替換字元。但沒有回報錯誤,不代表已經識別了原始編碼,也不代表確認了內容正確。有些位元組同時適用於多種編碼;內容也可能先被轉換成其他字元,再次儲存成有效的 UTF-8。這個檢查只是在確認選定的解碼器能否接受輸入,並不會自動驗證檔案來源、內容是否與原文一致,或檔案在中途是否被篡改。

5. 正規化是統一同一字元表示的另一項操作

正常顯示的兩個字串,內部碼位序列也可能不同。‘가’既可表示為一個 U+AC00,也可表示為組合初聲 U+1100 與中聲 U+1161 的序列。後面這兩個值,與直接排列日常相容字母‘ㄱ’和‘ㅏ’的例子不同,因此要一起核對編號。這兩種表示通常繪成同一個音節,但 JavaScript 的原始字串比較可能認為它們不相等。依據 Unicode 正規化規則,對兩者應用 NFC,就能把這個正準等價的例子統一為同一種表示再比較。NFD 則採用正準分解。在這個例子中,NFC 的 UTF-8 表示有三個位元組,NFD 的兩個字母表示則有六個位元組,所以畫面一樣,儲存大小也可能不同。搜尋詞與儲存的姓名看起來一樣卻比對不到時,可以調查這種表示差異;它應與錯誤解釋位元組所造成的亂碼區分開。

正規化形式也要按目的選擇。NFC 處理正準等價,而 NFKC 包含相容分解,可能更廣泛地改變全形數字、帶圈數字等原本有所區別的輸入表示。生成搜尋用的比較值時,這可能有幫助,但在數學記號或原始資料中,這些區別也可能有意義。因此,可以保留原始值,再決定對必要的比較副本應用哪種形式。正規化也不代替大小寫比較、按語言排序或尋找發音相同的詞。批次轉換現有檔案名稱或帳號識別字,可能讓不同項目合併為同一個比較值,所以應先檢查服務的命名規則和衝突處理。不能僅因兩個字元看起來相似,就認為把所有這類字元儲存為同一值是正規化的普遍目標。

6. 保留原文,記錄每一次轉換的邊界

定位損壞原因時,應在覆蓋初始檔案之前保留原始位元組和取得檔案的途徑。在副本中嘗試按預期編碼讀取,並檢查把正常顯示的結果再次儲存後,內容是否仍然一致。W3C 的 BOM 說明介紹的 UTF-8 起始簽名是 EF BB BF,但這些位元組不是必需的。UTF-8 不需要選擇位元組順序,所以不能把這個簽名理解為與 UTF-16 的位元組順序選擇具有相同作用。添加或刪除 BOM,與把正文的位元組轉換成另一種編碼,也是不同操作。如果無法讀取的值已經被替換字元或問號替代後儲存,多個原始值可能已合併成一個,不能保證自動還原。只有確認原始編碼和轉換歷史後,才能評估是否有機會逆轉;正規化並不是找回丟失位元組的工具。

開發多語言功能時,不能只檢查一行英文就結束。應在同一組短句中加入韓文音節、帶組合標記的拉丁字母、補充平面的表情符號,以及阿拉伯文、希伯來文等從右向左書寫的文字,並比較輸入、儲存、重新查詢與匯出的結果。每一步需要核對顯示外觀、碼位、UTF-8 位元組數,以及產品規定的字元計數標準。書寫方向不是把 UTF-8 換成另一種編碼就能解決的屬性,介面的語言與方向設定也需要單獨檢查。修改輸入限制或搜尋比較方式時,同樣應保留原文,並說明標準如何變化。還要確認瀏覽器提示的數字與伺服器接受的標準是否使用同一單位;如果確實需要不同限制,就分別標示。本文的數字與程式碼只演示短字串的轉換,不是對某個服務故障的測量結果。依次檢查字元編號、儲存位元組、字串單位、顯示和比較規則,才能重現每個邊界究竟發生了什麼變化。

依據與參考資料

本文依據公開資料進行說明。標示出處不代表相關機構曾審閱或認證本文。

關於經營者與聯絡方式

同主題的其他文章