对象键顺序:整数键排序,字符串键保持
构建混合键的对象:整数键升序排列,字符串键保持插入顺序,哪些键算整数,以及 JSON、Map、for-in 与 spread 如何遵循这个顺序。
BotBrowser Team
BotBrowser 支持在声明的 Profile 下重复运行经授权的同源页面 fixture,并保留范围受限的观察:页面遍历了哪些键的顺序、序列化出什么字符串;但它不能改变运行时如何排列普通对象的键,不能让整数键保持插入顺序,也不会把键顺序变成身份信号。本文构建带混合键的对象,并通过 Object.keys、JSON.stringify、for...in、Map 与 spread 读回,然后记录为什么整数键升序排列而字符串键保持插入顺序、哪些键算整数、为什么 for...in 与 Object.keys 不同,以及 Map 和 JSON 在哪里偏离普通对象的规则。
摘要
- 整数键总是排在最前并升序排列,所以按
b, 2, a, 10, 1插入读回为["1","2","10","b","a"],与写入顺序无关。 - 只有
0到4294967294之间的规范整数才算;01、-1与1.5是字符串键并保持插入顺序。 - 字符串键保持插入顺序,符号排在任何字符串键之后,
Object.keys会跳过它们。 JSON.stringify遵循同一顺序,所以混合对象序列化时整数键在前,即使它们是最后添加的。for...in包含继承的可枚举键;Object.keys只返回自有键。- 删除再重新添加一个字符串键会把它移到末尾,而整数键会重新排回其数字位置。
Map即使对整数键也保持插入顺序,所以同样的键在Map和对象里读回的顺序不同。
目录
为什么整数键排在字符串键之前
普通对象不按写入顺序遍历键。语言定义的顺序是:先整数键按数字升序,然后是其余字符串键按插入顺序,然后是符号按插入顺序。探针写入五个键,混合整数与字符串键,然后读回:
Object=function Map=function node=v26.9.0
insert b,2,a,10,1 -> keys ["1","2","10","b","a"]
这些键是按 b、2、a、10、1 插入的,但 Object.keys 返回 1、2、10、b、a。三个整数键 2、10 与 1 排在最前,按数字排序,所以 1 在 2 之前、2 在 10 之前,而不是按文本排序时 10 会在 2 之前。两个字符串键 b 与 a 按添加顺序紧随其后。没有手动重排;运行时在每次读取时都应用了规范的顺序。
这种数字排序就是为什么"10 在 2 之前"是错误直觉:作为文本 "10" 排在 "2" 之前,但作为索引它排在之后。一个比较两个键列表的记录假设它们只有在以同样方式构建时才可比较,因为通过插入构建的对象和通过 JSON.parse 同一文本构建的对象可能以相同顺序遍历,而一个整数键后来添加的对象仍会把它们排在最前。
实际后果是,从一个对象记录下来的键顺序不是插入顺序。一个先加 b 再加 2 并读回 2 然后 b 的调用者看到了重排,而用同样键以不同插入顺序构建的两个对象的 diff 为空,因为整数键主导了两者的开头。因此,一条声称"插入顺序"的对象键记录,只要对象持有整数键就是错的。
重排还决定了迭代器返回的第一个键,一个把 Object.keys(o)[0] 当作"页面设置的第一个字段"的读取者,只要存在整数键就会读到错误的字段。列表开头是最小的整数键,而不是最早插入的键,所以同一代码对带数字键的对象和不带数字键的对象返回不同字段。这是从位置推断字段优先级的代码里的真实缺陷,因为这种推断只对键全是非整数字符串的对象成立。
哪些键算整数
并非每个看起来像数字的字符串都是整数键,这条边界决定哪些键排序、哪些保持插入顺序。探针写入五个看起来像数字但不是规范索引的键:
non-index keys order -> ["4294967294","01","-1","1.5","4294967295"]
4294967294 是 2^32 - 2,最大的规范数组索引,它作为整数键排到最前。键 01、-1 与 1.5 不是规范整数(01 有前导零、-1 为负、1.5 不是整数),所以它们是普通字符串键,在整数键之后保持插入顺序。键 4294967295 是 2^32 - 1,比最大索引大一位,所以它不算整数键,留在末尾作为字符串键。边界是精确的:0 到 4294967294 是索引,而 2^32 - 1 不是。
这条边界就是为什么一个看起来像数字的键能静默改变整个对象的顺序。一个把大整数当作 id 并加入对象的代码把它按数字顺序放在最前,而同一个 id 带前导零或符号写出来就变成字符串键,留在插入的位置。两个被读者称为"相同数据"的对象因此以不同顺序遍历,任何读取第一个键或依赖位置的代码就看到了不同的值。
让代码保持安全的规则是:当插入顺序重要时用 Map,并把对象键顺序当作除了"整数后字符串"规则之外不可信。一条声称通过普通对象保留服务器字段顺序的记录,对任何名字是规范整数的字段都是错的,因为对象会把这个字段移到最前,无论服务器把它写在哪里。
for-in 与 Object.keys 有何不同
两个看似相同的循环读取不同的键集,差别在继承。探针构建一个原型带一个键的子对象,然后比较两个迭代器:
for...in -> ["own","inherited"]
Object.keys -> ["own"]
for...in 先遍历子对象自有键,再遍历它从原型链继承的可枚举键,所以列出 own 与 inherited。Object.keys 只返回对象自有的可枚举字符串键,所以只列出 own。一个用带默认值的原型创建的对象因此会在 for...in 循环里列出那些默认值,而在 Object.keys 里不列,建于 for...in 的序列化器会把继承字段复制进输出,而建于 Object.keys 的不会。
这个缺口是微妙泄漏的来源:对一个继承自已填充原型的对象跑 for...in 循环会读到调用者从未设置的字段,而一条存储循环输出的记录把这些字段归到对象身上。Object.keys、Object.values 与 Object.entries 都取自有关键的视角,所以建于它们的记录只描述对象拥有的东西。两个迭代器在符号上也不同:for...in 从不访问符号键,Object.keys 也不返回它们,所以两者都看不到上一个探针里的符号,只有 Reflect.ownKeys 或 Object.getOwnPropertySymbols 能到达符号。
JSON、Map 与 spread 如何遵循或破坏顺序
页面使用的序列化器与容器继承或覆盖普通对象的顺序,探针展示三种行为。JSON 遵循对象顺序,spread 按同一顺序复制,而 Map 即使对整数键也保持插入顺序:
JSON.stringify order -> {"2":2,"b":1,"a":3}
JSON.stringify mixed -> {"2":"y","10":"x","z":"w"}
Map keys -> ["b","2","1"]
Object with same keys -> ["1","2","b"]
spread copy order -> ["2","b","a"]
对键为 b、2、a 的对象 JSON.stringify 先写 2,因为整数键排到最前,所以序列化文本不反映字段设置的顺序。混合对象序列化为 2 然后 10 然后 z,同样是整数键按数字顺序在前。一个按相等比较两个序列化正文的页面,总是看到整数键在同一个开头位置,这隐藏了对象本来会显示的插入顺序差异。
Map 那几行是重要的对照。一个键为 b、2、1 的 Map 通过 [...map.keys()] 读取返回 b、2、1,即插入顺序,因为 Map 按设置条目的顺序迭代,不排序整数键。用 Object.fromEntries 把这个 Map 转成对象会把同样的键重排为 1、2、b,所以经过对象的往返改变了 Map 保留的顺序。一条把 Map 存成对象再读回的记录,已经静默地用"整数后字符串"规则替换了插入顺序。
这种分歧就是为什么容器选择是数据契约决定而不是风格偏好。一个页面收到设置列表、把它们存进 Map 以保持到达顺序、然后为存储把它们序列化成普通对象的做法,在转换的那一刻就丢弃了到达顺序。一个稍后加载已存对象并读第一个键的读取者看到最小的整数键,而不是第一个到达的设置,而存储形式里没有任何东西记录它是哪一个。把 Map 保持为 Map,或者把键顺序作为显式数组与值一起存储,才是保留对象所丢弃的事实的方式。
spread 那一行确认副本保持对象顺序:把 {b:1, 2:2, a:3} 展开到新对象得到键 2、b、a,整数键在前。Spread 按枚举顺序复制自有可枚举字符串键,所以它保留的是重排而不是原始插入。一个通过展开对象来构建干净副本并期望字段顺序与源一致的人,只有在对象没有整数键时才正确。
删除再重新添加在哪里移动键
删除一个键再添加它改变其位置,这个改变取决于键是否为整数。探针删除再重新添加一个字符串键和一个整数键:
delete+readd string -> ["b","c","a"]
delete+readd integer -> ["3","5"]
对于字符串键对象 {a, b, c},删除 a 再重新添加把 a 移到末尾,所以顺序变成 b、c、a。这是按插入排序的键的特征:移除它忘记它的槽位,重新添加就把它追加到末尾。对于键为 5 与 3 的整数键对象,删除 3 再重新添加让顺序保持 3、5,因为整数键无论何时添加都会重新排回其数字槽位。删除移到末尾的行为只适用于字符串键,整数键对它免疫。
这个差异就是为什么"删除键以把它推到末尾"的做法对字符串键有效而对整数键失败。一个删除再重新添加热条目以把它保持在对象末尾的缓存,只在键是字符串时有效;一旦键是规范整数,条目就弹回其数字位置,做法静默失效。同样的不对称也出现在数组里:数组的索引是整数键,所以数组总是按索引顺序遍历,探针用带一个额外字符串属性的普通数组确认了这一点。
记录一次键读数
一份键读数支撑一个很窄的断言:这个运行时、这个对象、用这个方法遍历,产生了这个键列表。六个字段让它可复核,且没有一个假定该列表是插入顺序:
- 迭代方法,
Object.keys、for...in、Reflect.ownKeys、JSON.stringify或Map迭代器,因为每一个返回不同的集合或顺序。 - 按返回的键,整数键已按数字顺序排好,因为原始列表是唯一忠实的记录,而一旦存在整数键,重新推导的"插入顺序"就是错的。
- 对象是否有贡献可枚举键的原型,因为
for...in会读它们而Object.keys不会。 - 是否请求了符号键,因为
Object.keys与for...in跳过它们,只有Reflect.ownKeys包含它们。 - 对于
Map,按设置时的插入顺序,因为转换为对象再转回会重排整数键。 - 是否有键被删除再重新添加,因为字符串键会移到末尾而整数键会重排,省略它的记录无法解释被移动的键。
这样的读数不能显示的是服务器如何产生同样的键。一个按字段顺序写 JSON 的服务器可能被一个把整数键移到最前的客户端运行时重新序列化,所以服务器选择的顺序不是页面读取的顺序。关于同样对象的拷贝一侧,structuredClone 与 JSON 展示了哪些容器类型在深拷贝后存活、JSON 往返会压塌什么;关于 regex 扫描序列化形式所得文本,RegExp lastIndex 与 flags 展示了为什么重复使用的 global 模式会翻转答案。
两个习惯让读数随时间保持诚实。第一,记录所用确切方法的原始键列表,因为"整数后字符串"规则让任何带数字键的对象上假定的插入顺序都是错的。第二,当插入顺序是数据的一部分时用 Map,因为普通对象在每次读取以及每次经过 JSON 和 spread 往返时都会重排整数键。
公开来源
结论
把对象键顺序当作"整数后字符串"规则,而不是插入顺序:整数键升序排列并在最前,只有 0 到 4294967294 的规范整数才算,01、-1、1.5 与 4294967295 保持为字符串键。要知道 JSON.stringify 和 spread 遵循这个顺序,Map 即使对整数键也保持插入顺序所以经过对象往返会重排它们,以及 for...in 读取继承键而 Object.keys 不会。删除再重新添加把字符串键移到末尾但让整数键留在数字槽位。记录确切方法和原始键列表,并在插入顺序是数据的一部分时用 Map,这样一份声称的字段顺序永远不会与运行时实际读取的顺序矛盾。
延伸阅读
继续深入:先看 structuredClone 与 JSON 里哪些容器类型能在深拷贝中存活,再看 RegExp lastIndex 与 flags 里 global 模式如何携带跨调用状态,最后看 数组排序与比较器 里排序给这些键的数组什么顺序。
本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界