侧边栏壁纸
博主头像
一笑痕

仙人之下我无敌,
仙人之上一换一。

  • 累计撰写 62 篇文章
  • 累计收到 7 条评论

对象的键顺序不是插入顺序,整数键会被提前

2026-9-17 / 0 评论 / 12 阅读

对象的键顺序不是插入顺序,整数键会被提前

两个服务各自把同一份订单拼成 JSON 再算一遍摘要,字段名一样,字段值一样,字段个数也一样,算出来的摘要对不上。差异不在数据上,在键的顺序上。这个顺序不是插入顺序,规范为它单独定义了一个抽象操作。

规范里的五步

ECMAScript 把普通对象自有属性键的枚举顺序写成了 OrdinaryOwnPropertyKeys:

10.1.11.1 OrdinaryOwnPropertyKeys ( obj )

The abstract operation OrdinaryOwnPropertyKeys takes argument obj (an Object)
and returns a List of property keys. It performs the following steps when called:

1. Let keys be a new empty List.
2. For each own property key propertyKey of obj such that propertyKey is an
   array index, in ascending numeric index order, do
   a. Append propertyKey to keys.
3. For each own property key propertyKey of obj such that propertyKey is a
   String and propertyKey is not an array index, in ascending chronological
   order of property creation, do
   a. Append propertyKey to keys.
4. For each own property key propertyKey of obj such that propertyKey is a
   Symbol, in ascending chronological order of property creation, do
   a. Append propertyKey to keys.
5. Return keys.

顺序被切成三段,段与段之间不穿插。第一段是能当数组下标用的整数键,按数值升序;第二段是剩下的字符串键,按创建时间;第三段是 Symbol 键,也按创建时间。所以一个最后写进去的整数键,会跑到第一个写进去的字符串键前面。

什么算 array index

第一段收哪些键,取决于术语表里的两个定义叠出来的结果:

integer index: a property name n such that CanonicalNumericIndexString(n)
returns an integral Number in the inclusive interval from +0𝔽 to 𝔽(2^53 - 1).

array index: an integer index n such that CanonicalNumericIndexString(n)
returns an integral Number in the inclusive interval from +0𝔽 to 𝔽(2^32 - 2).

CanonicalNumericIndexString 要求键名能原样转回自身,所以 "01"、"1.0"、" 1" 这类字符串都进不了整数段,它们跟普通字符串键一起排。上界取的是 2^32 - 2 而不是 2^53 - 1,于是 "4294967294" 仍是整数键,"4294967295" 被挤到字符串段,排在所有整数键后面。

实测九组

脚本存在 tools/verify-object-key-order.mjs,跑在本机 Node v26.8.1 上:

node v26.8.1

--- 1. 插入顺序 vs 枚举顺序:整数键被提前 ---
插入顺序 b, "2", a, "1", "01", "1.0", " 1", "-1", "3.5"
Object.keys  -> "1", "2", "b", "a", "01", "1.0", " 1", "-1", "3.5"
JSON.stringify -> {"1":1,"2":1,"b":1,"a":1,"01":1,"1.0":1," 1":1,"-1":1,"3.5":1}

--- 2. 整数键按数值升序,不是字典序 ---
插入顺序 "10","9","100","2"
Object.keys -> "2", "9", "10", "100"

--- 3. 整数键的边界:2^32-1 不算 array index ---
Object.keys -> "0", "4294967294", "4294967295", "4294967296"
说明:4294967294 = 2^32-2 仍是整数键;4294967295 = 2^32-1 已被排除

--- 4. delete 之后重新赋值,会排到最后 ---
初始 a,b,c → 删 a 再写回 → "b", "c", "a"

--- 5. Symbol 与不可枚举属性:不在 Object.keys / JSON 里 ---
Object.keys            -> "x"
JSON.stringify         -> {"x":1}
Object.getOwnPropertyNames -> "x", "inv"
Reflect.ownKeys 含 symbol -> 3 个

--- 6. for...in 会把继承来的可枚举属性也算上 ---
Object.keys(o6) -> "own"
for...in        -> "own", "inherited"

--- 7. 顺序进了签名:同样内容,摘要不同 ---
A: {"9":1,"amount":100,"orderId":"A1"}  sha256=4cd03e73067504dd
B: {"9":1,"orderId":"A1","amount":100}  sha256=745afa53cc622c4d
字段值完全一致,摘要一致吗: false

--- 8. 对照:Map 只按插入顺序,数字键不提前 ---
Map keys -> 2, "b", 1

--- 9. structuredClone 之后顺序保留 ---
clone 后 -> "5", "z", "y"
JSON 一致 -> true

第二组值得多看一眼:"10"、"9"、"100"、"2" 四个键先写成字符串被塞进对象,枚举出来是 2、9、10、100,按数值大小排。如果是按字符串比较,"100" 会排在 "2" 前面,这里没有。

第四组说明整数键的位置也不固定。删掉再写回同一个键,它会掉到字符串段里,可见「先写的排前面」这条规则只在同一段内部成立。

第五组的三个 API 覆盖范围不同:Object.keys 与 JSON.stringify 只看可枚举的自有字符串键,Object.getOwnPropertyNames 多带上不可枚举的,Reflect.ownKeys 才会把 Symbol 也列出来。不可枚举属性和 Symbol 键都不参与序列化,签名时看不到,这本身也是信息:一个字段在 JSON 里出现与否,不取决于它有没有被赋值。

第七组是这套规则真正的杀伤面。两个对象内容完全相同,只是字段写入的先后不同,JSON.stringify 出来的字节就不同,sha256 摘要跟着不同:

4cd03e73067504dd...  vs  745afa53cc622c4d...

落到签名上的取舍

把 JSON.stringify 的输出直接当签名原文,等于把字段的写入顺序也签了进去。后端从数据库取字段的顺序、前端表单里 push 的顺序、中间某次重构调换了两个字段的位置,任何一处变动都会让摘要对不上,而且报错信息通常只是「签名校验失败」,看不出差在哪。

站得住的做法有两种。一种是在签名前把键重排一遍,比如对每一层的键做一次 sort 再序列化,代价是每层都要递归,且数组里的对象也要处理。另一种干脆放弃把对象当签名载体,参数按固定字段名顺序拼成字符串再算摘要,多写几行代码,但顺序由代码显式决定,不依赖运行时。这两种都不用改数据,只改签名那一处的前处理。前处理我倾向于放在签名函数内部,别让调用方各自记得先排序。

还有一种情况要单独留神:签名双方用的语言不同。规范只管 JavaScript 这一侧,Java 的 HashMap、Python 的 dict、Go 的 map 各有各的顺序保证,跨语言约定签名时,键必须能排成同一份确定的序列,否则两边各自都自洽,拼在一起就是不一致。

至于什么时候不需要管:对象只用来传参、不做逐字节比较,或者两端都在同一个进程里,顺序差异没有出口,这类场景把键排一遍属于白做的功夫。真正需要排序的是签名和缓存键这类产物,它们会被别的东西按字节读取,落盘的配置文件也算一种。据这次实测,整数键的提前是最容易忽略的一处,因为它是静默发生的:没有报错,没有警告,只是摘要在某些数据下偶尔对不上。

    🤞 分享