
「JS 对象保留插入顺序」这句话是对的,也是错的。对的部分:字符串键确实按插入先后来。错的部分:'2' 这种长得像整数的键根本不排队,直接被抽出来放到最前面。ECMA-262 把 OrdinaryOwnPropertyKeys 的次序写得很死:先列整数型键,按数值升序;再列字符串键,按创建顺序;符号键最后。下面六组输出全部来自本机 node v26.8.1,验证脚本在 tools/verify-object-key-order2.mjs。
溯源:规范怎么写的
ECMA-262 的 [[OwnPropertyKeys]] 内部方法,对普通对象的规定是三段式:返回的数组要按这样的次序包含全部键,整数索引的键按数值升序在前,然后是字符串键按创建先后,符号键最后。
关键在于什么算「整数索引的键」。规范给的定义是 canonical numeric string:一个字符串能转成 ToString(ToUint32(它自己)) 且结果等于原串,就算。'2' 是,'11' 是;'-1' 带负号不是,'1.5' 带小数点也不是。所以被提前的键的范围比「整数」还窄一些,是「非负整数且无多余零」。
实测:六组输出
第一组,整数键被提前并按数值排:
=== 1. 整数键在前 ===
插入顺序: z , 2 , -1 , a , 11
实际顺序: 2 , 11 , z , -1 , a
'2' 和 '11' 跳到最前面且按数值升序(11 排在 2 后面,因为 11 > 2);'-1' 留在字符串键区,按插入顺序排。这就是规范三段式的直接体现。
第二组,字面量写法也一样,说明不是赋值方式造成的:
=== 2. 整数重排,字符串键保持插入序 ===
字面量 { b, 10, a, 2, c, 1 } 的键: 1 , 2 , 10 , b , a , c
第三组,小数键不走整数规则,整体维持插入顺序:
=== 3. 小数键不走整数规则 ===
插入顺序: 1.5 , 0.5 , b , 2.5 , a
实际顺序: 1.5 , 0.5 , b , 2.5 , a
第四组最反直觉。数组的稀疏空位,forEach 直接跳过不回调,map 却会保留空位、产出 null:
=== 4. forEach 跳稀疏空位,map 不跳 ===
数组 [x, <1 empty>, z] forEach 收到: ["x","z"] (长度 2)
map 的产物: ["x!",null,"z!"] (长度 3)
这也是同一个键顺序模型推出来的:数组的索引就是整数键,空洞是「键存在但没有值」。forEach 按 HasProperty 判断,空洞不算存在,跳过;map 按 CreateDataProperty 写回,空洞位置产出 null。
第五组,delete 再加回同一个键,它会换位置:
=== 5. delete 后再 add,顺序跟着新位置走 ===
delete b 再加回 b 之后: a , c , b
b 原来排在中间,删掉再加回,就排到了字符串键区的最末尾。对象的键顺序会变,这是最容易被忽视的一处。
第六组,JSON 的往返。JSON.stringify 按对象当前的键顺序输出,整数键同样排在前面:
=== 6. JS 与 JSON.stringify 顺序一致 ===
JSON.stringify(o5) = {"2":"b","100":"a","name":"x","id":"c"}
recovered = {"2":"b","100":"a","name":"x","id":"c"}
parse 回来的对象顺序和 string 前一致,因为 JSON.parse 按文本顺序建键,整数键照样被归位到最前面。这一段没实测别的引擎,V8 是这个行为,SpiderMonkey 与 JavaScriptCore 按规范也要一致,但没逐一验证过。
什么时候会踩到
数组混入非索引属性时最常见。const arr = [1,2,3]; arr.extra = 'x' 之后,Object.keys(arr) 是 ['0','1','2','extra'],整数索引永远在 extra 前面,不会因为 extra 是后加的就排前面。
另一个是拿对象当有序字典用。键是自增 id 字符串时,Object.keys 给出的顺序与插入顺序看似一致,一旦混进一个非数字键,就分成了两个区块,靠「遍历顺序即插入顺序」写的 diff 或渲染逻辑会出乱子。
取舍
需要严格有序的场景,别依赖对象本身的顺序,用 Map。Map 按「键第一次插入的顺序」遍历,整数键也不例外,这是规范对 Map 的单独保证,跟 OrdinaryOwnPropertyKeys 那套无关。对象键顺序这条规则不值得背下来,值得记住的只有一句:整数键排队插队,字符串键按先来后到,Map 才是真排队。
反过来说,不用 Map 的代价有时是可以接受的:JSON 配置对象、纯字符串键的场景,现状顺序就够用。这类场景下的性能差异我没实测过,不做推荐,只记录判断依据。