ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

汉字首字母分组排序:从拼音转换到通讯录索引的完整实战方案

汉字首字母分组排序:从拼音转换到通讯录索引的完整实战方案 有段时间我在做后台管理系统的通讯录模块产品提了个需求联系人列表要按照姓名首字母A-Z分组右侧带一个字母索引条点击“L”能直接跳到“李”开头的那一片。听起来不难不就是把中文转成拼音首字母然后分组排序嘛但真动手写的时候才发现坑比想象的多。汉字不像英文那样天然有26个字母它没有“字母”这个属性你得先想办法把汉字变成拼音再从拼音里抠出首字母中间还要处理多音字、非汉字字符、大小写、空值这些边角料。这篇文章就把我实际用过的方案、踩过的坑和最终沉淀下来的代码完整写出来给同样要做“汉字按首字母分组排序”的朋友一个能直接抄作业的参考。1. 先搞清楚需求为什么首字母分组不是简单排序1.1 这个需求到底长什么样首字母分组排序最常见的地方就是通讯录、城市选择器、音乐列表、商品品牌筛选。你有一串中文名称比如“张三”“李四”“王五”“安娜”产品要求界面左侧是A到Z的字母导航右侧是对应的名字列表点哪个字母就滚到哪个字母的区域。这个需求拆开其实是两件事第一分词分组把同一首字母的名字归到一组第二组内排序让同一组里的名字也有顺序。听起来像废话但很多人第一步就搞错了他们拿sort()直接对中文做字典序排序结果发现“安”跑到了“张”后面因为JavaScript默认比较的是Unicode码点而不是拼音。再往深里说这个需求的本质是“把汉字这种不以字母为基础的文字映射到一个以字母为基础的索引体系里”。英文世界没有这个问题“Alice”“Bob”天然就能分到A组和B组中文必须先转拼音、再取首字母中间的转换质量直接决定最终分组的准确性。1.2 汉字排序为什么不能直接sort如果你写过[张, 安, 李].sort()会发现结果并不是按拼音来的。原因很简单JavaScript的默认字符串比较走的是UTF-16码元顺序“安”的Unicode码点是U5B89“张”是U5F20码点大小决定顺序所以排出来是按笔画还是按什么别的反正不是拼音。有人会说用localeCompare(zh-CN)不就行了确实在现代浏览器和Node环境里localeCompare在zh语言环境下可以按拼音比较但它依赖运行环境的ICUInternational Components for Unicode实现不同平台、不同Node版本的表现可能不完全一致。而且它解决的是“排序”问题不解决“取首字母”的问题。说到底首字母分组的核心链路是汉字字符串 - 拼音 - 拼音首字母 - 大写字母 - 按字母分组。排序只是最后一公里前面的拼音转换才是重头戏。2. 方案选型拼音库怎么选分组策略怎么定2.1 拼音库选型对比要把汉字转成拼音手写一个声韵母表不现实更靠谱的是用现成的库。社区里比较主流的两个是node-pinyin和pinyin-pro我在项目里两个都用过说说实际感受。对比项node-pinyinpinyin-pro包体积较大内置完整字典较小支持按需引入多音字识别较弱“重庆”可能取成“zhong”支持常见多音字可自定义修正API设计返回数组/字符串参数略繁琐pinyin(str, options)直观简洁性能中规中矩较快支持流式处理大文本维护状态更新偏慢活跃文档完善我个人最终选了pinyin-pro主要看重它支持直接取首字母和声母代码写起来干净。核心API是这样的import { pinyin } from pinyin-pro; pinyin(张三); // 输出 zhāng sān pinyin(张三, { pattern: first, toneType: none }); // 输出 z s即每个字的首字母 pinyin(张三, { pattern: initial, toneType: none }); // 输出 zh s即每个字的声母pattern: first是取拼音首字母一个汉字返回一个单字母toneType: none是不带声调避免后面做排序和分组时被声调符号干扰。就这一个API已经覆盖了我们的核心需求。2.2 分组策略的整体设计拿到拼音之后思路就清晰了把每个汉字转成首字母转大写然后判断是不是A-Z中的一个是就进对应分组不是就放一个兜底的#组。这样设计有几个好处第一26个字母固定分组键可控前端渲染索引条的时候直接遍历A-Z拉一个有序数组就行。第二英文、数字、emoji这些非汉字字符不会让程序报错统一丢到#组保证数据不丢。第三组内排序可以叠加二次规则比如同一组内先按拼音排、再按笔画排扩展性有。数据流大概是这样的原始字符串数组 - 遍历每个元素 - 提取每个字符的首字母 - 取第一个有效的字母 - 加入对应分组 - 最后遍历每组做内部排序。3. 核心实现从零写出可用的分组排序函数3.1 完整代码分组函数groupByInitial直接贴我在项目里用的版本注释都写好了你可以复制过去改改用import { pinyin } from pinyin-pro; /** * 获取单个字符的首字母非汉字字符返回原字符的大写形式 * param {string} char 单个字符 * returns {string} 大写首字母若无法识别则返回空字符串 */ function getCharInitial(char) { if (!char) return ; const initial pinyin(char, { pattern: first, toneType: none, type: string }); return initial.toUpperCase(); } /** * 将中文字符串按首字母分组 * param {string[]} items 原始字符串数组比如 [张三, 李四, Anna, 123] * returns {Object} 分组结果如 { A: [Anna], L: [李四], Z: [张三], #: [123] } */ function groupByInitial(items) { const groups {}; for (const item of items) { const trimmed item.trim(); if (!trimmed) continue; // 空字符串跳过 // 取字符串第一个有效字符的首字母 // 如果是中文pinyin会返回拼音首字母如果是英文/数字直接用原字符 let key ; for (const ch of trimmed) { const initial getCharInitial(ch); // 过滤掉明显不是字母字符的比如空格、标点 if (/[A-Z]/.test(initial) || /[a-zA-Z0-9]/.test(ch)) { key initial || ch.toUpperCase(); break; } } // 兜底实在取不到有效字符的放到 # if (!key || !/[A-Z]/.test(key)) { key #; } if (!groups[key]) { groups[key] []; } groups[key].push(trimmed); } // 对每组内部排序 const result {}; Object.keys(groups) .sort((a, b) { // 字母组正常按字母排序#组排最后 if (a #) return 1; if (b #) return -1; return a.localeCompare(b); }) .forEach((key) { result[key] groups[key].sort((x, y) x.localeCompare(y, zh-CN)); }); return result; } // 使用示例 const names [张三, 李四, 王五, 安娜, Bob, 123, 赵六]; const grouped groupByInitial(names); console.log(grouped); // 输出大致为 // { // A: [安娜], // B: [Bob], // L: [李四], // W: [王五], // Z: [张三, 赵六], // #: [123] // }代码里有两个容易被忽视的点。第一个是key的取值逻辑我用了逐个字符去匹配因为有些名字可能以标点开头比如“·张三”你不用循环跳过去这个人的首字母就会变成空或者丢进#组体验很不好。第二个是组内排序用了localeCompare(zh-CN)这会在依赖ICU的环境里按拼音排同一组内“张三”和“赵六”就能正确排出先后。3.2 通讯录和表格排序的落地玩法拿到分组对象之后前端渲染就是水到渠成的事了。通讯录最典型右边一个索引条遍历A-Z遇到有数据的字母就高亮点击之后滚动到对应栏。你可以用scrollIntoView配合给每个分组容器加id或者用锚点跳转。数据量小的时候直接一次性渲染全部性能没压力。如果是后台管理系统的表格要按中文列排序那就更直接了把这一列所有的值都用上面的函数转成“拼音首字母字符串”然后把拼音作为排序键排序。pinyin-pro的pinyin(str)返回带声调的完整拼音你可以去掉声调作为排序键const sortKey pinyin(value, { toneType: none, type: array }).join(); // 比如 张三 - zhangsan这样排序键的长度是可预期的不会出现“只取首字母导致大量重复”的问题。表头点击排序时动态切换排序键就行。实测下来几千行数据完全秒排。3.3 扩展到其他技术栈如果你不在前端而是在Excel、SQL Server、Linux命令行里碰到同样需求方案不一样但思路一致核心还是“先把汉字映射到拼音/拼音首字母”再分组排序。我在一个Excel表哥的需求里就见过“汉字按首字母排序”的诉求Excel本身没有直接的拼音提取函数但可以用VBA调用Windows的拼音排序规则或者用Power Query配合一个拼音对照表实现。更省事的办法是先在Excel里加一列拼音首字母可以写一段VBA或爬一个对照表然后按辅助列排序完事以后辅助列隐藏掉。SQL Server里同样的问题更常见你要按中文拼音排序字段直接ORDER BY name时SQL Server默认按的是中文排序规则比如Chinese_PRC_CI_AS这种规则本身就是按拼音排的。但如果你要“分组按首字母”建议在表中加一个冗余字段initial写入时存好拼音首字母查询时GROUP BY initial性能和使用成本都低。MySQL 8.0以下的版本对中文排序支持比较弱加冗余字段几乎是最稳的路子。Linux命令行里给文件名做首字母统计也是一样的逻辑ls | sort在zh_CN.UTF-8的locale下会按拼音排序但如果文件名的首字母是中文字符sort处理靠的是glibc的locale数据不同发行版表现不一。用Python的pypinyin库做转换再排序反而是最可控的做法。3.4 一个完整的Vue通讯录示例把上面的函数接到Vue组件里一个可直接用的通讯录长这样只贴核心逻辑script setup import { computed } from vue; import { groupByInitial } from ./utils/groupByInitial; const props defineProps({ contacts: { type: Array, required: true } // 联系人数组 }); const grouped computed(() groupByInitial(props.contacts)); const alphabet ABCDEFGHIJKLMNOPQRSTUVWXYZ.split(); function scrollTo(key) { document.getElementById(group-${key})?.scrollIntoView({ behavior: smooth }); } /script template div classcontact-wrapper div classcontact-list div v-forletter in alphabet :keyletter classcontact-group div :idgroup-${letter} v-ifgrouped[letter]?.length classgroup-title {{ letter }} /div div v-forname in grouped[letter] :keyname classcontact-item {{ name }} /div /div /div div classindex-bar span v-forletter in alphabet :keyletter clickscrollTo(letter) {{ letter }} /span /div /div /template这里有一个实际经验索引条不要只渲染“有数据的字母”而是全部26个字母都渲染没数据的字母用灰色样式区分。产品看起来更直观用户也知道该字母当前没有联系人不会以为页面坏了。4. 边界情况与细节打磨4.1 多音字的坑和补救方案多音字是拼音方案最大的软肋。我做过一个实验拿“重庆”“长沙”“曾志伟”“解晓东”这些名字去跑默认字典下“重庆”会被识别成“zhong qing”“曾”可能被识别成“zeng”而不是“ceng”分组结果就错了。这个问题没法百分之百靠库解决只能从几个层面补救。第一业务层面能控的话录入数据时就人工标注读音存一个拼音字段。第二代码层面可以维护一个“自定义多音字词典”在取拼音之前先查表命中就用手动指定的拼音。pinyin-pro允许你传入自定义字典pinyin方法支持customDict参数。import { pinyin } from pinyin-pro; const customDict { // 表示遇到“重”字优先使用索引1的读音 chong 重: chong }; pinyin(重庆, { pattern: first, toneType: none, customDict });这个方法治标不治本因为业务数据是动态的不可能把所有多音字都收进字典。我的实际策略是默认分组先用通用字典跑然后在用户反馈或数据巡检时把高频错误的多音字单独加到自定义字典里做一个“针对性修正”平衡维护成本和准确性。4.2 非汉字字符、空数据与全半角问题真实业务数据永远比示例数据脏得多。我遇到过的几种情况这里一次性说清楚。空字符串和纯空格在groupByInitial里直接跳过不参与分组否则会出现一个只有空字符串的#组渲染出来是一排空白。以数字或英文开头的字符串比如“12345”、“iPhone手机”。我的策略是英文按字母进对应分组数字统一进#。代码里对数字字符的判断就是/[a-zA-Z0-9]/命中时取原字符的大写作为key但当key不是/[A-Z]/时就会被兜底逻辑丢到#符合预期。全半角问题全角数字“”和半角“123”看起来差不多但前者不是/[a-zA-Z0-9]/能识别的会被丢进#组。如果业务上要求兼容提前做一次全角转半角function fullToHalf(str) { return str.replace(/[\uFF01-\uFF5E]/g, (ch) String.fromCharCode(ch.charCodeAt(0) - 0xFEE0) ); }4.3 性能优化数据量大的时候怎么办几百条数据的通讯录直接遍历转拼音没有任何问题。但如果你的场景是几万条数据或者后端接口要给一批数据做排序就要考虑性能了。pinyin-pro的转换性能虽然不错但每个字符都要查字典几万条字符串一次性转完还是有明显耗时。我的经验是在前端做时把“原始字符串 - 首字母”的映射缓存起来用Map存同一个字符串只转一次。通讯录里重名率不低缓存命中率其实还行。在服务端做时提前在数据库里存好拼音首字母字段查询时直接GROUP BY initial不要在业务代码里实时算。如果数据是流式的、实时变化的可以在写入时就算好存起来读取时永远是现成的。缓存的具体实现很简单在groupByInitial外面套一个Mapconst initialCache new Map(); function getCachedInitial(str) { if (initialCache.has(str)) return initialCache.get(str); const initial pinyin(str, { pattern: first, toneType: none, type: string }); initialCache.set(str, initial); return initial; }5. 常见问题排查与避坑实录5.1 典型问题速查表把我在实际项目里遇到过的典型问题整理成了一张表排查的时候可以直接对着看。问题现象可能原因解决办法所有中文名字都进了#组拼音库没有正确安装/引入pinyin返回了空或原字符先打印pinyin(张)看输出确认是z而不是空首字母是小写分组对不上忘了调用toUpperCase()取到首字母后统一大写“重庆”分到C组而不是Z组多音字识别错误加自定义字典或人工标注同一组内顺序乱不是按拼音排组内排序没有用localeCompare(zh-CN)检查sort方法注意不要用默认的字典序英文名字跑到#组正则判断条件写错可能过滤掉了英文字符确保/[A-Z]/.test(initial)能命中英文大写带空格的“张 三”没分组空格参与循环导致key取错先trim()再处理SQL里按拼音排序结果不对表排序规则不是中文拼音规则加冗余拼音首字母字段或转换排序规则Excel里中文排序不按拼音Excel默认按笔画或不稳定用辅助列VBA取拼音首字母再按辅助列排序5.2 实际操作中总结的经验基于我自己的实操有几条经验如果你能提前知道能省不少麻烦。第一务必写一个覆盖A-Z全字母的测试用例。不要只拿三五条样本测那样根本测不出边界问题。我当时写了一个包含所有字母开头、以数字开头、以英文开头、多音字开头的测试集合每次改完代码跑一遍才放心。样例大概是这样的[安, 波, 陈, 董, 鄂, 冯, 高, 韩, 艾, 金, 李, 马, 牛, 欧, 彭, 秦, 任, 孙, 唐, 吴, 魏, 许, 杨, 张, 赵, 周, 123, Bob, 重庆, 曾志伟]。第二分组键最好用Map而不是普通对象。普通对象的键会被自动排序而且原型链上可能有意外属性用Map更干净。我在重写时把groups换成了Map减少了几个潜在bug。第三如果这个功能会开放给用户自定义数据比如用户自己维护一个联系人列表那么无论如何都要加一个“手动调整分组”的兜底入口。你最聪明的算法也猜不透“解晓东”应该读“谢”还是“解”让人工修正兜底是最务实的做法。第四注意拼音库的版本兼容性。pinyin-pro的API在不同大版本之间有过调整比如pattern: initial在某些老版本叫type: initial升级后要重新跑一遍测试用例。第五如果你的环境是Node服务端建议用Intl.Collator配合拼音做排序比如常见的“姓氏笔画排序”需求直接用Intl.Collator(zh-u-co-stroke)就能按笔画排不需要自己写算法。这类内置能力能不用库就不用库。我个人在使用中最大的体会是汉字首字母分组这个需求难点一般不在算法本身而在“脏数据”和“多音字”这两个不规律因素上。写代码前把数据清洗规则、多音字兜底策略想清楚比到处找完美的拼音库更重要。拼音库能解决九成场景剩下的一成靠自定义字典和人工修正兜底这才是能落地的方案。希望上面这些踩坑记录和代码片段能帮你少走几步弯路。
返回列表