在 JavaScript 中可靠地移除文字的重音符號,遠不止呼叫 String.prototype.normalize('NFD') 並去除產生的組合標記那麼簡單,因為 Unicode 字元資料庫對於 ø、ł、đ、þ、ß、æ、œ、đ、ħ、ŧ 以及無點的 ı 等一整族拉丁字母,並未記錄任何標準分解。純粹的 normalize-and-strip 管線會悄悄地把這些字元全部原封不動地留下來,因此像 Łódź 這樣的城市名稱會停留在 Łódź 而不會變成 Lodz,而一個讀作 "São Paulo,Łódź,Quito" 的 CSV 欄位,在不同匯出之間既無法對應到 "Sao Paulo,Lodz,Quito",也無法與自身相符。原因出在結構上:像 ø 或 ł 這類字元並沒有「基底字母加附加符號」的形式,所以諸如 /\p{M}/gu 的正則表達式找不到任何組合序列,腳本於是在沒做任何有效處理之前就結束了。在 JavaScript 中可靠的作法是分層實作:先以 NFD 正規化來捕捉像 é 與 ç 這類真正的變音符號,再為不可分解的字母建立明確的對照表,另外為 æ→ae 與 ß→ss 之類的音譯提供獨立開關,並設置嚴格的保護機制,使希臘文、斯拉夫文、中文、日文、韓文、天城文以及 emoji 能原封不動地通過。Remove Accents from Text 工具把那條精確的管線封裝成單一貼上後點擊即可使用的頁面,本文的其餘部分會逐一說明每一層必須做什麼、NFD 漏掉了哪些情況,以及如何在不必自行撰寫、測試與維護整個流程的情況下使用該工具。

remove accents from text javascript
在 JavaScript 中移除文字重音,不落入 NFD 陷阱

為何 JavaScript 的 Normalize-and-Strip 方法會在真實輸入上失效

在幾乎每個 JavaScript 教學中首先出現的配方大致如下:

str.normalize('NFD').replace(/\p{M}/gu, '')

對於當初設計要處理的變音符號,這個兩步驟模式確實正確。一個預組成的 é(U+00E9)在 NFD 下會分解成基底字母 e 加上組合銳音符號 U+0301,而第二步會刪除該組合標記,留下乾淨的 e。對於 ç、ü、ñ、ï、ÿ 以及整個「基底字形加上一個堆疊標記」的字母家族,這條路徑同樣有效,而這也正是 UAX #15 中 Unicode 正規化規則所設計支援的情況。JavaScript 的 String.prototype.normalize() 方法忠實地實作了這些規則,而 \p{M} 這個 Unicode 屬性跳脫能涵蓋每一個標準的組合標記類別。

接著腳本繼續執行,café 的單元測試也通過了,但一個星期後,當某個波蘭地址、冰島姓名或德文產品標題突然無法相符時,bug 便浮上檯面。腳本確實如實完成了被要求的工作;只是所被要求的根本不夠。像 ø、ł、đ、ħ、ŧ、無點的 ı、ð 與 Ð 這些字元,根本不帶任何可分離的附加符號。它們在 Unicode 字元資料庫中是原子化的碼位,沒有任何對應到「基底字母加上組合標記」的分解映射。因此 NFD 會原封不動地保留它們,正則表達式找不到可移除的內容,最終字串中仍然保留著輸入原本就有的那個字母。對於一個標榜「移除重音」的工具而言,這就是靜默失敗的典型模式。

NFD 正規化無法觸及的字母

NFD 能捕捉到的內容與真實世界文字所包含的內容之間,存在著足以打斷實際工作流程的落差。下方表格列出最常造成問題的拉丁字母、單純的 normalize-and-strip 腳本對它們的處理結果,以及完整實作應將其對應為何。音譯部分(æ→ae、ß→ss、þ→th 以及其大寫形式)刻意與變音符號移除分開,並置於獨立的開關之後,因為它們是以一個字母取代兩個字母;而一位為了 slug 而清理波蘭文字的使用者通常會希望啟用它,一位致力於保留古英文拼法的語言學家則通常不會。

輸入字母NFD 是否能移除變音符號?正確的對應分類
é (U+00E9)e變音符號
ç (U+00E7)c變音符號
ü (U+00FC)u變音符號
ø (U+00F8)o不可分解的字母
ł (U+0142)l不可分解的字母
đ (U+0111)d不可分解的字母
ð (U+00F0)d不可分解的字母
þ (U+00FE)th音譯
ß (U+00DF)ss音譯
æ (U+00E6)ae音譯
œ (U+0153)oe音譯
ħ (U+0127)h不可分解的字母
ı (U+0131)i不可分解的字母

上方「正確的對應」欄中的每一個項目,皆已對照官方的 UnicodeData.txt 檔案進行過驗證。音譯並非 Unicode 定義的對應,而是有記載的拼字選擇;這也正是 Remove Accents from Text 頁面基於原則將其區分開來,而非模糊變音符號移除與重新拼字之間界線的原因。

以正確方式在 JavaScript 中移除文字重音的作法

對大多數 JavaScript 開發者而言,要在不必自行建置並維護上述分層管線的情況下,最快交付無重音輸出的方式,就是直接使用一個已具備該管線的工具。Remove Accents from Text 頁面在單一的瀏覽器端流程中執行 NFD、套用經驗證的對照表、開放音譯開關,並為每一個非拉丁文字系統設置保護機制。其工作流程如下。

  1. 貼上文字:將含有變音符號或特殊拉丁字元的文字貼入輸入區域。最多可在單一線性流程中處理一百萬個字元,因此即便是大型 CSV 匯出檔與地址清單也能即時返回結果。
  2. 選擇音譯開關:針對像 æ、œ、ß 與 þ 等連字與字母進行設定。在產生 slug、通訊錄去重複與搜尋索引時請保持開啟;當你需要保留原始拼字(例如德文「Straße」或古英文「Þorri」)時,則將其關閉。
  3. 選擇是否保留或去除非拉丁文字系統。預設會逐位元組保留希臘文、斯拉夫文、中文、日文、韓文、天城文與 emoji;唯有在你確實需要僅含 ASCII 的輸出時,才啟用嚴格模式。
  4. 點擊 Remove accents。頁面會套用 NFD,僅針對拉丁基底字母移除組合標記,依表格對不可分解的字母進行對應,套用開關規則,最後再重組為 NFC,使輸出結果無論輸入以何種形式儲存皆保持一致。
  5. 確認頁面上回報的變更數量以了解有多少字元被重新寫入,接著將清理後的結果複製回你的 JavaScript 管線、CSV 檔案或 slug 欄位。

由於該操作具備等冪性,將同一份輸入執行兩次會與執行一次產生相同的輸出。這個特性在清理後的文字會被送入下一個清理步驟時格外重要,而這也正是 NFC 重組被納入契約的原因:以 U+00E9 形式儲存的預組成 é,以及以 e 接著 U+0301 形式儲存的分解 é,在頁面處理完畢後都會抵達同一個單一碼點,無論輸入是上述哪一種形式,都會產生相同的清理後字串。

一個真正合格的移除重音工具必須拒絕處理的對象

相反方向的失敗模式同樣常見,同樣靜默無聲。像 ά(帶銳音符號的 alpha)、έ(帶銳音符號的 epsilon)以及 ή(帶銳音符號的 eta)這類希臘字母,在 Unicode 中的分解方式與帶變音符號的拉丁字母相同:基底字母加上一個組合標記。斯拉夫字母的行為也是如此,印度的諸文字系統亦然,其母音符號位於基底子音之上或之下。一個會刪除所遇每一個 \p{M} 的天真剝除器,會悄悄地把 ά 改寫為 α、把 ё 改寫為 е,並把天城文的母音符號刪除殆盡,從而損毀它根本未被要求清理之文字系統的內容。

Remove Accents from Text 工具僅移除附加於拉丁基底字母之上的標記。希臘文、斯拉夫文、中文、日文、韓文與天城文皆會逐位元組原封通過,而天城文的母音符號在語法上是字母而非重音,絕不會被視為可移除的標記。Emoji 也以同樣方式原封通過,這在輸入欄位接受使用者產生內容的任何情境下都很重要。可選的嚴格模式能在你確實需要僅含 ASCII 的輸出時,直接刪除每一個非拉丁字元,但該模式預設為關閉,因為這是一項破壞性的選擇,而頁面允許使用者明確地自行決定。

何時你真正需要僅含 ASCII 的 JavaScript 輸出

要從 JavaScript 字串中移除重音,最有力的理由往往是某些下游系統並不具備 Unicode 感知能力。Slug 產生器是最明顯的例子:URL 路徑片段不能包含未經百分號編碼的 é、ñ 或 ü,而經百分號編碼的波蘭文 URL 對使用者與爬蟲而言同樣不友善。Text To Slug 工具在重音處理的基礎上,再套用一套獨立且明確的 ASCII 或 Unicode 政策,使 slug 規則與重音規則得以各自獨立稽核。去重複是第二個情境:對大多數郵務流程而言,兩位分別名為「Müller」與「Muller」的聯絡人其實是同一人,但這只有在 ü 上的變音符號被對應掉之後才成立。搜尋比對是第三個情境:在未使用 ICU 校對器的情況下所建置的 Postgres 或 Elasticsearch 索引,會將「São Paulo」與「Sao Paulo」視為不同的字串,而在資料匯入階段就移除重音,可使索引保持一致。僅含 ASCII 的乾淨輸入同時也是許多舊有系統、識別碼的 MD5 校驗碼、DNS 標籤與較舊 Windows 字碼頁的硬性需求;在上述每一種情境中,問題並不在於是否要對應,而在於究竟只對應真正的變音符號,還是連音譯情況一併重新拼寫。

Remove Accents from Text 工具背後的契約刻意劃定了這條界線。變音符號移除預設為開啟且不可由使用者控制,因為在其涵蓋的拉丁字母範圍內,答案是明確的。音譯置於開關之後,因為把 ß 改成 ss 是一項應由使用者自覺做出的拼字變更。剝除非拉丁文字系統同樣置於開關之後,理由亦同:這具有破壞性,而預設是保留其他語言不動。一切皆在瀏覽器內執行,沒有任何內容會被上傳,也不需要帳號,使這個頁面可以放心地放進 JavaScript 相關的工作流程中,即使輸入本身已屬敏感資料亦無妨。

延伸閱讀:如何從 Rhino 文字匯出中移除重複的行

延伸閱讀:在 Excel 中以不靠公式的方式移除重複的單字