返回知识中心
指纹

Intl.NumberFormat:舍入模式与符号显示

一支探针看数字格式化:默认用哪种舍入模式、各模式在 .5 边界上怎么做,以及一个符号或末尾零如何悄悄消失。

BotBrowser Team

文档中心

想直接进入 指纹 文档吗?

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

BotBrowser 支持在已声明 profile 下回放授权的同源夹具,并保存一份有界的记录,说明页面为一个数字实际渲染出的确切字符串,但它不能让格式化器按读者假设的方式舍入,不能把一个被选项去掉的负号还原,也不能让两种货币显示同样多的小数位。本评测用一支探针跑十一个固定用例,记录 .5 边界上默认是哪种舍入模式、每种显式模式对同样四个值做了什么、signDisplay 与 trailingZeroDisplay 如何改变渲染出的字符串、roundingPriority 如何在两个上限之间仲裁、货币的默认小数位为什么来自它的代码,以及 resolvedOptions() 与 formatToParts() 对这个真正运行过的格式化器报告了什么。

摘要

  • 默认舍入是 half-expand,也就是远离零:零位小数下探针对 2.5, 3.5, -2.5, 0.5, 1.45 打印 default -> 3,4,-3,1,1,所以 2.5 变成 3,不是 2。
  • halfEven 是那个让 2.5 -> 2 的模式,探针打印 halfEven-vs-expand -> 2,4 vs 3,4,所以两种模式只在边界前一位是偶数时不同。
  • trunc、ceil、floor 把负数往不同方向挪:-2.5 在 trunc 下变成 -2,在 ceil 下变成 -2,在 floor 下变成 -3。
  • signDisplay: 'never' 会去掉负数的负号,所以探针打印 sign:never -> 5,0,5,读这个字符串的消费方无法知道它来自 -5。
  • signDisplay: 'always' 在零上也会加 +,所以探针打印 sign:always -> +5,+0,-5。
  • trailingZeroDisplay: 'stripIfInteger' 保留 3.10 的两位小数,但去掉只因最小值才存在的那些,所以 3.00 渲染成 3。
  • roundingPriority: 'morePrecision' 会放宽输出以同时满足两个上限,所以 12.345 在 morePrecision 下打印 12.35,而 auto 和 lessPrecision 打印 12.3。
  • 货币的默认小数位来自它的代码,所以同一组选项打印 $5.00 和 ¥5。
  • 在货币格式化器上设 maximumFractionDigits: 0 也会把 minimumFractionDigits 设成 0,resolvedOptions() 报告 min=0 max=0 mode=halfExpand。

目录

第一栏显示 2.5 与 3.5 在默认 half-expand 下舍入成 3 与 4,在 halfEven 下是 2 与 4。第二栏显示 trunc、ceil、floor 把 -2.5 送到 -2、-2、-3。第三栏显示 sign never 把 -5 变成 5,sign always 把 5 变成 +5。第四栏显示 morePrecision 打印 12.35 而 auto 打印 12.3,以及 USD 默认两位小数对 JPY 默认零位。

默认是哪种舍入模式

不管调用方有没有指定模式,数字格式化器都会舍入,而默认是一个具体的取舍,读者很少去核对。探针在零位小数、不指定舍入模式的情况下格式化五个值:

node=v26.9.0
default -> 3,4,-3,1,1
mode:halfExpand -> 3,4,-3,2
mode:halfEven -> 2,4,-2,2
mode:trunc -> 2,3,-2,1
mode:ceil -> 3,4,-2,2
mode:floor -> 2,3,-3,1
mode:expand -> 3,4,-3,2
mode:halfTrunc -> 2,3,-2,2
halfEven-vs-expand -> 2,4 vs 3,4
sign:auto -> 5,0,-5
sign:never -> 5,0,5
sign:always -> +5,+0,-5
sign:exceptZero -> +5,0,-5
sign:negative -> 5,0,-5
trailing:default -> 3.00 3.10
trailing:strip -> 3 3.10
priority -> 12.345:auto=12.3 more=12.35 less=12.3 | 1234.5:auto=1230 more=1234.5 less=1230 | 456.78:auto=457 more=456.78 less=457
currency -> $5.00 ¥5
currency:min0 -> $5
percent -> 13% 13%
compact -> 1.2M 1.2M
digits -> ar-EG=١٢٣٤ ar-EG-latn=1234
grouping -> de-DE=1.234.567,89 en-US=1,234,567.89
resolved -> min=0 max=0 mode=halfExpand
parts -> minusSign:-|currency:USD|literal: |integer:5|decimal:.|fraction:00

第一行回答的正是大多数人会答错的那个问题。default -> 3,4,-3,1,1 说明 2.5 舍成 3、-2.5 舍成 -3,所以默认是 half-expand,也就是把平局向远离零的方向舍入。它不是很多人从别的语言或一门财务课里以为的「四舍六入五成双」,而差别会出现在每一个 .5 值上,恰恰是测试夹具里很可能出现的数。

那一行里第二个值说明同一条规则的另一半。零位小数下 1.45 打印 1 而不是 2,因为 1.45 在二进制浮点里并不精确等于 1.45;它比中点略低,所以没有平局要破,结果就是它下面最近的那个整数。一个假设十进制字面量精确的页面,会看到 .45 向下舍而 .5 向上舍,两种行为是同一条规则用在不同的输入上。

那一行里最后一个值最让报告意外。0.5 变成 1,所以一个很小的正数会舍进一个整单位,尽管它只有一位非零数字、是最小的那种数。一个用零位小数打印计数的页面,因此会对 [0.5, 1.5) 里的任何值都显示 1;一个用它打印很小比例的页面,从一半往上就会显示 1,这在最小的那些值上读起来像是 100% 的错误。

格式化器输出的分隔符和数字形状由 CLDR 数据决定,而不是由舍入模式决定,Intl API 的语言数据 记录了格式化器会读语言标签的哪些部分。这与语言无关。探针传的是 en-US,而模式是格式化器的属性,不是语言的;de-DE 或 ja-JP 舍入方式一样,只在后面几行展示的分隔符和数字形状上不同。所以只在一种语言里测过舍入的页面已经看全了这条规则,而一个以为别的语言会舍成不同结果的页面,其实把分组方式的差别读成了算术的差别。

默认也体现在 resolvedOptions() 里,这才是值得写进记录的那项检查。探针为一个只设了 maximumFractionDigits: 0 的格式化器打印 resolved -> min=0 max=0 mode=halfExpand,也就是解析后的模式点出了调用方没写的那条默认。一份只存输入选项、不存解析后选项的记录,以后解释不了 2.5 为什么打印成 3,因为原因就在调用方没设的那个字段里。

每种舍入模式在边界上怎么做

接着探针把规范提供的每种模式都设上,格式化同样四个值,问题就变成一张表。每一行是一种模式对 2.5, 3.5, -2.5, 1.6 的四个结果。

mode:halfExpand -> 3,4,-3,2 是把默认写明。mode:halfEven -> 2,4,-2,2 把 2.5 送到偶数邻居 2、把 3.5 留在偶数邻居 4,所以平局总落在最后一位数字是偶数的那个值上。探针那条专门的对照让差别具体化:halfEven-vs-expand -> 2,4 vs 3,4,两种模式在 3.5 上一致、在 2.5 上分歧,这就是「平局由奇偶而非方向决定」的定义。

mode:trunc -> 2,3,-2,1 把边界之后的每位都丢掉、不管多接近,所以 1.6 变成 1、-2.5 变成 -2。mode:ceil -> 3,4,-2,2 朝正无穷舍,所以 1.6 变成 2、-2.5 变成 -2,因为 -2 比 -2.5 大。mode:floor -> 2,3,-3,1 朝负无穷舍,所以 1.6 变成 1、-2.5 变成 -3。mode:expand -> 3,4,-3,2 每个值都远离零,所以它在正数上和 ceil 一致、在负数上和 floor 一致。

三个方向型模式,是符号处理 bug 最容易引进来的地方。一个想「向上舍」的页面拿了 ceil,对 -2.5 得到 -2,它更大但更靠近零,所以一个意思是「远离零」的「向上舍」需要 expand 而不是 ceil。一个对一个不能被夸大的量取保守选择的页面,在正数上要 floor、在负数上要 ceil,这是两条规则而不是一条,而模式名字并不把它们合起来。

mode:halfTrunc -> 2,3,-2,2 把平局向零舍、其余向最近舍,所以 2.5 变成 2、3.5 变成 4、-2.5 变成 -2。这是和 halfExpand 相反的平局方向,当一个页面报告一个在平局上不许向上舍的总数时就用它,比如一个下游系统会拿阈值去比对的计数。

每个行里 1.6 都是对照。它不是平局,所以每个向最近舍的模式都给 2,只有截断型给 1。因此一个同时格式化一个非平局值和一个平局值的测试,能把「只改平局」的模式和「每值都改」的模式分开;而只测 1.6 的页面,根本分不出 halfExpand 和 halfEven。

对记录而言,后果是模式得写进记录。同一个 app 的两次运行,同一个 2.5 可能打印 2 和 3,如果一个格式化器一次用 halfEven、一次用默认,而存下的字符串说不出是哪一种模式产生的。一份把解析后的 roundingMode 和每个格式化值一起存下来的记录,把这个差别变成可复现的事实,而不是只在恰好是平局的值上出现的谜团。

符号与末尾零如何改变字符串

两个显示选项会改变输出的宽度和符号而不改变值,探针那几行符号还展示了一个符号被丢掉的用例。

sign:auto -> 5,0,-5 是默认:正数和零不带符号,负数带一个负号。sign:always -> +5,+0,-5 在正数和零上都加一个加号,所以读者能看出这个数是以带符号的方式格式化的,两份报告做 diff 时也能区分一个没变的正值和一个没变的负值。sign:exceptZero -> +5,0,-5 在正数上加加号但让零裸着,当一个零是真实值、不该看起来带符号时选它。

sign:never -> 5,0,5 是危险的那个。它把 -5 的负号去掉、打印 5,所以把字符串解析回数字的消费方从一个负输入拿到了正值。一个用 sign:never 渲染增量、假设「符号在别处显示」的页面,为 +5 和 -5 产出两个一模一样的字符串,下游的解析再也分不出是哪一个。sign:negative -> 5,0,-5 只在值为负时显示负号,这才是打 never 时通常真正想要的效果,并且它在负数上保住了符号。

末尾零选项改的是那些只因最小值才存在的小数位。探针对一个最小值、最大值都是两位的格式化器打印 trailing:default -> 3.00 3.10,设了 stripIfInteger 之后打印 trailing:strip -> 3 3.10。所以一个取整数值的数丢掉了它那串零,而 3.10 保住了末尾零,因为 3.10 不是整数、只有整数那一种情况被裁掉。一列用默认的价目显示成 3.00、4.00,同一列开了裁剪就显示成 3 挨着一个 3.10,成了一列宽度不一、表格容易对不齐的东西。

两个选项和符号、小数部分的交互,是选项列表没讲清的。stripIfInteger 作用在 -3.00 上得 -3,负号保留;sign:always 作用在 -3.00 上得 -3.00,符号和小数是彼此独立的。同时组合两者的页面,得决定 -0 想要什么;探针在 sign:always 下的 +0 说明格式化器会给零也打印符号,而零正是把相等的两个数相减常常得到的结果。

所以输出的宽度不是数字一个人的属性。同一个 5 会因三个选项渲染成 5、+5、5.00 或 +5.00,而一个量了一个变体就固定列宽的布局,会把其它变体裁掉或推歪。格式化表格的页面应该把选项固定一次、对每个单元格都用同一套,而不是让一个默认和一个显式选项在同一列里相遇。

两个上限在哪里相撞

两个选项可以同时限制位数,而谁说话算数取决于第三个几乎没人设的选项。探针把 maximumSignificantDigits: 3 和 maximumFractionDigits: 2 放在一起,对三个值分别用每种 roundingPriority;记录里 priority -> 那一行就是这些结果。

auto 和 lessPrecision 在每个值上都一样:12.345 变成 12.3、1234.5 变成 1230、456.78 变成 457。两者都遵守有效数字上限,并把小数上限当作已被结果满足,所以三位那条规定胜出、两位小数那条从不起作用。morePrecision 是怪的那一个:它打印 12.35、1234.5、456.78,留着比单个上限允许的还多的位数,因为它是按「两个上限都被满足」去舍,而不是让一个压过另一个。

1234.5 这个用例让意外看得见。auto 下它变成 1230,三位有效数字、没有小数;而 morePrecision 下它仍是 1234.5,四位有效数字、一位小数。一个同时设两个上限、期望「最多 3 位有效数字且最多 2 位小数」的页面,在默认下拿到 1230;一个设 morePrecision、期望「取两者中更严的那个」的页面,反而拿到更宽的那个。两种对选项的读法都不符合实际行为,所以该进记录的是解析后的输出,而不是背后的意图。

想要一个上限的格式化器就该只设一个上限。同时设两个、指望默认优先级,得到的是有效数字那套行为,所以只想要「两位小数」的页面应该只设 maximumFractionDigits,只想要「三位有效数字」的应该只设 maximumSignificantDigits。组合是为了两个都必须守住的情况,而这正是 morePrecision 明确声明、而不是交给默认去猜的东西。

货币那两行展示的是调用方从不设的第二个上限。currency -> $5.00 ¥5 给美元两位小数、给日元零位,因为默认小数位来自货币代码,而不是语言或选项。currency:min0 -> $5 说明设 minimumFractionDigits: 0 会覆盖代码的默认,所以符号和位数是两个独立的决定。一个打印跨货币价目表的页面因此每种货币的宽度都不同,唯一知道各是什么的办法,是对每个代码读 resolvedOptions()。

percent -> 13% 13% 和 compact -> 1.2M 1.2M 是另一个选项带来的同一种意外。0.125 作百分比是 12.5%,默认把它舍成 13%,所以读者期待是半个百分点的一个比率变成了整一个百分点。紧凑写法按有效数字而不是小数舍,所以 1234567 在默认和显式 maximumFractionDigits: 1 下都变成 1.2M,因为这种写法在加后缀之前就已经限住了位数。

digits 和 grouping 那两行提醒:值和字符串是两个不同的对象,同一份语言数据也决定一个时区在格式化数字旁边怎么渲染。digits -> ar-EG=١٢٣٤ ar-EG-latn=1234 让同一个 1234 在一种语言里用阿拉伯-印度数字、在带 Unicode 扩展的另一种里用拉丁数字,grouping -> de-DE=1.234.567,89 en-US=1,234.567,89 展示仅由语言带来的分隔符互换。一个把格式化字符串解析回数字的页面得同时知道这两样,而一个只存字符串的页面,早就把数丢了。

resolvedOptions() 是把环扣上那个字段。探针在一个只设了最大值之后打印 resolved -> min=0 max=0 mode=halfExpand,所以最小值跟着最大值一路降到了零,哪怕调用方从没要求过。这就是页面在货币格式化器上设最大值时最常漏掉的规则:货币默认的两位最小值是被替换掉、而不是保留的,$5.00 和 $5 的差别只是调用方没写的一个选项。

formatToParts() 是记录的另一半。探针打印 parts -> minusSign:-|currency:USD|literal: |integer:5|decimal:.|fraction:00,输出的每一片都被命名了,消费方可以照着重建字符串、或重排这些片,而不必猜符号、货币符号和小数落在哪里。一个在一种语言里把货币符号放在数字后面、在另一种里放在前面的页面,可以从片而不是从整串去做这件事,这才是处理顺序不同的语言的可靠办法。

记录一次数字格式读数

一条数字格式读数支撑的是一个窄结论:这个运行时、这个语言、这些选项,产生了这个字符串。四个字段让它保持可核对:

  1. 解析后的选项,因为 resolvedOptions() 点出真正运行过的舍入模式、最小值和最大值,而调用方的意图不在字符串里。
  2. 舍入模式和输入值,因为一个平局值和一个非平局值可以差整整一个单位,没有输入的记录说不出是哪种情况。
  3. 符号和小数的显示选项,因为 sign:never 会抹掉一个负号、stripIfInteger 会抹掉一串零小数,两者都从输出里找不回来。
  4. 片,因为一种语言输出的顺序和分片都不同,存下的字符串离开产生它的语言就没法重新解析。

公开来源

实践结论

与其相信默认,不如把舍入模式设上,因为默认是 half-expand,一个 .5 值不会像期待「四舍六入五成双」的读者那样舍入。用 sign:negative 而不是 sign:never 保住负号,因为 never 把负数变成了解析器找不回来的正数串。除非两个上限都得守住,否则只选一个位数上限,并在建好货币格式化器之后读 resolvedOptions(),因为一个最大值也会把最小值拉下来。掌管分隔符和数字形状的语言数据,读Intl API 的语言数据;同一族格式化器里时区那一半,读时区与语言。

#Intl.NumberFormat#舍入#本地化#货币#ECMAScript

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

让 BotBrowser 从研究走向生产

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