
一份通讯录按姓氏排序,屏幕上出来的顺序看着不太对:陈在最前面,周跑到最后,中间那段像被人重新洗过。名字一个没少,就是整列的顺序和拼音对不上。把这批名字丢给 node 排一遍,先看到的是开销那一栏:5 万个名字排一次,按元素新建排序器要 1113 毫秒,复用同一个只要 18 毫秒。
六种比较器排同一批名字
== 0. 运行时与默认区域 ==
node 版本: v26.8.1
ICU: full-icu
默认 locale: en-US
系统 locale 相关环境: (未设置)
== 1. 同一批姓名,六种比较器的结果 ==
默认比较(无比较器): 何四 吕五 吴十 周九 孙八 张三 施六 李四 欧阳修 王五 秦二 许三 赵七 郑一 陈六
localeCompare 无参数: 何四 吕五 吴十 周九 孙八 张三 施六 李四 欧阳修 王五 秦二 许三 赵七 郑一 陈六
localeCompare('zh'): 陈六 何四 李四 吕五 欧阳修 秦二 施六 孙八 王五 吴十 许三 张三 赵七 郑一 周九
Collator('zh-Hans-CN'): 陈六 何四 李四 吕五 欧阳修 秦二 施六 孙八 王五 吴十 许三 张三 赵七 郑一 周九
Collator('zh-Hans-CN-u-co-stroke'): 王五 吕五 孙八 许三 何四 吴十 张三 李四 周九 欧阳修 陈六 施六 赵七 郑一 秦二
Collator('en'): 何四 吕五 吴十 周九 孙八 张三 施六 李四 欧阳修 王五 秦二 许三 赵七 郑一 陈六
本机默认区域是 en-US。不传参数的比较和默认比较给出的顺序完全一样,看着像按字符编码值走。换成 zh 或者 zh-Hans-CN 之后,顺序变成拼音:陈、何、李、吕、欧阳排在前面,周落在最后。把排序方式换成笔画,同一批名字又是另一套顺序。
三个顺序并排看,同一个姓的位置能差好几个身位。通讯录那列不对,是因为默认区域里没有中文的排序规则,代码又没有指定。
第 10 章排在第 1 章前面
== 2. 数字串的默认顺序与 numeric 选项 ==
默认比较: 第10章 第1章 第20章 第2章 第3章
Collator('zh-Hans-CN'): 第10章 第1章 第20章 第2章 第3章
Collator('zh-Hans-CN', {numeric:true}): 第1章 第2章 第3章 第10章 第20章
数字串是按字符比的。第 1 章和第 10 章的第一个字符都是「第」,第二个字符都是 1,接着比第三个字符,0 的编码值比「章」小,于是第 10 章被排到前面。加上 numeric 之后,串里的数字段被当成数来比,顺序回到 1、2、3、10、20。
文件列表,版本号,章节号,这几类数据都会踩到。光指定 zh 不够,还得把这个开关一起打开。
四个同音字在两种规则下的位置
== 4. 同音不同调的四个字:默认比较按码位,zh 规则按声调 ==
默认比较: 妈 马 骂 麻
Collator('zh') 默认 sensitivity: 妈 麻 马 骂
Collator('zh', {sensitivity:'base'}): 妈 麻 马 骂
'妈'.localeCompare('麻','zh') = -1
'妈'.localeCompare('麻','zh',{sensitivity:'base'}) = -1
'张'.localeCompare('李','zh') = 1 (正数=李在前,拼音 li 排在 zhang 前面)
默认比较给出的顺序是妈、马、骂、麻,zh 规则给出的是妈、麻、马、骂。同一个音的四个字,一个按编码值排,一个按声调排,出来的两个序列没有一处重合。
把敏感度降到 base,也没能把它们并成一个:比较结果照样是 -1。想按首字母分组、把同音字当成同一类,这条路走不通,得自己把声调剥掉再比。
那 60 倍是从哪来的
== 5. 开销:5 万条排一遍,三种写法各测三次 ==
每个元素 new Collator: 1113ms / 1083ms / 1099ms
localeCompare 无参数: 22ms / 22ms / 22ms
复用同一个 Collator: 18ms / 18ms / 18ms
== 6. 数据量放大一档:2 万条,看比值是否稳定 ==
localeCompare 无参数: 9ms
复用 Collator: 7ms
倍数: 1.28x
排序过程会把每一对元素都比一遍,比较器建在排序回调里面,等于每个元素都新建一次排序器。5 万条数据,这一项就是 1113 毫秒,比复用同一个慢 60 倍。
数据量掉到 2 万条,复用同一个排序器与不传参数的比较只差 1.28 倍。这个差距不算大,说明两件事的花费来源不一样:不传参数的比较每次都要去查一遍区域规则,复用排序器把这部分省下来;而按元素新建排序器,省下的是另一笔账。
落到代码里的做法
第一种是把排序器提成模块级常量,整个应用只建一次,比较函数直接引用它。这一改动的收益最大,也最省事。
第二种是数据量到几万条以上、或者排序结果要存下来的时候,先算好排序键。把名字转成拼音串存进列表项,之后每次排序比的都是普通字符串,规则清清楚楚,换语言也不用改排序逻辑。
第三种是把排序放到服务端或数据库里做,接口直接返回排好的顺序。代价是前端做本地筛选和分页时要留意顺序的权威在哪边。
判断要不要动排序这一层,有个很省事的办法:把数据里的姓名换成一批中文名试一次,如果结果和拼音对不上,问题就在区域参数这一层,不在数据结构上。
这次的取舍是把排序器提成模块级常量,中文键在数据入库前算好。这样同一份数据在列表页和导出文件里的顺序一致,出了问题只用看一个地方。我不建议在渲染函数里新建排序器,那个位置每帧都可能被调用到。