最近打算写一篇手写 JS 数组常用方法的文章,准备拿 Array.prototype.map()
开刀,发现还是应该单独写一篇文章来简单解释一下 JS 里的「稀疏数组」。
稀疏数组(sparse array) 是什么
这个名词是非常直白的:数组本来就是连续的索引和元素的集合,如果一个数组是稀疏的,说明这个数组里缺失了某些元素和其索引,出现了空洞(hole)。
在 JS 中,要创建稀疏数组非常容易,最常见的办法是用 Array
构造器新建一个数组:
const arr = new Array(3);
console.log(arr); // [ <3 empty items> ]
注意到 arr
的内容并不是 [undefined, undefined, undefined]
。
另一种方式是在正常的数组(密集数组,dense array) 上开洞:
const arr = [1, 2, 3]
delete arr[1];
console.log(arr); // [ 1, <1 empty item>, 3 ]
即:打印稀疏数组时一定会显示存在 "empty item(s)"。
稀疏数组上的空洞到底是什么
数组的「稀疏」和「密集」性质是从存储的角度来确定的。密集数组是以连续内存的形式存储的,而稀疏数组则表现得类似于散列表。
需要注意的是,[undefined, undefined, undefined]
是密集数组,而 [ <3 empty items> ]
却是稀疏数组。但是,JS 里的 undefined
不就是表示值不存在吗?为何会出现上面的情况?
让我们回想一下,JS 里的数组到底是什么?是对象:
> typeof []
'object'
我们知道,JS 对象的属性访问语法有两种:点语法(obj.propName
)和方括号语法(obj['propName']
)。而数组元素的访问语法正是方括号语法,并且 JS 对象的属性名只会是字符串或者 symbol
!至此,我们不难写出以下的代码:
const arr = [1, 2, 3];
for (let i = 0; i < arr.length; i++) {
console.log(arr[`${i}`]);
}
// 1
// 2
// 3
console.log(arr[9999]); // undefined
我们再作思考:JS 里访问对象上不存在的属性会如何?很明显,当对象本身不存在该属性时,会沿着原型链依次查找,如果真找不到就会返回 undefined
(🤦):
const arr = [1, 2, 3];
const obj = {
9999: 9999,
};
Object.setPrototypeOf(arr, obj);
for (let i = 0; i < arr.length; i++) {
console.log(arr[`${i}`]);
}
console.log(arr[9999]); // 9999
console.log(arr[10001]); // undefined
console.log(Object.hasOwn(arr, 0)); // true
console.log(Object.hasOwn(arr, 1)); // true
console.log(Object.hasOwn(arr, 2)); // true
console.log(Object.hasOwn(arr, 9999)); // false
console.log(Object.hasOwn(arr, 10001)); // false
也许你已经看出来了,稀疏数组的空洞实质上是数组对象自身上不存在某些整数索引属性。而 null
和 undefined
都是合法的对象属性的值,这就是为什么 [undefined, undefined, undefined]
是密集数组,而 [ <3 empty items> ]
却是稀疏数组。
使用稀疏数组时的注意事项
某些数组方法会忽略数组里的空位,例如,打印稀疏数组:
const arr = [1, 2, 3, 4, 5]; delete arr[1]; delete arr[3];
// [ 1, <1 empty item>, 3, <1 empty item>, 5 ]
// 普通 for 循环
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}
// 1
// undefined
// 3
// undefined
// 5
// forEach()
arr.forEach((el) => console.log(el));
// 1
// 3
// 5
map()
得到的新数组会保持稀疏性:
// map()
let arr = [1, 2, 3, 4, 5]; delete arr[1]; delete arr[3];
// [ 1, <1 empty item>, 3, <1 empty item>, 5 ]
arr = arr.map((el) => el ** 2);
// [ 1, <1 empty item>, 9, <1 empty item>, 25 ]
但是有的数组方法会将空洞视为存在的元素(值为 undefined
):
const arr = [1, 2, 3, 1, 2, 3]; delete arr[1]; delete arr[3];
// [ 1, <1 empty item>, 3, <1 empty item>, 2, 3 ]
console.log(arr.lastIndexOf(3)); // 5
const arr2 = Array(5); arr2[1] = 111; arr2[3] = 333;
arr2.fill(123);
console.log(arr2); // [ 123, 123, 123, 123, 123 ]
限于篇幅,此处直接摘抄 MDN 的说明1:
这些方法会跳过空位,或者会在返回的数组中包含空位:
concat()
copyWithin()
every()
filter()
flat()
flatMap()
forEach()
indexOf()
lastIndexOf()
map()
reduce()
reduceRight()
reverse()
slice()
some()
sort()
splice()
而这些方法则会将空位视作值为 undefined
的元素:
entries()
fill()
find()
findIndex()
findLast()
findLastIndex()
includes()
join()
keys()
toLocaleString()
toReversed()
toSorted()
toSpliced()
values()
with()
如何创建密集数组
最简单的方式(性能不错,语义也清晰):
const length = 42;
const arr = Array(length).fill(undefined);
更清晰的写法(规避了 Array 构造器怪异的行为,但比较慢):
const length = 42;
// 一定不是稀疏数组,会自动填 undefined
const arr = Array.from({ length });
以及传统写法(快):
const length = 42;
const arr = new Array(42);
for (let i = 0; i < length; i++) {
arr[i] = undefined;
}