☰
JavaScript实现汉字按拼音排序的完整方案与避坑指南
2026/10/7 13:59:17 网站建设 项目流程

做前端这几年,凡是跟“排序”沾边的需求,十有八九会碰到中文按拼音排序的问题。通讯录要按姓名首字母分组,城市选择器要把“重庆”排在C组而不是Z组,商品列表要按名称的拼音顺序展示。每次遇到这种需求,总有同事第一反应是直接调sort(),结果一跑,发现“北京”排在“上海”后面,直接傻眼。

这个标题看着简单,真做起来坑比想象中多。今天就把 JavaScript 里汉字按拼音 a-z 排序这件事彻底讲透,从原生 API 的用法到多音字的坑,从数组排序到通讯录分组,全是我实际踩过坑之后沉淀下来的方案,可以直接抄作业。

1. 需求拆解与实现思路

1.1 汉字排序的难点在哪里

首先要搞清楚一个问题:为什么 JS 原生的排序不能直接用?

JavaScript 的Array.prototype.sort()默认把元素转成字符串,然后按 UTF-16 编码单元的数值大小排序。问题在于,汉字在 Unicode 里的编码顺序是按照部首和笔画来的,跟拼音没有任何关系。

举个例子,“爱”的 Unicode 码点是\u7231,“北”是\u5317,“城”是\u57CE。默认sort()排序时,“北”(0x5317) 排在“城”(0x57CE) 前面,“城”又排在“爱”(0x7231) 前面。但按拼音应该是“爱”(ai) → “北”(bei) → “城”(cheng),顺序完全乱套。

这背后涉及的机制是 ICU(International Components for Unicode)。现代浏览器和 Node.js 在实现localeCompare和Intl.Collator时,会调用 ICU 内置的语言排序规则。中文排序规则里面包含了拼音字典数据,所以它知道“爱”对应的拼音是“ai”,“北”是“bei”。但前提是:你要把这个语言环境参数正确传进去。

1.2 方案选型:原生 API 还是拼音库

我梳理了当前主流的三种方案,先做个对比:

方案实现方式优点缺点
A字符串调用localeCompare(b, 'zh-Hans-CN')原生支持,无需依赖,性能好多音字受限于 ICU 字典,个别字不准
BIntl.Collator实例化比较器可复用实例,适合高频比较,参数更精细底层逻辑跟 A 一样,多音字问题同样存在
C引入 pinyin / pinyin-pro 拼音库能拿到完整拼音和首字母,控制力最强增加包体积,大列表场景需要做缓存

我的习惯是:如果只是排序,优先用方案 A 或 B;如果需要拿拼音做更多事情(搜索、分组、首字母索引),再引入拼音库。为了一个排序功能就甩一个几百 KB 的库进去,完全不划算。

另外说一句,别迷信哪种方案一定“最正确”。我见过有人只凭一两个用例就断定localeCompare不行,结果换成拼音库又得处理各种多音字词库,反而更麻烦。选型的核心依据是业务场景对准确率的要求,以及你手里已有的依赖。

2. localeCompare 与 Intl.Collator 核心用法

2.1 基础语法和参数拆解

localeCompare的完整语法是:

string.localeCompare(compareString, locales, options)

三个参数里,locales和options都可以省略。但做中文拼音排序时,这两个参数恰恰是关键。

先看locales。要传中文语言标签:

const arr = ['重庆', '北京', '上海', '深圳', '广州']; arr.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')); console.log(arr); // 期望结果:['北京', '重庆', '广州', '上海', '深圳']

这里有几个常见的语言标签写法:'zh'、'zh-CN'、'zh-Hans-CN'、'zh-Hant-TW'。在大多数现代浏览器里,简体中文的排序结果差异不大。但有一个细节我测试过:如果你不传或只传'zh',某些浏览器的默认 ICU 数据可能拿不到简体中文的排序规则,结果退化到按 Unicode 码点排序。所以稳妥的做法是写'zh-Hans-CN'这种完整的 BCP 47 标签。

再来看options。这个参数控制比较精度,最常用的几个选项:

const collator = new Intl.Collator('zh-Hans-CN', { sensitivity: 'base', numeric: true, ignorePunctuation: true });
  • sensitivity: 'base':忽略大小写、重音符号和声调,只区分字母本身。做拼音排序时最推荐这个。
  • numeric: true:排序时把数字按数值比较,而不是字符串字典序。这样“第2章”会排在“第10章”前面。
  • ignorePunctuation: true:忽略标点符号对排序的影响。

2.2 两种 API 的差异与性能对比

localeCompare和Intl.Collator底层是同一套 ICU 排序规则,功能上等价。区别在于使用方式:

// 不推荐:每次比较都走一遍内部逻辑 arr.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')); // 推荐:先创建比较器,复用 compare 方法 const collator = new Intl.Collator('zh-Hans-CN', { sensitivity: 'base' }); arr.sort(collator.compare);

Intl.Collator实例化一次后,compare方法可以直接复用。对于反复排序的场景,比如用户点了好几次表头切换升降序,性能差距会很明显。我实际测过一万条数据的数组,复用 Collator 比每次调用localeCompare快约 30%~40%。

另外提醒一句:collator.compare是一个函数引用,直接传给sort没问题。但要注意箭头函数的写法会让this绑定失效,这个在比较器里通常不影响,因为compare不依赖this。

2.3 关于排序稳定性

ES2019 之后,Array.prototype.sort被要求是稳定排序。也就是说,如果两个元素的比较结果相同,它们原来的相对顺序会被保留。

这一点对中文排序挺重要。比如你有一个列表,先按创建时间排好,再按拼音排序。当拼音相同时,稳定排序能保留之前的时间顺序,用户看到的就是“拼音相同再按时间排”的效果。老一些的浏览器(比如 IE11 和某些旧版 WebView)不是稳定排序,如果你需要兼容它们,就得自己加一个序号字段作为第二比较键。

users.sort((a, b) => { const result = collator.compare(a.name, b.name); if (result !== 0) return result; return a.createTime - b.createTime; // 第二排序键 });

3. 完整项目实现:从数组到通讯录分组

3.1 纯字符串数组排序

最基础的场景,直接把城市列表按拼音排序:

const cities = ['重庆', '北京', '上海', '深圳', '广州', '成都', '杭州']; const collator = new Intl.Collator('zh-Hans-CN', { sensitivity: 'base' }); cities.sort(collator.compare); console.log(cities); // ['北京', '成都', '重庆', '广州', '杭州', '上海', '深圳']

注意观察“重庆”的位置,它在“成都”之后、“广州”之前,说明“重”确实被识别为 chong,而不是 zhong。这是大多数现代浏览器中 ICU 字典的表现,但我在某些旧版 Android WebView 里测过,“重庆”会被排到 Z 组去。如果你的用户群里有大量旧设备,最好用我后面说的“拼音库+自定义词典”兜底方案。

3.2 对象数组按拼音字段排序

实际业务中更多是对象数组,比如人员列表按姓名排序:

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)); // 按 陈、李、王、张 的顺序排列

这里有一个必须处理的情况:name字段可能为空或undefined。不处理的话,collator.compare会直接抛异常。我的习惯是先做空值判断:

users.sort((a, b) => { const nameA = a.name ?? ''; const nameB = b.name ?? ''; return collator.compare(nameA, nameB); });

空字符串的排序位置在最前面,符合大多数业务直觉。

3.3 通讯录首字母分组实战

“按首字母分组”和“按拼音排序”是一对孪生需求,通讯录、城市索引都用得到。这里需要拿到每个汉字的首字母,原生Intl.Collator只能比较排序,不能输出拼音字符串,所以我会引入 pinyin-pro 这个库。

先安装:

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: '张三' }]

关于拼音库选型,我对比过pinyin、pinyin-pro、tiny-pinyin这几个:

  • pinyin-pro:体积小(gzip 后 8KB 左右),多音字识别率高,支持自定义词典,推荐。
  • pinyin:老牌库,功能全但体积偏大。
  • tiny-pinyin:极简,但不支持多音字,适合只取首字母的场景。

3.4 点击表头排序的表格实现

管理后台的表格基本都有“点击表头排序”的功能,放在这里一起讲,因为核心逻辑完全一样,只是多了方向切换和状态管理。

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; }); }

这里有一个经验之谈:排序前一定要先复制数组,用[...tableData]而不是直接tableData.sort(...)。尤其在 React/Vue 这种数据驱动视图的框架里,直接修改原数组会导致你在setState或reactive里很难追踪数据变化,容易触发重复渲染、列表 key 错乱等一堆问题。

很多搜索词里出现的“点击表头排序”“字符串排序”“js 忽略大小写”都是在这个场景下衍生的。实际项目里涉及中英文混排时,sensitivity: 'base'就能同时解决忽略大小写的问题。

4. 多音字、生僻字与边界场景处理

4.1 多音字为什么不能完美解决

这是汉字拼音排序最让人头疼的部分。同一个字在不同词语里读音可能完全不同,而 ICU 内置的排序规则只能根据字本身的常见读音来排。

举几个典型例子:

  • “重庆”的“重”读 chóng,但默认字典可能按 zhòng 处理
  • “厦门”的“厦”读 xià,但默认可能按 shà 处理
  • “单”作姓氏读 shàn,作形容词读 dān
  • “解”作姓氏读 xiè

我实测过 Chrome 最新版,“重庆”能正确排到 C 组,但“单田芳”“解晓东”这类人名的姓氏读音就很不稳定。这不是 JS 的 bug,而是 ICU 字典覆盖不了所有上下文。

4.2 拼音库兜底与自定义词典

如果你的业务对多音字准确率有硬性要求,我推荐一个三层策略:

  1. 自定义词典优先:把业务里高频出现的特殊读音词条维护成一个 JSON,排序前先查这个词条。
  2. 拼音库兜底:没命中词典的词,用 pinyin-pro 这种带多音字词库的库生成拼音。
  3. 原生 API 保底:如果用户环境不支持 ES Module 或者不想引库,退回到localeCompare。

pinyin-pro 支持传入自定义拼音:

import { pinyin } from 'pinyin-pro'; const customDict = { '重庆': 'chong qing', '厦门': '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' }); }); }

这个方法的好处是:词典只管特殊词,常见词交给拼音库,整体可控性很强。坏处也很明显,需要一个维护成本。我会把这个词典放到后端接口里下发,这样前端不用发版就能更新读音数据。

4.3 中英文混合、数字开头与特殊字符

中文排序场景里经常混着英文、数字和标点,这类边角情况需要单独处理。

我的经验是:先按“首字符类型”分桶,再做组内排序。比如约定排序规则为:数字 > 英文 > 中文,或者反过来。不然的话,“123”和“abc”和“张三”混在一起比较,很容易出现预期之外的顺序。

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; }

这个“先分桶再排序”的思路,比在比较函数里写一堆正则判断要清晰得多,也好维护。

5. 常见问题与排查技巧实录

5.1 大小写和音调干扰排序

现象:"apple"和"Apple"被排到完全不同的位置,或者“妈”(mā) 和“马”(mǎ) 被当成不同字处理。

原因:localeCompare默认的分级是variant,会区分大小写、重音和音调。拼音排序的预期通常不关心声调。

解决:设置sensitivity: 'base'。

const collator = new Intl.Collator('zh-Hans-CN', { sensitivity: 'base' });

这个选项我在多个项目里都是固定配置,省心。

5.2 不同浏览器与 Node 版本的结果差异

最让人无语的坑:同一份代码,Chrome 里排序正常,Firefox 里顺序不一样,Node 里又是第三种结果。

根源是 ICU 数据版本不一致。浏览器各自内置的 ICU 完整度不同,尤其老版本 Safari 和某些国产浏览器,中文排序支持非常薄弱。

Node.js 环境更明显。Node 14 之前的默认构建只带small-icu,只包含很少的语言数据,中文排序大概率直接用不了。我踩过这个坑之后,排查步骤固定如下:

  1. 直接在 Node 里跑一次'北京'.localeCompare('上海', 'zh-Hans-CN'),看结果是不是负数。
  2. 如果是 0(相等),说明 ICU 数据不完整,考虑升级 Node,或者用full-icu包补全数据。
  3. 如果业务必须跨端保持顺序一致,最稳妥的办法是服务端返回拼音字段,前端只做字段字符串比较。

5.3 大列表性能优化

一万条数据的数组排序,Intl.Collator大概几十毫秒,看起来不慢。但如果每条数据里还要做拼音转换(比如为了首字母分组),性能就会明显劣化。

优化方案是缓存。拼音转换的结果是确定性的,同一个字永远转出同一个拼音。用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; }

实务里这个缓存效果立竿见影。通讯录这种重复姓氏很多的场景,缓存命中率能到 95% 以上,排序耗时基本可以忽略。

5.4 排序结果和接口对不上

有一种很常见的场景:前端自己排了一版,后端接口也有一个排序字段,两边结果不一致,产品经理来问。

我遇到过几次,最后定位到的原因都是:前端的排序规则和后端的排序规则不是同一套。比如后端用的是 MySQL 的ORDER BY CONVERT(name USING gbk),按 GBK 编码顺序排,而前端用的是拼音,两边的“字典序”概念根本不一样。

这种事靠前端调代码是调不齐的。正确做法是:排序规则以一端为准。要么后端把所有数据排好再返回,前端只做展示;要么后端把每个字段的拼音串作为独立字段返回,前端直接用String.prototype.localeCompare做普通字符串比较。两边都严格走同一条拼音数据链路,才能保证一致。

6. 写在最后:方向比代码更重要

做前端这几年,我最大的感受是:像“汉字按拼音排序”这种需求,真正难的往往不是那一行sort代码,而是你选哪条路走下去。

如果你只做一次简单的城市列表排序,原生Intl.Collator一行就够。如果你做的是通讯录、企业组织架构、后台表格这种对准确率和性能都有要求的场景,别犹豫,直接上“拼音库+自定义词典+缓存”的组合。要记住一个原则:方案复杂度永远跟着业务需求走,不要为了技术炫耀引入复杂度,也不要为了省事而牺牲准确率。

最后分享一个排查排序问题的小工具。我会在控制台里跑这样一段代码,快速看当前运行环境对中文排序的支持情况:

console.log('北京'.localeCompare('上海', 'zh-Hans-CN')); // 期望输出负数(因为 bei < shang),如果输出 0 或正数,说明环境有问题

这个简单的验证步骤,能帮你省掉后面排查环境的非常多时间。排序看起来是小事,但踩过坑的人都知道,它有时候比写一个业务模块还磨人。希望这篇文章能让你少走几步弯路。

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

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

立即咨询