同一份列表,为什么“项目10”跑到了“项目2”前面?把比较函数改成中文排序后,两个同名条目的先后位置又为什么会变?
这两个问题需要分别处理:名称怎样比较,以及名称比较相等时,记录怎样排。给列表加一行sort(),并不等于把排序规则定义完整。
本文面向需要处理中文列表的 JavaScript 开发者,使用自造数据演示排序规则,不涉及真实用户数据或具体产品源码。内容及配图使用 AI 辅助,示例代码经过独立运行核验。
1. 先说清楚你想要哪一种“顺序”
下面四个需求,看起来都叫排序,实际规则不同:
| 需求 | 要先决定的规则 |
|---|---|
| 中文名称便于查找 | 拼音、笔画,还是业务自定顺序? |
| 项目1、项目2、项目10 | 编号中的数字是否按数值比较? |
| 两条记录名称相同 | 保留输入顺序,还是用稳定的唯一 ID 决定? |
| 名称缺失或只含空格 | 放到末尾、放到开头,还是拒绝这类数据? |
默认的Array.prototype.sort()会把元素转换为字符串,按 UTF-16 码元顺序比较;它不会主动推断中文拼音规则。sort()还会改变被调用的数组,所以想保留原数组顺序,可以先复制再排序。参见 MDN:Array.prototype.sort()。
2. 让 Intl.Collator 负责名称比较
Intl.Collator提供与语言相关的字符串比较。它的compare(a, b)返回负数、零或正数;判断方向时看符号即可,不要把返回值写死为-1或1。参见 MDN:Intl.Collator。
constnameOrder=newIntl.Collator("zh-CN-u-co-pinyin",{usage:"sort",numeric:true,sensitivity:"variant"});constlabels=["项目10","项目2","项目1"];constorderedLabels=[...labels].sort(nameOrder.compare);console.log(orderedLabels);// 本次环境:["项目1", "项目2", "项目10"]console.log(nameOrder.resolvedOptions());这组配置提出三个明确要求:申请中文拼音排序;把数字片段按数值比较;保留较细的字符差别。它并不是适合所有业务的默认答案。
例如,numeric: true很适合“项目2/项目10”这种编号,却不是版本号解析器。对于v1.2.0、v1.2.0-beta,应先定义版本语义;不要仅凭几个样例看起来顺眼,就推断完整版本排序也正确。
还要区分usage: "sort"与usage: "search":本文使用前者。后者用于按完整字符串进行匹配判断,不应拿其非零结果做顺序排序,也不是子串搜索接口。选项含义可查 MDN:Intl.Collator 构造函数。
配置会经过运行环境解析,实际采用情况可以用resolvedOptions()观察。本文环境里,中文请求的collation显示为default;不要只看到这个字样就断言“没按中文排”,也不要把传入参数当作跨环境验证结果。应结合实际 locale、选项和样例核对。参见 MDN:resolvedOptions()。
3. 处理真实列表时,还缺两层规则
设想一个通用项目列表:同名记录允许存在,暂未命名的记录也需要保留。这里选择如下规则:
- 字符串名称先去掉首尾空格;非字符串、缺失值和空白名称按“未命名”处理。
- 有名称的记录在前;同组按
nameOrder比较。 - 名称比较相等时,用唯一 ID 排序,避免仅因接口返回顺序不同就交换位置。
图:本文示例的三层比较规则。
这只是本文的业务约定。若非字符串代表数据错误,你可以改成校验失败,不必照搬“按未命名处理”。ID 在这里约定为唯一、非空的 ASCII 字符串;数据进入函数前应满足这一条件。
functiontextKey(value){returntypeofvalue==="string"?value.trim():"";}functioncompareId(a,b){// ID 仅作最后一层确定次序,不按中文名称规则比较。returna<b?-1:a>b?1:0;}functioncomparePrepared(a,b){constemptyA=a.key==="";constemptyB=b.key==="";if(emptyA!==emptyB)returnemptyA?1:-1;returnnameOrder.compare(a.key,b.key)||compareId(a.row.id,b.row.id);}functionorderRows(rows){returnrows.map(row=>({row,key:textKey(row.name)})).sort(comparePrepared).map(item=>item.row);}constrecords=[{id:"r4",name:"项目10"},{id:"r3",name:"项目2"},{id:"r2",name:"项目2"},{id:"r5",name:" "},{id:"r1",name:"项目1"}];console.log(orderRows(records).map(row=>row.id));// 本次环境:["r1", "r2", "r3", "r4", "r5"]这里提前为每条记录计算名称键,比较时复用同一个 Collator。函数没有改变输入数组顺序,也没有改写对象属性;返回的是新数组,里面仍是原来的对象引用,并不是深拷贝。
“比较相等”同样不表示“同一条数据”。中文规则、数字规则或其他配置可能使不同字符串比较为零。不能因此合并联系人、覆盖记录,或拿这个结果代替账号 ID 判断。
如果你希望相等时保持原输入顺序,可以不加 ID 这一层;现代标准要求稳定排序。但输入顺序本来就会变化时,稳定排序并不能替你统一每次刷新后的次序。应按产品需求选择。参见 MDN:排序稳定性。
4. 不只验证“排出来看着还行”
下面的验证代码接在前两个代码块后,可以在 Node.js 中运行。它检查数字顺序、同名记录、空名称、输入保留,以及比较函数的自反性、反对称性和传递性。这里的数学性质检查只覆盖列出的样本集合。
constassert=require("node:assert/strict");constids=rows=>rows.map(row=>row.id);constbefore=JSON.stringify(records);assert.deepEqual(orderedLabels,["项目1","项目2","项目10"]);assert.deepEqual(ids(orderRows(records)),["r1","r2","r3","r4","r5"]);assert.equal(JSON.stringify(records),before);assert.notEqual(orderRows(records),records);assert.equal(orderRows(records)[0],records[4]);assert.deepEqual(ids(orderRows([...records].reverse())),ids(orderRows(records)));assert.deepEqual(ids(orderRows(orderRows(records))),ids(orderRows(records)));assert.deepEqual(orderRows([]),[]);assert.deepEqual(ids(orderRows([{id:"b",name:null},{id:"a",name:" "},{id:"c",name:" 项目1 "}])),["c","a","b"]);assert.equal(textKey(123),"");assert.deepEqual(["张禾","王宁","李乔","陈安"].sort(nameOrder.compare),["陈安","李乔","王宁","张禾"]);constsample=[...records,{id:"r6",name:"项目02"},{id:"r7",name:"陈安"},{id:"r8",name:null},{id:"r9",name:" 项目1 "}].map(row=>({row,key:textKey(row.name)}));for(constaofsample){assert.equal(comparePrepared(a,a),0);for(constbofsample){constab=Math.sign(comparePrepared(a,b));constba=Math.sign(comparePrepared(b,a));assert.ok(ab===-ba);for(constcofsample){if(comparePrepared(a,b)<=0&&comparePrepared(b,c)<=0){assert.ok(comparePrepared(a,c)<=0);}}}}console.log("示例断言通过");本次核验环境为Node.js v24.19.0、ICU 78.3。实际运行的是本文三个代码块,全部断言通过;没有据此宣称浏览器、数据库或 App 真机测试已经完成。
这些中文名字的期望顺序是本轮样例,用于发现配置和环境变化,不是“所有中文姓名都能正确辨音”的保证。遇到多音姓氏、人工指定读音、企业简称等要求,建议把经确认的排序字段作为业务数据维护。Intl.Collator返回比较关系,不会替你返回一份姓名拼音表。
5. 分页、跨端一致性要另外设计
如果接口只返回第 1 页的 20 条数据,浏览器重排这 20 条,只能得到这一页内部的顺序。第 2 页里本应更靠前的记录,不会因此自动挪过来。需要全量有序分页时,应让负责分页的一端先完成统一排序,并明确同名记录的最后比较字段。
对前后端结果必须一致的系统,建议同时记录排序策略、运行环境和代表性样例,而不只记录一段 locale 字符串。语言排序依赖相应数据与规则,规则也可能随版本演进;ICU 文档对不同语言、用途和排序约定的差异有专门说明:ICU:Collation。
验收时可以直接问三个具体问题:空名称放哪;同名数据靠什么分先后;分页发生在排序之前还是之后。把这三件事说清楚,往往比继续给比较函数叠加选项更有帮助。
实际接入时,先明确数据约束和分页位置,再用上述边界样本验证比较规则。本文示例没有实现输入校验或服务端分页,这两部分仍需结合具体系统补充。