稀疏数组
稀疏数组是指部分下标未被赋值而存在空槽的数组,本质是跳过部分属性名的特殊对象。孔洞代表键不存在,不同于值为空的未定义值。各类数组方法对孔洞的处理机制各异,有的直接跳过,有的则将其视为未定义。此外,稀疏数组在序列化时孔洞会转为空值,且在底层引擎中会引发性能降级。因此在日常开发中,稀疏数组多为负担,建议优先使用稠密数组或映射表等数据结构,以避免潜在的逻辑与性能问题。
稀疏数组
先来做一个小魔术
请你临时当一回人肉解释器,看这几行,先在心里默默报答案:
js
const a = [1, , 3];
console.log(a.length); // ?
console.log(a[1]); // ?
console.log(Object.keys(a)); // ?
然后是最好玩的一行:
js
new Array(3).map(() => 0); // ?
你亲手写了 map,可那个回调一次都没被叫到;你以为造了三个元素,length 也的确是 3,但真实存在的键只有两个。
这种"长度和内容对不上号"的数组,就叫稀疏数组。
稀疏数组究竟是什么
普通数组(顺口叫稠密数组)里,0 到 length - 1 每个下标都有值。稀疏数组则不然:某些下标压根没被赋过值——是"不存在",而不是"存在且为 undefined"。
这种缺席的下标,规范里叫 empty slot(空槽),社区更爱叫它 hole(孔洞)。
js
const dense = [1, undefined, 3];
const sparse = [1, , 3];
Object.keys(dense); // ['0', '1', '2']
Object.keys(sparse); // ['0', '2'] ← 悄悄少了 '1'
理解它的钥匙只有一把:数组本质上是种特殊对象,下标就是属性名。稀疏数组不过是一个"跳过了几个属性名"的数组。length 描述的是下标范围,而不是元素个数。
js
sparse.length; // 3 ← 说的是"范围到哪"
Object.keys(sparse).length; // 2 ← 说的是"真有几个"
孔洞是怎么冒出来的
孔洞不会凭空出现(大多是我们自己手一抖),常见来源有这么几种。
构造函数里塞一个数字
js
const a = new Array(3); // [ <3 empty items> ]
const b = Array(3); // 与上面等价
这里有个小分界:参数是单个数字时,它造的是"这么长的空数组";只要参数不是单个数字,它就变回"元素列表"。
js
new Array('3'); // ['3'] 长度 1
Array(1, 2, 3); // [1, 2, 3] 长度 3
要是参数是负数或小数,它会礼貌地拒绝:
js
new Array(-1); // RangeError: Invalid array length
new Array(1.5); // RangeError: Invalid array length
字面量里的逗号空档
js
const a = [1, , 3]; // 下标 1 是孔洞
const b = [, , ,]; // 长度 3,全空(末尾那个逗号不算元素)
一步跨太远的赋值
js
const a = [];
a[10] = 'x';
// [ <10 empty items>, 'x' ] 长度 11
中间的座位没人坐,就都成了空槽。
delete 运算符
js
const a = [1, 2, 3];
delete a[0];
// [ <1 empty item>, 2, 3 ] 长度还是 3
delete 删掉的是属性,不是"把后面的元素挤过来",所以长度不动,位置留下一个洞。日常想删元素,用 splice 或 filter 更省心。
把 length 调大
js
const a = [1, 2, 3];
a.length = 5; // [1, 2, 3, <2 empty items>]
而把 length 调小则是干脆利落地截断,不留洞:
js
const a = [1, 2, 3, 4, 5];
a.length = 2; // [1, 2]
孔洞与 undefined 的分界线
这是全文最要紧的一条线,也是最容易看走眼的地方。
js
const sparse = [1, , 3];
const dense = [1, undefined, 3];
sparse[1]; // undefined
dense[1]; // undefined
sparse[1] === dense[1]; // true ← 读出来长得一样
从读取这一面看,两者都返回 undefined。但翻到存在性那一面,就分道扬镳了:
js
1 in sparse; // false ← 键不在
1 in dense; // true ← 键在,值恰好是 undefined
Object.hasOwn(sparse, 1); // false
Object.hasOwn(dense, 1); // true
Object.keys(sparse); // ['0', '2']
Object.keys(dense); // ['0', '1', '2']
一句话收好:孔洞是"没有这个键",
undefined是"有这个键,只是值为空"。稀疏数组所有古怪脾气,源头都在这一句。
如何识破一个稀疏数组
没有内置的 Array.isSparse(),但手边有几把趁手的扳手:
js
const a = [1, , 3];
// 某个下标是不是孔洞
1 in a; // false
Object.hasOwn(a, 1); // false
// 真实存在的下标
Object.keys(a); // ['0', '2']
// 整体是否稀疏:真实元素数 < length
Object.keys(a).length < a.length; // true
// 是不是彻底空
Object.keys(a).length === 0; // false(还留着两个)
顺手提一句:
Object.hasOwn(a, i)比i in a更严格,因为in会顺着原型链继续找。对纯数组二者结果一致,但万一有人往Array.prototype上挂了东西,hasOwn才是你想要的那把尺子。
遍历语法对孔洞的各路态度
先看遍历"姿势",因为"看着在做同一件事"的几种写法,对孔洞的态度并不一致。
| 遍历方式 | 是否访问孔洞 | 孔洞处看到什么 |
|---|---|---|
for (let i = 0; i < a.length; i++) |
是 | a[i] 为 undefined |
for...in |
否 | 只枚举真实存在的键 |
for...of |
是 | 得到 undefined |
arr.forEach |
否 | 回调不执行 |
arr.keys() |
是 | 产出全部下标 |
arr.values() / arr.entries() |
是 | 孔洞位产出 undefined |
js
const a = [1, , 3];
for (const v of a) console.log(v); // 1 undefined 3 ← 连孔洞一起访问
for (const k in a) console.log(k); // '0' '2' ← 跳过了孔洞
for...of 值得多看一眼:它的迭代器是按位置逐个吐值的,孔洞会被补成 undefined。这一路等于把数组悄悄"压实"了,"这个下标到底在不在"的信息就此溜走。
数组方法逐个看:对孔洞的态度
下面让样本数组 [1, , 3](下标 1 是孔洞)挨个过一遍每个 Array.prototype 方法。"是否碰到孔洞"指回调或迭代会不会在下标 1 处停一下。
| 方法 | 是否碰到孔洞 | 对孔洞的处理 | 样本结果 |
|---|---|---|---|
forEach |
否 | 跳过,回调不执行 | 回调拿到下标 0、2 |
map |
否 | 跳过,结果同位仍是孔洞 | [1, <hole>, 3](仍稀疏) |
filter |
否 | 跳过,孔洞被丢下 | [1, 3] |
reduce |
否 | 跳过 | 累加只算真实元素 |
reduceRight |
否 | 跳过 | 同上,从右往左 |
every |
否 | 跳过(孔洞不参与判断) | every((x) => x !== undefined) 为 true |
some |
否 | 跳过 | some((x) => x === undefined) 为 false |
find |
是 | 把孔洞当 undefined |
命中时可能返回 undefined |
findIndex |
是 | 把孔洞当 undefined |
findIndex((x) => x === undefined) 得 1 |
findLast |
是 | 同上,从右往左 | 仍会停在孔洞 |
findLastIndex |
是 | 同上,从右往左 | 仍会停在孔洞 |
flat |
— | 直接删掉孔洞 | [1, 3] |
flatMap |
否 | 跳过,孔洞被丢下 | [1, 3] |
indexOf |
否 | 跳过(孔洞视作不存在) | indexOf(undefined) 得 -1 |
lastIndexOf |
否 | 跳过 | -1 |
includes |
是 | 把孔洞当 undefined |
includes(undefined) 得 true |
join |
— | 孔洞当空字符串 | '1,,3' |
toString |
— | 同上 | '1,,3' |
toLocaleString |
— | 同上 | '1,,3' |
slice |
— | 原样保留孔洞 | [1, <hole>, 3] |
concat |
— | 原样保留孔洞 | [1, <hole>, 3, 9] |
reverse |
— | 保留孔洞(随位置一起调头) | [3, <hole>, 1] |
sort |
— | 保留孔洞,挪到末尾 | [1, 3, <hole>] |
splice |
— | 搬运时保留孔洞 | 删出的片段仍带孔洞 |
push / unshift |
— | 原有孔洞不受影响 | [0, 1, <hole>, 3] |
pop / shift |
— | 按位置取删,与孔洞无关 | pop() 得 3 |
fill |
— | 连孔洞一起填 | fill(0) 得 [0, 0, 0] |
copyWithin |
— | 目标位的孔洞被覆盖 | 拷贝后变稠密 |
at |
是 | 孔洞处返回 undefined |
at(1) 得 undefined |
with |
— | 新数组里孔洞被替换或补成 undefined |
with(1, 'y') 得 [1, 'y', 3] |
toReversed |
— | 稠密新数组,孔洞变 undefined |
[3, undefined, 1] |
toSorted |
— | 稠密新数组,孔洞变 undefined 排末尾 |
[1, 3, undefined] |
toSpliced |
— | 稠密新数组 | [1, 3] |
keys |
是 | 产出全部下标 | [0, 1, 2] |
values |
是 | 孔洞位产出 undefined |
[1, undefined, 3] |
entries |
是 | 孔洞位产出 undefined |
[[0,1],[1,undefined],[2,3]] |
Symbol.iterator |
是 | 孔洞位产出 undefined |
[...a] 得 [1, undefined, 3] |
Array.from |
是 | 孔洞补成 undefined |
[1, undefined, 3] |
照着这张表,可以把它们收成几类。
会停在孔洞的(把孔洞当 undefined)
js
find / findIndex / findLast / findLastIndex
includes
at
keys / values / entries / Symbol.iterator
Array.from
悄悄跳过孔洞的(回调不执行)
js
forEach / map / filter / flatMap
reduce / reduceRight
every / some
跳过且顺手删掉孔洞(结果稠密、长度变短)
js
filter / flat / flatMap
跳过但保留孔洞形态
js
map // 跳过的位置补回孔洞
slice / concat / reverse / sort / splice
会把孔洞填成具体值(稠密化)
js
fill / copyWithin
with / toReversed / toSorted / toSpliced / Array.from / [...arr]
完全绕开数组内容、只做转字符串的
js
join / toString / toLocaleString // 孔洞 → 空字符串
indexOf / lastIndexOf // 跳过孔洞,当作不存在
JSON.stringify // 孔洞 → null
其中有两组对照特别值得记牢,因为"改自身"和"返回新数组"这两支的表现并不一样:
js
const a = [3, , 1];
a.reverse(); // 原数组: [1, <hole>, 3] ← 孔洞还在
a.toReversed(); // 新数组: [1, undefined, 3] ← 孔洞补成 undefined
const b = [3, , 1];
b.sort(); // 原数组: [1, 3, <hole>] ← 孔洞挪到末尾,仍是真孔洞
b.toSorted(); // 新数组: [1, 3, undefined] ← 孔洞补成 undefined
一句话记住:
to*家族的新方法,产出的永远是一份稠密副本;而它们的老对应方法会动原数组,并且把孔洞原样留着。
还有两条容易记反的:
js
[1, , 3].indexOf(undefined); // -1 ← 跳过孔洞,没找到
[1, , 3].includes(undefined); // true ← 把孔洞当 undefined
indexOf 和 includes 对同一份数据给出相反的答案,而且同为"查找",every/some 会跳过孔洞,find/findIndex 却会停在孔洞上。分组记,比死背单个方法靠谱。
序列化时的变脸
稀疏数组在序列化时会换上不同的"妆容",其中 JSON 这一款最值得留意。
js
const a = [1, , 3];
a.join(','); // '1,,3' ← 孔洞变空字符串,位子还在
a.toString(); // '1,,3'
JSON.stringify(a); // '[1,null,3]' ← 孔洞变 null
JSON.stringify(new Array(3)); // '[null,null,null]'
JSON.stringify 把孔洞写成 null 是规范里写死的行为,理由也简单:JSON 里没有"孔洞"这个概念。于是就有了这样一个日常小插曲:
js
const list = new Array(3);
// 前端拿到 '[null,null,null]'
前端会当成三个 null,后端原本只是想"先占个位"。数据往返一趟,稀疏数组就永久压成了 null,那点稀疏的心思也就没了。凡是跨越 JSON 边界的数据,都别指望稀疏性还能留住。
length 属性的小脾气
length 不是"元素个数",而是"下标上界加一"。围着它有几条略显任性的规则:
js
const a = [1, , 3];
a.length; // 3
a.length = 10; // [1, <9 empty items>] 变大 → 造孔洞
a.length = 1; // [1] 变小 → 直接截断
a.length = 0; // [] 清空
const b = [];
b['3'] = 'x'; // 字符串键被当作下标,长度变 4
b['foo'] = 'y'; // 非数字键,只是普通属性,长度不受影响
b.length; // 4
只要你写的是合法下标(0 到 2³² - 2 之间的整数或整数形字符串),length 就会在必要时被撑大,中间的座位自动补成孔洞。而 'foo'、'1.5'、'-1' 这类键,只是挂在数组对象上的普通属性,长度对它无感。
引擎眼中的稀疏数组:元素种类
为什么"能用就行"的稀疏数组,在性能上偶尔会拖点后腿?答案藏在 JavaScript 引擎(V8,也就是 Chrome 和 Node.js 用的引擎)给数组贴的"元素种类"标签里。
引擎大致有这样一条进化链:
PACKED_SMI_ELEMENTS 清一色小整数,最紧凑
PACKED_DOUBLE_ELEMENTS 清一色浮点数
PACKED_ELEMENTS 普通引用类型
↓ 一旦出现孔洞,就滑进下面这一支
HOLEY_SMI_ELEMENTS
HOLEY_DOUBLE_ELEMENTS
HOLEY_ELEMENTS
规则有两条:
- 孔洞会把数组从
PACKED_*推入HOLEY_*,而且这一步不可逆——事后补上值也回不到紧凑形态。 - 从
SMI→DOUBLE→ELEMENTS的降级同样是单行道,越往下越费力。
为什么 HOLEY 更慢?因为读取某个下标时,引擎没法断定"这里一定有值",得先判断它是不是孔洞;若是孔洞,还得继续去原型链上找同名属性。稳妥,但更贵。
而这行再普通不过的代码:
js
const a = new Array(1000); // 一出生就是 HOLEY_SMI_ELEMENTS
它直接就拿到了靠下的种类。HOLEY 一旦背上,还可能牵连后续操作被优化器"去优化"(deopt)。
至于代价有多大,得看场景:绝大多数业务代码里,这点开销小到仪器都测不出来;但在热的数值循环、大数据处理、图形计算里,它就可能变成性能曲线上的一点小抖动。所以原则很朴素——能稠密,就别稀疏。
稀疏数组常见的几处小别扭
把前面散落的心得攒到一处,方便日后对照。
别扭一:new Array(n) 配 map,什么都不会发生
js
new Array(3).map(() => 0); // [ <3 empty items> ] ← 回调压根没跑
想要"长度为 3 的全 0 数组",得把值亲手放进去:
js
new Array(3).fill(0); // [0, 0, 0]
Array.from({ length: 3 }, () => 0); // [0, 0, 0]
别扭二:forEach 数数时对不上账
js
const a = new Array(3);
let count = 0;
a.forEach(() => count++);
console.log(count); // 0 ← 一次都没进
别扭三:indexOf 找不到"看着在那里"的 undefined
js
[1, , 3].indexOf(undefined); // -1
[1, , 3].includes(undefined); // true ← 和上一行唱反调
别扭四:JSON 往返会丢信息
js
JSON.parse(JSON.stringify([1, , 3])); // [1, null, 3] 孔洞悄悄变 null
别扭五:把 delete 当成删除
js
const a = [1, 2, 3];
delete a[1];
a.length; // 3 ← 长度没动,留了个洞
想删就用 a.splice(1, 1)。
别扭六:拿稀疏数组当"定长容器"
js
const grid = new Array(100); // 想搭 100 格的画布
grid[0] = 1;
grid.forEach(...); // 除了下标 0,其余全被跳过
什么时候该用,什么时候绕开
说句实在话,稀疏数组在业务代码里几乎总是负担。它真正体面的用武之地很窄:
- 需要和底层内存或类型化数据对齐的算法,且孔洞有明确含义;
- 用下标表示 ID 的"存在性集合",且绝大多数位置为空——不过这种场合,
Map或Set往往更顺手; - 解析外部格式(比如表格里的空单元格)时,先当个中间表示。
除此之外,遇到"我知道长度、但眼下还没值"的场景,直接造稠密的就好:
js
// 不太推荐
const a = new Array(n);
a[i] = v;
// 更省心:先建稠密数组,再逐格填
const a = new Array(n).fill(0);
const b = Array.from({ length: n }, () => null);
判断标准可以很简单:如果 Object.keys(arr).length 会和 arr.length 对不上,而你并不打算利用这个差异,那这个数组就不该是稀疏的。
把稀疏数组变回稠密数组
拿到一个来路不明的稀疏数组时,按需要挑一种"压实"方式:
js
const a = [1, , 3];
[...a]; // [1, undefined, 3] 孔洞补成 undefined,位子还在
Array.from(a); // [1, undefined, 3] 同上
a.filter(() => true); // [1, 3] 干脆丢掉孔洞
a.flat(); // [1, 3] 同上
- 想保住下标对齐、只把孔洞补成
undefined→ 用扩展运算符或Array.from; - 想只留真实元素、顺带压缩长度 → 用
filter或flat。
反过来,想稳当地判断再取值,先 i in arr(或 Object.hasOwn)再读,比直接 arr[i] !== undefined 靠得住——后者会把"值恰好是 undefined"的稠密数组当成孔洞。
写在最后
稀疏数组的各种古怪脾气,说到底只有一句话:它是个对象,孔洞意味"键不存在",而不是"值为 undefined"。把这句话握稳,indexOf 与 includes 的唱反调、map 回调不执行、JSON 里变 null、引擎里的 HOLEY 降级,都能顺着推出来。
日常写代码时,把它当"注意事项"而不是"贴心功能":要定长数组,就用 fill 或 Array.from 造稠密的;要"稀疏的映射",就用 Map。这样一来,你手上那些无缘无故的 bug 大概能少掉一大半。