返回知识中心
指纹

Intl.Collator:语言排序顺序为何不是码元顺序

一次固定列表探测:语言比较与普通码元排序的差别,numeric、sensitivity、caseFirst 与标点各自如何改变结果,以及 resolvedOptions 报告什么、不支持的 locale 回退到什么。

BotBrowser Team

文档中心

想直接进入 指纹 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

BotBrowser 支持在声明的 Profile 下重复运行经授权的同源页面 fixture,并保留范围受限的记录:页面把哪些内容按哪种语言排序;但它不能改变运行时随附的排序规则数据,不能让两种语言对同一顺序达成一致,也无法在事后还原某次比较所用的排序规则。本文用一次探测覆盖十个固定列表,记录语言比较与普通码元排序的差别,numeric、sensitivity、caseFirst 与 ignorePunctuation 各自如何改变结果,resolvedOptions() 实际报告什么,不支持的 locale 回退到什么,以及为什么直接传入 collator 而不是它的 compare 函数会抛错。

摘要

  • 码元排序把 Ápple 放在最后,而语言排序把它放在第三,所以同一组五个单词得到两种不同的顺序。
  • 语言会改变同一对词的先后:瑞典语把 ä 排在 z 之后,德语和英语把它排在前面,因此探测打印 pair-sv -> z,ä 与 pair-de -> ä,z。
  • numeric: true 把 item2 排在 item10 之前,而默认把两者当作文本,把 item10 排在 item2 之前。
  • sensitivity 决定哪些差异被计入:base 认为 a、A 与 á 相等,accent 区分带重音形式,case 区分大写。
  • ignorePunctuation: true 让 ab 与 a-b 比较相等,而默认把它们区分开。
  • caseFirst 只有在大小写成为差异时才显现,并把 AaBb 反转为 aAbB。
  • resolvedOptions() 报告运行时实际授予的排序规则,包括 locale 标签中指定的排序规则,而不支持的 locale 会回退到 en-US 而非抛错。
  • 把 collator 直接传给 sort 会抛出 TypeError;只有 sort(collator.compare) 有效。

目录

第一个面板显示同样五个单词分别按码元排序与按英语 collator 排序的结果,码元排序把带重音的单词放在最后,语言排序把它放在第三。第二个面板显示同一对词在瑞典语下为 z 然后 ä,在德语下为 ä 然后 z。第三个面板显示 numeric 关闭时顺序为 item1、item10、item2,开启时为 item1、item2、item10。第四个面板显示四个 sensitivity 级别以及各自视为相等的内容。

collator 比较了哪些码元排序不比较的内容

默认的 Array.prototype.sort() 比较 UTF-16 码元。collator 按语言排列文本的方式进行比较:重音并入基本字母,大小写是次级差异,每种语言决定一个字母的位置。探测把同样五个单词用两种方式排序:

node=v26.9.0
icu=78.3 unicode=17.0
code-unit -> Apple,apple,banana,zebra,Ápple
locale-en -> apple,Apple,Ápple,banana,zebra
pair-sv -> z,ä
pair-de -> ä,z
pair-en -> ä,z
numeric-off -> item1,item10,item2
numeric-on -> item1,item2,item10
sensitivity-base -> a-vs-A=0 a-vs-á=0 a-vs-b=-1
sensitivity-accent -> a-vs-A=0 a-vs-á=-1 a-vs-b=-1
sensitivity-case -> a-vs-A=-1 a-vs-á=0 a-vs-b=-1
sensitivity-variant -> a-vs-A=-1 a-vs-á=-1 a-vs-b=-1
punct-kept -> 1
punct-ignored -> 0
caseFirst-upper -> AaBb
caseFirst-lower -> aAbB
caseFirst-upper-base -> Aa vs aA
resolved -> locale=de-u-co-phonebk numeric=true sensitivity=accent collation=phonebk
fallback-locale -> en-US
passthrough -> sort(collator)=TypeError sort(collator.compare)=ab
tie-check -> sv(ä vs z)=1 de(ä vs z)=-1

它与普通排序的差别,正是 数组排序与比较器 对比较器本身所画出的那条线。前两行就是全部要点的缩影。Ápple 的码元高于 z,所以码元排序把它推到末尾;语言把 Á 看作带重音的 A,所以语言排序把它与其他以 a 开头的单词放在一起。两种顺序都不算错,但一个页面若在两个运行时之间比较同一列表却不指明用的是哪一种,它记录的结果就取决于用了哪种排序。

第二对是更难的情形。在瑞典语里 ä 不只是带重音的 a,它是独立的字母,排在 z 之后。德语则把它保留在 a 附近。探测把两种顺序并排打印,而一个把其中任意一种硬编码为「唯一」顺序的页面,在另一种语言上就会出错。tie-check 这一行以比较结果而非排序列表的形式显示了同一件事:同一对词在瑞典语下返回 1,在德语下返回 -1。

numeric 与 sensitivity 如何改变结果

numeric: true 把一串数字从文本比较切换为数值比较,探测在三个列表项上显示了差别:

numeric-off -> item1,item10,item2
numeric-on -> item1,item2,item10

默认把 item10 读作字符「1」「0」,所以作为文本它排在 item2 的「2」之前。启用 numeric 后,运行时把这串数字读作十,于是 item2 排在前面。一个按默认 collator 排序文件名或表格行、却期望自然顺序的页面,得到的是第一行,而不是第二行。

sensitivity 决定比较到底报告哪些差异,探测在四个级别下各比较三对词:

sensitivity-base -> a-vs-A=0 a-vs-á=0 a-vs-b=-1
sensitivity-accent -> a-vs-A=0 a-vs-á=-1 a-vs-b=-1
sensitivity-case -> a-vs-A=-1 a-vs-á=0 a-vs-b=-1
sensitivity-variant -> a-vs-A=-1 a-vs-á=-1 a-vs-b=-1

base 对大小写对和重音对都报告 0,所以 a、A 与 á 都相等,只有 b 排在别处。accent 开始区分带重音的形式,但仍把 a 与 A 视为相等。case 则相反:它区分大写并重新折叠重音。variant 区分一切。一个以小写化、去重音的字符串为键的查找表,其实就是手工实现 sensitivity: 'base' 一个选项就做到的事;而一个希望 A 与 a 相撞、却让 á 单独排序的表,需要的是 accent。

这一区分对指纹问题和对排序同样重要。一个按 sensitivity: 'base' 分组的页面和一个按 variant 分组的页面,从相同输入会得到不同的分组数,所以「不同值的数量」这一读数必须指明 sensitivity,否则无法在两个页面之间比较。

同一张表里还有一层次级陷阱。sensitivity: 'base' 对 a 与 á 报告 0,而一个把这个 0 当作「这些字符串是同一个值」的页面会合并两条用户认为不同的记录。比较报告的是顺序,不是相等:0 表示 collator 对谁在前没有意见,一个以原始字符串为键的 Map 仍然同时保存两者。去重必须另行决定平局意味着「保留第一个」、「都保留」还是「合并」,而 collator 并不回答这个问题。

四个级别还以固定顺序嵌套,知道顺序就能预测每一行。base 忽略重音和大小写;accent 增加重音区分;case 则增加大小写区分并保持重音折叠;variant 两者都增加。这就是为什么 accent 与 case 在不同对上给出不同结果,而不是一个比另一个更严格,也是为什么一个想要「同一个词、任意大小写、任意重音」的页面想要的是 base,而不是一串手工规范化。

case、标点、等价与语言大小写在哪里进入顺序

ignorePunctuation 让 collator 在比较时跳过标点,探测在一对词上显示效果:

punct-kept -> 1
punct-ignored -> 0

当标点被计入时 ab 与 a-b 不同,不计入时相等。一个排序含连字符姓名列表、期望 ab 与 a-b 保持分开的页面,依赖的是默认值;而一个在忽略标点的 collator 上去重的页面会把它们合并。这个选项是策略选择,不是细节。

同一个选项也改变搜索框认为什么是匹配。一个把查询小写化并用默认 collator 比较的过滤器会保持 a-b 与 ab 分开,所以在只含 a-b 的列表里输入 ab 什么也找不到。关闭标点比较后查询就能匹配,而关闭它也令 a.b 与 a-b 彼此匹配,这通常正是读者对搜索框的期望,却很少是精确匹配过滤器想要的。一个选项,两种产品,默认值相反。

caseFirst 决定一旦大小写算作差异时哪种大小写在前,探测同时显示效果与它的极限:

caseFirst-upper -> AaBb
caseFirst-lower -> aAbB
caseFirst-upper-base -> Aa vs aA

在默认 sensitivity 下,upper 在每一对里把大写放在小写之前,lower 则反过来。第三行是极限:在 sensitivity: 'base' 下两种大小写相等,所以 caseFirst 无顺序可定,打印结果只是各自输入的原有顺序。一个除非另一个选项被设置才生效的选项,很容易被误读为坏掉了,而探测是看清它并非如此的最省事方式。

这个依赖值得作为一条规则陈述:caseFirst 是在已经区分大小写的比较内部的平局裁决,在那一层之上它不起作用。一个同时设置 caseFirst: 'upper' 和 sensitivity: 'base' 并期望大写在前的页面,设置的是两个相互抵消的选项,修法是去掉 sensitivity 覆盖,而不是增加手工比较。探测的最后一行显示两种输入顺序原样保留,这就是抵消的可见形式。

规范等价与语言大小写在哪里让排序出乎意料

最令人困惑的两种行为其实与选项开关无关;它们来自 collator 如何看待看起来相同的文本,以及哪些文本的大小写取决于语言。第二次探测记录了两者。

第一个是规范等价。字母 é 可以存为一个码点,也可以存为 e 后跟一个组合重音,而这两个字符串并不相等:

normalization-text -> nfc.length=1 nfd.length=2 equal=false
normalization-collate -> base=0 variant=0

长度不同,字符串比较说它们不同,但 collator 在 base 甚至 variant sensitivity 下都报告 0,因为两种拼写规范等价,排序规则数据把它们折叠在一起。于是用 collator 比较的页面把两个字节不同的字符串当作同一个词,而用 === 或 Map 比较的页面则不会。这种分裂是「排序对了但查找落空」最常见的单一来源:排序用了 collator,查找用了原始字符串。

第一种也是按语言比较与纯文本比较会分歧的原因,Unicode 规范化 从字符串一侧做了说明。第二个是语言相关的大小写。在土耳其语里,I 小写为无点的 ı,i 大写为带点的 İ,后者与英语的 I 是不同的字母:

dotted-i-tr -> i-vs-I=1 i-vs-İ=-1
dotted-i-en -> i-vs-I=-1 i-vs-İ=-1
turkish-lower -> tr=ı en=i

在土耳其语下 collator 把 i 排在 I 之后、İ 之前,所以 i 与 I 在大小写或顺序上都不再是同一个字母;在英语下它把 i 排在这两者之前。最后一行在字符串层面显示同样的分裂:'I'.toLocaleLowerCase('tr') 得到 ı,而英语调用返回 i。一个不指定语言就小写化再比较的页面,在两种语言下都得到英语顺序,这就是为什么语言相关的比较和语言相关的大小写折叠必须一起选择,而不能分开逐个选。

还有第三种形状值得记录,因为它打破了「码元排序足够接近」的假设。占两个码元的辅助平面字符,在普通排序下按首个码元排,在 collator 下按其排序权重排:

astral-codeunit -> 61,7a,1f600
astral-collator -> 1f600,61,7a

普通排序把表情符号放在最后,因为它的前导码元高于 z;collator 把它放在最前,因为它的排序权重在字母之前。一个把辅助平面字符混进姓名列表并按默认排序的页面,会得到没人期望的顺序,修法是 collator,而不是手工重排。

最后,这一切的前奏是 localeCompare 与 collator 一致,这是在构建 collator 之前确认假设最省事的方式:

localeCompare-vs-collator -> ä-z:-1/-1 a-b:-1/-1
hoisted-vs-per-call -> same=true order=Alpha,bravo,charlie,delta

第一行显示 'ä'.localeCompare('z', 'de') 与一个德语 collator 返回相同的 -1,所以探测可以先用一行写法起步,只在需要选项时才动用 collator。第二行显示提升到循环外的 collator 与每次比较都构造一个 collator 得到相同的顺序,这印证了先前的说法:提升改变的是开销而不是结果。

resolvedOptions 报告什么,什么会回退

resolvedOptions() 返回在 locale 标签和选项都应用之后,运行时实际授予的内容:

resolved -> locale=de-u-co-phonebk numeric=true sensitivity=accent collation=phonebk

collator 被要求用 de-u-co-phonebk 并带两个选项,解析后的 locale 保留 -u-co-phonebk 扩展,并单独报告 collation=phonebk。这个区分很重要,因为页面可以读取任一字段却得到不同的字符串,而一条只存 locale、丢掉 collation 的记录,失去了决定顺序的那一部分。电话簿排序规则在德语里把 ä 与 ae 排在一起,这又是第三种顺序,既不同于前面一节打印的普通德语顺序,也不同于瑞典语顺序。

不支持的 locale 不会抛错。探测请求 zz-ZZ:

fallback-locale -> en-US

运行时回退到 en-US 并如实说明。一个从请求头或查询参数读取 locale 并直接传入 collator 的页面,因此总能得到 collator,只有 resolvedOptions().locale 才揭示请求并未被兑现。一条存了请求的 locale 而非解析后 locale 的记录,记录的是意图,不是测量。

为什么不能把 collator 直接传给 sort

collator 是一个带 compare 方法的对象,而 Array.prototype.sort() 接受函数,不接受带兼容方法的对象。探测直接传入 collator 本身:

passthrough -> sort(collator)=TypeError sort(collator.compare)=ab

前半部分抛出 TypeError,因为参数不可调用;后半部分正确排序,因为 collator.compare 是绑定函数。这在代码评审里是一个词的错误,在生产里却是一个运行时异常,值得点名,因为 sort 的报错根本不提 collator。

同样的形状解释一个更细微的错误。一个创建一次并重复使用的 collator 不仅是 compare 的工厂,它还持有 locale 与选项,而它的 compare 绑定到这些内容。一个在排序回调里每次比较都新建 collator 的页面,会重建 ICU 状态成千上万次,这正确但慢;而一个把单个 collator 提升到循环外的页面,以相同顺序更快。探测在五个元素的列表上无法显示时间差别,两种做法顺序完全相同,所以这个选择纯粹关于开销。

记录一次排序规则读数

一次排序规则读数支持一个范围很窄的断言:这个运行时、这个 ICU 版本、这个 locale 与这些选项,产出了这些顺序。五个字段让它可被核对:

  1. 运行时及其 ICU 版本,因为探测打印 icu=78.3 unicode=17.0,不同的 ICU 随附不同的排序规则数据。
  2. 请求的 locale 与解析后的 locale,因为不支持的标签会静默回退。
  3. 解析后的排序规则,因为 -u-co- 扩展会改变顺序,并与 locale 分开报告。
  4. 改变结果的四个选项,numeric、sensitivity、caseFirst 与 ignorePunctuation,因为同 locale 不同选项的两个 collator 会不一致。
  5. 所用的比较,sort() 或 collator.compare,因为直接传对象会抛错。

公开来源

实践结论

每当一个顺序离开页面时都要点名 collator:locale、解析后的排序规则与四个选项,因为其中每一个都会在相同输入上改变结果。优先用 sensitivity 而不是手工的大小写与重音折叠,并把 ignorePunctuation 当作策略决定而非细节。把 collator.compare 传给 sort,绝不要传 collator,并把单个 collator 提升到循环外。至于默认排序弄错的数值比较,请读 数组排序与比较器;至于顺序背后的 locale 数据,请读 Intl API locale 数据。

#Intl.Collator#localeCompare#ICU#排序顺序#Numeric 排序

本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界

让 BotBrowser 从研究走向生产

先用这些指南理解模型,再进入跨平台验证、隔离上下文和面向规模化的浏览器部署。