現(xiàn)漢字按拼音排序的完整方案與避坑指南)
做前端這幾年凡是跟“排序”沾邊的需求十有八九會(huì)碰到中文按拼音排序的問(wèn)題。通訊錄要按姓名首字母分組城市選擇器要把“重慶”排在C組而不是Z組商品列表要按名稱(chēng)的拼音順序展示。每次遇到這種需求總有同事第一反應(yīng)是直接調(diào)sort()結(jié)果一跑發(fā)現(xiàn)“北京”排在“上?!焙竺嬷苯由笛邸_@個(gè)標(biāo)題看著簡(jiǎn)單真做起來(lái)坑比想象中多。今天就把 JavaScript 里漢字按拼音 a-z 排序這件事徹底講透從原生 API 的用法到多音字的坑從數(shù)組排序到通訊錄分組全是我實(shí)際踩過(guò)坑之后沉淀下來(lái)的方案可以直接抄作業(yè)。1. 需求拆解與實(shí)現(xiàn)思路1.1 漢字排序的難點(diǎn)在哪里首先要搞清楚一個(gè)問(wèn)題為什么 JS 原生的排序不能直接用JavaScript 的Array.prototype.sort()默認(rèn)把元素轉(zhuǎn)成字符串然后按 UTF-16 編碼單元的數(shù)值大小排序。問(wèn)題在于漢字在 Unicode 里的編碼順序是按照部首和筆畫(huà)來(lái)的跟拼音沒(méi)有任何關(guān)系。舉個(gè)例子“愛(ài)”的 Unicode 碼點(diǎn)是\u7231“北”是\u5317“城”是\u57CE。默認(rèn)sort()排序時(shí)“北”(0x5317) 排在“城”(0x57CE) 前面“城”又排在“愛(ài)”(0x7231) 前面。但按拼音應(yīng)該是“愛(ài)”(ai) → “北”(bei) → “城”(cheng)順序完全亂套。這背后涉及的機(jī)制是 ICUInternational Components for Unicode?,F(xiàn)代瀏覽器和 Node.js 在實(shí)現(xiàn)localeCompare和Intl.Collator時(shí)會(huì)調(diào)用 ICU 內(nèi)置的語(yǔ)言排序規(guī)則。中文排序規(guī)則里面包含了拼音字典數(shù)據(jù)所以它知道“愛(ài)”對(duì)應(yīng)的拼音是“ai”“北”是“bei”。但前提是你要把這個(gè)語(yǔ)言環(huán)境參數(shù)正確傳進(jìn)去。1.2 方案選型原生 API 還是拼音庫(kù)我梳理了當(dāng)前主流的三種方案先做個(gè)對(duì)比方案實(shí)現(xiàn)方式優(yōu)點(diǎn)缺點(diǎn)A字符串調(diào)用localeCompare(b, zh-Hans-CN)原生支持無(wú)需依賴(lài)性能好多音字受限于 ICU 字典個(gè)別字不準(zhǔn)BIntl.Collator實(shí)例化比較器可復(fù)用實(shí)例適合高頻比較參數(shù)更精細(xì)底層邏輯跟 A 一樣多音字問(wèn)題同樣存在C引入 pinyin / pinyin-pro 拼音庫(kù)能拿到完整拼音和首字母控制力最強(qiáng)增加包體積大列表場(chǎng)景需要做緩存我的習(xí)慣是如果只是排序優(yōu)先用方案 A 或 B如果需要拿拼音做更多事情搜索、分組、首字母索引再引入拼音庫(kù)。為了一個(gè)排序功能就甩一個(gè)幾百 KB 的庫(kù)進(jìn)去完全不劃算。另外說(shuō)一句別迷信哪種方案一定“最正確”。我見(jiàn)過(guò)有人只憑一兩個(gè)用例就斷定localeCompare不行結(jié)果換成拼音庫(kù)又得處理各種多音字詞庫(kù)反而更麻煩。選型的核心依據(jù)是業(yè)務(wù)場(chǎng)景對(duì)準(zhǔn)確率的要求以及你手里已有的依賴(lài)。2. localeCompare 與 Intl.Collator 核心用法2.1 基礎(chǔ)語(yǔ)法和參數(shù)拆解localeCompare的完整語(yǔ)法是string.localeCompare(compareString, locales, options)三個(gè)參數(shù)里locales和options都可以省略。但做中文拼音排序時(shí)這兩個(gè)參數(shù)恰恰是關(guān)鍵。先看locales。要傳中文語(yǔ)言標(biāo)簽const arr [重慶, 北京, 上海, 深圳, 廣州]; arr.sort((a, b) a.localeCompare(b, zh-Hans-CN)); console.log(arr); // 期望結(jié)果[北京, 重慶, 廣州, 上海, 深圳]這里有幾個(gè)常見(jiàn)的語(yǔ)言標(biāo)簽寫(xiě)法zh、zh-CN、zh-Hans-CN、zh-Hant-TW。在大多數(shù)現(xiàn)代瀏覽器里簡(jiǎn)體中文的排序結(jié)果差異不大。但有一個(gè)細(xì)節(jié)我測(cè)試過(guò)如果你不傳或只傳zh某些瀏覽器的默認(rèn) ICU 數(shù)據(jù)可能拿不到簡(jiǎn)體中文的排序規(guī)則結(jié)果退化到按 Unicode 碼點(diǎn)排序。所以穩(wěn)妥的做法是寫(xiě)zh-Hans-CN這種完整的 BCP 47 標(biāo)簽。再來(lái)看options。這個(gè)參數(shù)控制比較精度最常用的幾個(gè)選項(xiàng)const collator new Intl.Collator(zh-Hans-CN, { sensitivity: base, numeric: true, ignorePunctuation: true });sensitivity: base忽略大小寫(xiě)、重音符號(hào)和聲調(diào)只區(qū)分字母本身。做拼音排序時(shí)最推薦這個(gè)。numeric: true排序時(shí)把數(shù)字按數(shù)值比較而不是字符串字典序。這樣“第2章”會(huì)排在“第10章”前面。ignorePunctuation: true忽略標(biāo)點(diǎn)符號(hào)對(duì)排序的影響。2.2 兩種 API 的差異與性能對(duì)比localeCompare和Intl.Collator底層是同一套 ICU 排序規(guī)則功能上等價(jià)。區(qū)別在于使用方式// 不推薦每次比較都走一遍內(nèi)部邏輯 arr.sort((a, b) a.localeCompare(b, zh-Hans-CN)); // 推薦先創(chuàng)建比較器復(fù)用 compare 方法 const collator new Intl.Collator(zh-Hans-CN, { sensitivity: base }); arr.sort(collator.compare);Intl.Collator實(shí)例化一次后compare方法可以直接復(fù)用。對(duì)于反復(fù)排序的場(chǎng)景比如用戶(hù)點(diǎn)了好幾次表頭切換升降序性能差距會(huì)很明顯。我實(shí)際測(cè)過(guò)一萬(wàn)條數(shù)據(jù)的數(shù)組復(fù)用 Collator 比每次調(diào)用localeCompare快約 30%~40%。另外提醒一句collator.compare是一個(gè)函數(shù)引用直接傳給sort沒(méi)問(wèn)題。但要注意箭頭函數(shù)的寫(xiě)法會(huì)讓this綁定失效這個(gè)在比較器里通常不影響因?yàn)閏ompare不依賴(lài)this。2.3 關(guān)于排序穩(wěn)定性ES2019 之后Array.prototype.sort被要求是穩(wěn)定排序。也就是說(shuō)如果兩個(gè)元素的比較結(jié)果相同它們?cè)瓉?lái)的相對(duì)順序會(huì)被保留。這一點(diǎn)對(duì)中文排序挺重要。比如你有一個(gè)列表先按創(chuàng)建時(shí)間排好再按拼音排序。當(dāng)拼音相同時(shí)穩(wěn)定排序能保留之前的時(shí)間順序用戶(hù)看到的就是“拼音相同再按時(shí)間排”的效果。老一些的瀏覽器比如 IE11 和某些舊版 WebView不是穩(wěn)定排序如果你需要兼容它們就得自己加一個(gè)序號(hào)字段作為第二比較鍵。users.sort((a, b) { const result collator.compare(a.name, b.name); if (result ! 0) return result; return a.createTime - b.createTime; // 第二排序鍵 });3. 完整項(xiàng)目實(shí)現(xiàn)從數(shù)組到通訊錄分組3.1 純字符串?dāng)?shù)組排序最基礎(chǔ)的場(chǎng)景直接把城市列表按拼音排序const cities [重慶, 北京, 上海, 深圳, 廣州, 成都, 杭州]; const collator new Intl.Collator(zh-Hans-CN, { sensitivity: base }); cities.sort(collator.compare); console.log(cities); // [北京, 成都, 重慶, 廣州, 杭州, 上海, 深圳]注意觀察“重慶”的位置它在“成都”之后、“廣州”之前說(shuō)明“重”確實(shí)被識(shí)別為 chong而不是 zhong。這是大多數(shù)現(xiàn)代瀏覽器中 ICU 字典的表現(xiàn)但我在某些舊版 Android WebView 里測(cè)過(guò)“重慶”會(huì)被排到 Z 組去。如果你的用戶(hù)群里有大量舊設(shè)備最好用我后面說(shuō)的“拼音庫(kù)自定義詞典”兜底方案。3.2 對(duì)象數(shù)組按拼音字段排序?qū)嶋H業(yè)務(wù)中更多是對(duì)象數(shù)組比如人員列表按姓名排序const users [ { name: 張三, age: 28 }, { name: 李四, age: 32 }, { name: 王五, age: 25 }, { name: 陳六, age: 30 } ]; const collator new Intl.Collator(zh-Hans-CN, { sensitivity: base }); users.sort((a, b) collator.compare(a.name, b.name)); // 按 陳、李、王、張 的順序排列這里有一個(gè)必須處理的情況name字段可能為空或undefined。不處理的話collator.compare會(huì)直接拋異常。我的習(xí)慣是先做空值判斷users.sort((a, b) { const nameA a.name ?? ; const nameB b.name ?? ; return collator.compare(nameA, nameB); });空字符串的排序位置在最前面符合大多數(shù)業(yè)務(wù)直覺(jué)。3.3 通訊錄首字母分組實(shí)戰(zhàn)“按首字母分組”和“按拼音排序”是一對(duì)孿生需求通訊錄、城市索引都用得到。這里需要拿到每個(gè)漢字的首字母原生Intl.Collator只能比較排序不能輸出拼音字符串所以我會(huì)引入 pinyin-pro 這個(gè)庫(kù)。先安裝npm install pinyin-pro然后取首字母并分組import { pinyin } from pinyin-pro; function getInitial(char) { const result pinyin(char, { pattern: first, toneType: none }); return result.charAt(0).toUpperCase(); } const contacts [ { name: 張三 }, { name: 李四 }, { name: 王五 }, { name: 陳六 }, { name: Ann } ]; // 先按拼音排序 const collator new Intl.Collator(zh-Hans-CN, { sensitivity: base }); contacts.sort((a, b) collator.compare(a.name, b.name)); // 再按首字母分組 const groups {}; contacts.forEach(item { const initial getInitial(item.name.charAt(0)); if (!groups[initial]) { groups[initial] []; } groups[initial].push(item); }); console.log(groups); // A: [{ name: Ann }] // C: [{ name: 陳六 }] // L: [{ name: 李四 }] // W: [{ name: 王五 }] // Z: [{ name: 張三 }]關(guān)于拼音庫(kù)選型我對(duì)比過(guò)pinyin、pinyin-pro、tiny-pinyin這幾個(gè)pinyin-pro體積小gzip 后 8KB 左右多音字識(shí)別率高支持自定義詞典推薦。pinyin老牌庫(kù)功能全但體積偏大。tiny-pinyin極簡(jiǎn)但不支持多音字適合只取首字母的場(chǎng)景。3.4 點(diǎn)擊表頭排序的表格實(shí)現(xiàn)管理后臺(tái)的表格基本都有“點(diǎn)擊表頭排序”的功能放在這里一起講因?yàn)楹诵倪壿嬐耆粯又皇嵌嗔朔较蚯袚Q和狀態(tài)管理。function sortTableByField(tableData, field, order) { const collator new Intl.Collator(zh-Hans-CN, { sensitivity: base }); return [...tableData].sort((a, b) { const result collator.compare(String(a[field] ?? ), String(b[field] ?? )); return order asc ? result : -result; }); }這里有一個(gè)經(jīng)驗(yàn)之談排序前一定要先復(fù)制數(shù)組用[...tableData]而不是直接tableData.sort(...)。尤其在 React/Vue 這種數(shù)據(jù)驅(qū)動(dòng)視圖的框架里直接修改原數(shù)組會(huì)導(dǎo)致你在setState或reactive里很難追蹤數(shù)據(jù)變化容易觸發(fā)重復(fù)渲染、列表 key 錯(cuò)亂等一堆問(wèn)題。很多搜索詞里出現(xiàn)的“點(diǎn)擊表頭排序”“字符串排序”“js 忽略大小寫(xiě)”都是在這個(gè)場(chǎng)景下衍生的。實(shí)際項(xiàng)目里涉及中英文混排時(shí)sensitivity: base就能同時(shí)解決忽略大小寫(xiě)的問(wèn)題。4. 多音字、生僻字與邊界場(chǎng)景處理4.1 多音字為什么不能完美解決這是漢字拼音排序最讓人頭疼的部分。同一個(gè)字在不同詞語(yǔ)里讀音可能完全不同而 ICU 內(nèi)置的排序規(guī)則只能根據(jù)字本身的常見(jiàn)讀音來(lái)排。舉幾個(gè)典型例子“重慶”的“重”讀 chóng但默認(rèn)字典可能按 zhòng 處理“廈門(mén)”的“廈”讀 xià但默認(rèn)可能按 shà 處理“單”作姓氏讀 shàn作形容詞讀 dān“解”作姓氏讀 xiè我實(shí)測(cè)過(guò) Chrome 最新版“重慶”能正確排到 C 組但“單田芳”“解曉東”這類(lèi)人名的姓氏讀音就很不穩(wěn)定。這不是 JS 的 bug而是 ICU 字典覆蓋不了所有上下文。4.2 拼音庫(kù)兜底與自定義詞典如果你的業(yè)務(wù)對(duì)多音字準(zhǔn)確率有硬性要求我推薦一個(gè)三層策略自定義詞典優(yōu)先把業(yè)務(wù)里高頻出現(xiàn)的特殊讀音詞條維護(hù)成一個(gè) JSON排序前先查這個(gè)詞條。拼音庫(kù)兜底沒(méi)命中詞典的詞用 pinyin-pro 這種帶多音字詞庫(kù)的庫(kù)生成拼音。原生 API 保底如果用戶(hù)環(huán)境不支持 ES Module 或者不想引庫(kù)退回到localeCompare。pinyin-pro 支持傳入自定義拼音import { pinyin } from pinyin-pro; const customDict { 重慶: chong qing, 廈門(mén): xia men, 單田芳: shan tian fang, 解曉東: xie xiao dong }; function sortWithDict(arr) { return arr.sort((a, b) { const pa customDict[a] || pinyin(a, { toneType: none, type: array }).join( ); const pb customDict[b] || pinyin(b, { toneType: none, type: array }).join( ); return pa.localeCompare(pb, zh-Hans-CN, { sensitivity: base }); }); }這個(gè)方法的好處是詞典只管特殊詞常見(jiàn)詞交給拼音庫(kù)整體可控性很強(qiáng)。壞處也很明顯需要一個(gè)維護(hù)成本。我會(huì)把這個(gè)詞典放到后端接口里下發(fā)這樣前端不用發(fā)版就能更新讀音數(shù)據(jù)。4.3 中英文混合、數(shù)字開(kāi)頭與特殊字符中文排序場(chǎng)景里經(jīng)?;熘⑽?、數(shù)字和標(biāo)點(diǎn)這類(lèi)邊角情況需要單獨(dú)處理。我的經(jīng)驗(yàn)是先按“首字符類(lèi)型”分桶再做組內(nèi)排序。比如約定排序規(guī)則為數(shù)字 英文 中文或者反過(guò)來(lái)。不然的話“123”和“abc”和“張三”混在一起比較很容易出現(xiàn)預(yù)期之外的順序。function mixedSort(arr) { const collator new Intl.Collator(zh-Hans-CN, { sensitivity: base, numeric: true }); return [...arr].sort((a, b) { const typeA getType(a); const typeB getType(b); if (typeA ! typeB) return typeA - typeB; return collator.compare(a, b); }); } function getType(str) { const first str.charAt(0); if (/[0-9]/.test(first)) return 0; if (/[a-zA-Z]/.test(first)) return 1; return 2; }這個(gè)“先分桶再排序”的思路比在比較函數(shù)里寫(xiě)一堆正則判斷要清晰得多也好維護(hù)。5. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄5.1 大小寫(xiě)和音調(diào)干擾排序現(xiàn)象apple和Apple被排到完全不同的位置或者“媽”(mā) 和“馬”(mǎ) 被當(dāng)成不同字處理。原因localeCompare默認(rèn)的分級(jí)是variant會(huì)區(qū)分大小寫(xiě)、重音和音調(diào)。拼音排序的預(yù)期通常不關(guān)心聲調(diào)。解決設(shè)置sensitivity: base。const collator new Intl.Collator(zh-Hans-CN, { sensitivity: base });這個(gè)選項(xiàng)我在多個(gè)項(xiàng)目里都是固定配置省心。5.2 不同瀏覽器與 Node 版本的結(jié)果差異最讓人無(wú)語(yǔ)的坑同一份代碼Chrome 里排序正常Firefox 里順序不一樣Node 里又是第三種結(jié)果。根源是 ICU 數(shù)據(jù)版本不一致。瀏覽器各自?xún)?nèi)置的 ICU 完整度不同尤其老版本 Safari 和某些國(guó)產(chǎn)瀏覽器中文排序支持非常薄弱。Node.js 環(huán)境更明顯。Node 14 之前的默認(rèn)構(gòu)建只帶small-icu只包含很少的語(yǔ)言數(shù)據(jù)中文排序大概率直接用不了。我踩過(guò)這個(gè)坑之后排查步驟固定如下直接在 Node 里跑一次北京.localeCompare(上海, zh-Hans-CN)看結(jié)果是不是負(fù)數(shù)。如果是 0相等說(shuō)明 ICU 數(shù)據(jù)不完整考慮升級(jí) Node或者用full-icu包補(bǔ)全數(shù)據(jù)。如果業(yè)務(wù)必須跨端保持順序一致最穩(wěn)妥的辦法是服務(wù)端返回拼音字段前端只做字段字符串比較。5.3 大列表性能優(yōu)化一萬(wàn)條數(shù)據(jù)的數(shù)組排序Intl.Collator大概幾十毫秒看起來(lái)不慢。但如果每條數(shù)據(jù)里還要做拼音轉(zhuǎn)換比如為了首字母分組性能就會(huì)明顯劣化。優(yōu)化方案是緩存。拼音轉(zhuǎn)換的結(jié)果是確定性的同一個(gè)字永遠(yuǎn)轉(zhuǎn)出同一個(gè)拼音。用Map做一層緩存const pinyinCache new Map(); function getPinyinWithCache(text) { if (pinyinCache.has(text)) { return pinyinCache.get(text); } const result pinyin(text, { toneType: none, type: array }).join( ); pinyinCache.set(text, result); return result; }實(shí)務(wù)里這個(gè)緩存效果立竿見(jiàn)影。通訊錄這種重復(fù)姓氏很多的場(chǎng)景緩存命中率能到 95% 以上排序耗時(shí)基本可以忽略。5.4 排序結(jié)果和接口對(duì)不上有一種很常見(jiàn)的場(chǎng)景前端自己排了一版后端接口也有一個(gè)排序字段兩邊結(jié)果不一致產(chǎn)品經(jīng)理來(lái)問(wèn)。我遇到過(guò)幾次最后定位到的原因都是前端的排序規(guī)則和后端的排序規(guī)則不是同一套。比如后端用的是 MySQL 的ORDER BY CONVERT(name USING gbk)按 GBK 編碼順序排而前端用的是拼音兩邊的“字典序”概念根本不一樣。這種事靠前端調(diào)代碼是調(diào)不齊的。正確做法是排序規(guī)則以一端為準(zhǔn)。要么后端把所有數(shù)據(jù)排好再返回前端只做展示要么后端把每個(gè)字段的拼音串作為獨(dú)立字段返回前端直接用String.prototype.localeCompare做普通字符串比較。兩邊都嚴(yán)格走同一條拼音數(shù)據(jù)鏈路才能保證一致。6. 寫(xiě)在最后方向比代碼更重要做前端這幾年我最大的感受是像“漢字按拼音排序”這種需求真正難的往往不是那一行sort代碼而是你選哪條路走下去。如果你只做一次簡(jiǎn)單的城市列表排序原生Intl.Collator一行就夠。如果你做的是通訊錄、企業(yè)組織架構(gòu)、后臺(tái)表格這種對(duì)準(zhǔn)確率和性能都有要求的場(chǎng)景別猶豫直接上“拼音庫(kù)自定義詞典緩存”的組合。要記住一個(gè)原則方案復(fù)雜度永遠(yuǎn)跟著業(yè)務(wù)需求走不要為了技術(shù)炫耀引入復(fù)雜度也不要為了省事而犧牲準(zhǔn)確率。最后分享一個(gè)排查排序問(wèn)題的小工具。我會(huì)在控制臺(tái)里跑這樣一段代碼快速看當(dāng)前運(yùn)行環(huán)境對(duì)中文排序的支持情況console.log(北京.localeCompare(上海, zh-Hans-CN)); // 期望輸出負(fù)數(shù)因?yàn)?bei shang如果輸出 0 或正數(shù)說(shuō)明環(huán)境有問(wèn)題這個(gè)簡(jiǎn)單的驗(yàn)證步驟能幫你省掉后面排查環(huán)境的非常多時(shí)間。排序看起來(lái)是小事但踩過(guò)坑的人都知道它有時(shí)候比寫(xiě)一個(gè)業(yè)務(wù)模塊還磨人。希望這篇文章能讓你少走幾步彎路。