☰
汉字首字母分组排序:从拼音转换到通讯录索引的完整实战方案
2026/9/26 23:01:19 网站建设 项目流程

有段时间我在做后台管理系统的通讯录模块,产品提了个需求:联系人列表要按照姓名首字母A-Z分组,右侧带一个字母索引条,点击“L”能直接跳到“李”开头的那一片。听起来不难,不就是把中文转成拼音首字母,然后分组排序嘛,但真动手写的时候才发现坑比想象的多。汉字不像英文那样天然有26个字母,它没有“字母”这个属性,你得先想办法把汉字变成拼音,再从拼音里抠出首字母,中间还要处理多音字、非汉字字符、大小写、空值这些边角料。这篇文章就把我实际用过的方案、踩过的坑和最终沉淀下来的代码完整写出来,给同样要做“汉字按首字母分组排序”的朋友一个能直接抄作业的参考。

1. 先搞清楚需求:为什么首字母分组不是简单排序

1.1 这个需求到底长什么样

首字母分组排序最常见的地方就是通讯录、城市选择器、音乐列表、商品品牌筛选。你有一串中文名称,比如“张三”“李四”“王五”“安娜”,产品要求界面左侧是A到Z的字母导航,右侧是对应的名字列表,点哪个字母就滚到哪个字母的区域。

这个需求拆开其实是两件事:第一,分词分组,把同一首字母的名字归到一组;第二,组内排序,让同一组里的名字也有顺序。听起来像废话,但很多人第一步就搞错了:他们拿sort()直接对中文做字典序排序,结果发现“安”跑到了“张”后面,因为JavaScript默认比较的是Unicode码点,而不是拼音。

再往深里说,这个需求的本质是“把汉字这种不以字母为基础的文字,映射到一个以字母为基础的索引体系里”。英文世界没有这个问题,“Alice”“Bob”天然就能分到A组和B组,中文必须先转拼音、再取首字母,中间的转换质量直接决定最终分组的准确性。

1.2 汉字排序为什么不能直接sort

如果你写过['张', '安', '李'].sort(),会发现结果并不是按拼音来的。原因很简单:JavaScript的默认字符串比较走的是UTF-16码元顺序,“安”的Unicode码点是U+5B89,“张”是U+5F20,码点大小决定顺序,所以排出来是按笔画还是按什么别的,反正不是拼音。

有人会说用localeCompare('zh-CN')不就行了?确实,在现代浏览器和Node环境里,localeCompare在zh语言环境下可以按拼音比较,但它依赖运行环境的ICU(International 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 class="contact-wrapper"> <div class="contact-list"> <div v-for="letter in alphabet" :key="letter" class="contact-group"> <div :id="`group-${letter}`" v-if="grouped[letter]?.length" class="group-title"> {{ letter }} </div> <div v-for="name in grouped[letter]" :key="name" class="contact-item"> {{ name }} </div> </div> </div> <div class="index-bar"> <span v-for="letter in alphabet" :key="letter" @click="scrollTo(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”和半角“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外面套一个Map:

const 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')就能按笔画排,不需要自己写算法。这类内置能力能不用库就不用库。

我个人在使用中最大的体会是:汉字首字母分组这个需求,难点一般不在算法本身,而在“脏数据”和“多音字”这两个不规律因素上。写代码前把数据清洗规则、多音字兜底策略想清楚,比到处找完美的拼音库更重要。拼音库能解决九成场景,剩下的一成靠自定义字典和人工修正兜底,这才是能落地的方案。希望上面这些踩坑记录和代码片段能帮你少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询