返回知识中心
指纹

稀疏数组的空洞与各方法的处理差异

一支探针看一个空洞:哪些方法跳过它、哪些访问它、它会被变成什么,以及 indexOf 与 includes 为什么给出相反答案。

BotBrowser Team

文档中心

想直接进入 指纹 文档吗?

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

BotBrowser 支持在已声明 profile 下回放授权的同源夹具,并保存一份有界的记录,说明页面实际发送了什么、数组里哪些下标真正存在,但它不能让稀疏数组像密集数组那样工作,不能阻止 JSON 把空洞变成 null,也不能让 indexOf 与 includes 在缺失下标上取得一致。本评测用一支探针跑一个三元素、带一个空洞的数组,记录哪些迭代方法会访问空洞、哪些会跳过它、空洞在被复制、填充、排序或序列化之后变成什么,以及为什么同一个数组上的两次成员测试会给出相反答案。

摘要

  • 空洞是缺失的下标,不是存下来的 undefined:对 ['a', , 'c'] 探针打印 length=3 has1=false ownKeys=0,2,而密集的 ['a', undefined, 'c'] 打印 has1=true ownKeys=0,1,2。
  • forEach、map、filter、some、every 会完全跳过空洞,所以 map 结果里空洞还是空洞,filter 会返回一个更短的密集数组。
  • reduce 不带初值时从第一个存在的元素开始,因此把两个存在的值相加得到 a+c,而同样调用带初值 0 时只计两次访问。
  • 展开运算符和 Array.from 会把空洞填成 undefined,所以副本报告 has1=true value1=undefined,但 entries() 把空洞显示为 1:undefined,因为迭代器访问每个下标。
  • indexOf(undefined) 在空洞上返回 -1,而 includes(undefined) 返回 true,因为两者用的比较不同,其中一个会跳过空洞。
  • join 和 String() 把空洞当成空字段,所以 ['a', , 'c'].join('-') 是 "a--c",而 slice 和 reverse 保留空洞、sort 把空洞挪到最后一个下标。
  • 一次 JSON.parse(JSON.stringify(...)) 往返把空洞变成 null 并让它成为自有键,数组回来时比出去时更密。

目录

第一栏显示一个三元素数组,下标一处有空洞,标为 length=3 与 has1=false。第二栏显示 forEach 与 map 跳过空洞,而展开运算符把空洞填成 undefined。第三栏显示 indexOf 返回 -1,旁边 includes 返回 true。第四栏显示一次 JSON 往返把空洞变成 null 并作为自有键加入,底部一行说明空洞是缺失的下标,不是存下来的 undefined。

空洞与存下来的 undefined 有什么区别

Array 允许只给长度而不要求每个下标都有元素,留下的那段间隙就是空洞。探针用赋值而不是字面量来构造它,这正是空洞在真实代码里出现的方式:程序算出下标,而不是把每个位置写出来。

node=v26.9.0
shape -> length=3 has1=false ownKeys=0,2 json=["a",null,"c"]
dense -> length=3 has1=true ownKeys=0,1,2 json=["a",null,"c"]
forEach -> visits=0:"a"|2:"c"
map -> result=["0:\"a\"",null,"2:\"c\""] has1=false length=3
filter -> result=["a","c"] length=2
some -> indexesSeen=0,2
reduce-no-init -> a+c
reduce-with-init -> count=2
spread -> length=3 has1=true value1=undefined
from -> length=3 has1=true value1=undefined
entries -> 0:"a"|1:undefined|2:"c"
indexOf -> -1 includes -> true
indexOf-c -> 2
join -> "a--c"
toString -> "a,,c"
reverse -> length=3 has1=false keys=0,2
sort -> length=4 has1=true has3=false keys=0,1,2
fill -> has1=true value1="x"
slice -> length=3 has1=false
copyWithin -> has1=true value1="c"
json-roundtrip -> has1=true value1=null

头两行就是整篇文章的缩影。两个数组都是 length=3,也都序列化成 ["a",null,"c"],所以 JSON.stringify 分不出它们。差别在 1 in a 和 Object.keys:带空洞的那个报告 has1=false,只列出键 0,2;密集的那个报告 has1=true,列出 0,1,2。空洞不是一个值为 undefined 的属性,而是那个属性根本不存在,于是每个读取数组的方法都得决定拿这份「不存在」怎么办。

这就是为什么写成 if (arr[i] === undefined) 的检查会把两种情况都判错。在带空洞的数组上,读 a[1] 得到 undefined,和在密集数组上完全一样,因为按语言规则缺失下标就读作 undefined。唯一能把它们分开的是存在性检查 1 in a,或它的等价形式 Object.prototype.hasOwnProperty.call(a, 1);当天数组可能从被污染的 prototype 继承了一个数字属性时,要用后面这种。

这个区别在请求体积的工作里有代价。一个靠给算出的下标赋值来拼结果的页面,会在某个分支没跑的位置留下空洞,序列化后的形式就在这些位置放上 null。读这份 JSON 的消费方分不清「故意记下的 null」和「从没填过的空洞」,所以一个在意这个区别的 schema 需要一个旁边的字段或一个显式的存在标记,因为数组本身带不过 JSON.stringify。

空洞还能经受住一次看起来无损的传递。把数组传给函数、存进对象字段、再返回,拿到的还是同一个对象、同样的空洞,因为这条路径上没有任何一步复制它。空洞只会在某一步把它物化时消失,而哪些步骤会物化它,就是下面两节的主题。

有三种形态能造出空洞而没有人写下逗号,每一种在评审里都很容易被漏掉。第一种是探针用的那种计算赋值:一个只给通过过滤的下标写 result[index] = value 的循环,会让其余位置缺失。第二种是先 new Array(n) 再部分写入,因为构造器只设 length、别的什么都不设,所以每个下标一开始都是空洞。第三种是 delete arr[i],它移除了自有属性、length 不变,所以一次看起来像稀疏删除的操作恰恰就是一个空洞,并且在探针测过的每个方法里都按空洞行事。

第四条路径更隐蔽,来自越过数组末端的赋值。写 arr[arr.length + 2] = 'x' 会把 length 设成新下标加一,并让中间两个下标保持为空洞,因为语言只扩展数组、不补间隙。因此一个用计算偏移而不是 push 来追加的页面会悄悄造出空洞,存在元素的个数不再等于 length。

跨边界的检测是这件事变得可操作的地方。一份保存页面数组的记录可以带上 length 和序列化后的 JSON,而这两样在「带 null 的密集数组」和「带空洞的稀疏数组」上是一致的。探针头两行正是这种情况:两者都是 ["a",null,"c"]。所以想保住这个区别的记录必须补上存在映射,比如 Object.keys(arr);只读序列化形式的消费方还没开始就已经丢了这份信息。

这个区别对相等判断也重要。两个都序列化成 ["a",null,"c"] 的数组,在遍历自有键的深比较函数下是不相等的,因为一个有下标 1、另一个没有;在先把两者都序列化成 JSON 的函数下是相等的。因此把页面输出和夹具比较的测试取决于它用哪种比较风格,而手工搭的夹具会在页面产出稀疏数组时悄悄和密集数组对上。

哪些迭代方法会跳过空洞

数组迭代方法分成两组,而这个分法并不直观。接收回调的五个(forEach、map、filter、some、every)都把空洞当成下标不存在一样跳过。探针的 forEach 打印 visits=0:"a"|2:"c",列表里没有下标 1;some 打印 indexesSeen=0,2,因为它从不为空洞调用回调。

map 是让人意外的那一个,因为它的结果保留了空洞。探针打印 result=["0:\"a\"",null,"2:\"c\""] has1=false,所以映射后的数组长度不变,下标 0 和 2 是真实值,下标 1 仍然什么都没有。一个从每个元素构造字符串的 map 会产生一个空洞依然是空洞的结果,之后再对这个结果做 join,会把空洞显示成空字段,而不是字符串 undefined。

filter 正相反,因为它没法保留空洞:结果只包含被谓词接受的元素,而空洞从不交给谓词。探针传一个恒真的谓词,得到 result=["a","c"] length=2,比源数组短一位的密集数组。于是 filter(() => true) 成了一个去空洞工具,而一个拿它当复制数组手段的页面,其实是在顺手把副本密化。

reduce 夹在两组之间,行为取决于有没有初值。不带初值时,探针的 reduce 从第一个存在的元素开始,返回 a+c,空洞从不到达回调,加法次数是一、不是二。带初值 0 时,探针打印 count=2,因为累加器从一开始就存在,回调对每个存在的元素各跑一次。不带初值的这种写法在一个没有存在元素的数组上还会抛错,那是空数组情形的稀疏等价。

some 和 every 会短路,空洞对两者都不算一次访问。这在一个期待 every 已经看过每个下标的校验循环里要紧:一个带空洞的数组可以通过一段回调从没看见过的范围的 every 检查。本该拒绝那个缺失值的谓词根本不跑,所以对一个部分填充数组的 every 比它看起来的要弱,需要拒绝空洞的页面必须单独查存在性。

迭代协议本身不跳过空洞。探针的 entries() 打印 0:"a"|1:undefined|2:"c",所以 for...of 循环或在 entries() 上做展开会看到每个下标,并把空洞报告成 undefined。for...of 给出的序列正是比较器要排序的那个序列,数组排序与比较器 从排序那一侧讲了这件事。这是走遍整个数组的另一条路:回调方法跳过空洞,而迭代器协议把空洞填上,所以一个页面用不同循环会从同一个数组得到两个不同的元素计数。

空洞在复制数组时变成什么

复制数组没有唯一答案。有的操作保住空洞,有的用 undefined 替换它,有的把它挪走。探针做了其中五个,从同一个源得到三种不同结果。

展开运算符和 Array.from 都填上空洞。探针打印 spread -> length=3 has1=true value1=undefined,from 也一样,所以副本是密集的,空洞处是一个显式的 undefined。这是一个空洞最常见的「无声消失」方式:重构里一次 [...arr] 复制把稀疏数组变成了密集数组,任何原本指望 filter 甩掉空洞的代码,现在多了一个 undefined 要处理。

slice 保留空洞。探针打印 slice -> length=3 has1=false,所以切整个数组会复现同样的形态,因为 slice 复制自有键、不凭空补上缺失的那个。slice 复制的键就是遍历自有键时报告的那些键,对象键顺序 解释了它们为什么按顺序回来、而不是插入顺序。这是把存储而不是值做浅拷贝的那类复制,空洞有意义时就该用它。

fill 会写进它覆盖的每个下标,包括空洞。探针填下标 1,打印 fill -> has1=true value1="x",所以覆盖到空洞的 fill 会把它变成真元素。copyWithin 靠移动做同样的事:copyWithin -> has1=true value1="c" 显示值被复制到空洞上面。reverse 保留空洞。探针打印 reverse -> length=3 has1=false keys=0,2,所以把这个三元素数组反转之后,空洞仍是唯一的缺失下标;这说得通,因为反转把下标 1 映到它自己,并让两个存在的值彼此交换位置。

sort 的结果值得读两遍。探针先用 concat 追加 b,再对四元素结果排序;排序那一步打印 sort -> length=4 has1=true has3=false keys=0,1,2,所以排序把空洞从下标 1 挪到了最后一位,并把下标 1 填成真实值。一个排完部分填充数组再查 length 的页面仍然得到 4,但空洞现在在末尾而不是中间,所以一个在第一个 undefined 停下的循环读到的元素和排序前不同。形态没有变,变的只是位置:下标 1 的存在性从 false 翻成 true,下标 3 的则翻向另一边。

join 和 String() 彼此一致、与别人都不一致。探针打印 join -> "a--c" 和 toString -> "a,,c",两者都把空洞渲染成空字段,而不是 undefined 这个词。这和一个含真正空字符串的密集数组的输出一样,也就是说一条拼接好的字符串,不去数原来有几个空洞,就没法拆回原数组。因此经 join 和 split 的一次往返,会丢掉 in 原本携带的存在信息。

为什么两次成员测试给出相反答案

空洞最锋利的后果是同一数组上的两次成员测试给出相反答案。探针在带空洞的数组上搜索 undefined,打印 indexOf -> -1 includes -> true,它还打印 indexOf-c -> 2,表明搜索一个存在的值是正常的。

原因是 indexOf 用严格相等并跳过空洞,而 includes 用 SameValueZero 比较并把空洞当成 undefined。空洞读作 undefined,SameValueZero 认为 undefined 和 undefined 匹配,于是 includes 返回 true。indexOf 从不访问空洞,所以返回 -1。因此一个用 if (arr.indexOf(x) === -1) 保护值的页面,和一个用 if (!arr.includes(x)) 保护值的页面,在 x 是 undefined 且数组有空洞时必然分歧,而且分歧是静默的。

这个差别不是哪个方法有缺陷,而是有据可查的分工,它存在是因为 includes 是后来带着 NaN 的情形加进来的,所以必须用 SameValueZero 才能让 [NaN].includes(NaN) 为真。同一套比较让空洞和 undefined 匹配,这是语言保留下来的副作用,因为去掉它会让 includes 跟自相矛盾。一个既要判断「值在不在」又要判断「下标填没填」的页面,因此需要两个测试,一个查值、一个查存在,而这两个问题在稀疏数组上答案不同。

JSON 往返用一种掩盖问题的方式把缺口补上。探针打印 json-roundtrip -> has1=true value1=null,所以 JSON.parse(JSON.stringify(holey)) 返回的数组里下标 1 是存在的、值是 null。空洞变成了一个值为 null 的自有键,这说明基于 JSON 的克隆会悄悄把数组密化,并把「不存在」换成一个 schema 可能并不期待的值。这跟展开复制是同一个风险,但更难察觉,因为做这件事的代码看上去常常像是保住了数据。

记录一次稀疏数组读数

一条稀疏数组读数支撑的是一个窄结论:这个运行时、这个形态、这些方法,产生了这些结果。四个字段让它保持可核对:

  1. 存在映射,因为 length 和序列化形式分不出空洞和存下来的 undefined。
  2. 操作本身,以及它访问、跳过还是物化空洞,因为每个操作的答案都不同。
  3. 结果的自有键,因为 map 留住空洞、filter 丢掉它、排序把它挪到末尾。
  4. 值有没有经过 JSON,因为一次往返会把每个空洞变成 null 并让它成为自有键。

公开来源

实践结论

把稀疏数组当成与密集数组不同的类型:用 in 或 hasOwnProperty 查存在性,而不是读值;预期回调方法会跳过空洞,而展开运算符和 Array.from 会把它填上;还要记住排序会把空洞停到最后一位,它原本在中间。当一个值必须经受一次复制时,按复制对空洞做了什么来选:slice 保住它,展开运算符把它密化,JSON 往返把它变成 null。至于填充值一旦存在之后按什么顺序落位,读数组排序与比较器;至于 JSON 复制对结构其余部分做了什么,读structuredClone 与 JSON。

#Array#稀疏数组#JSON#迭代#ECMAScript

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

让 BotBrowser 从研究走向生产

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