浅谈 JS 中的「稀疏数组」

19 阅读3分钟

最近打算写一篇手写 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

也许你已经看出来了,稀疏数组的空洞实质上是数组对象自身上不存在某些整数索引属性。而 nullundefined 都是合法的对象属性的值,这就是为什么 [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

这些方法会跳过空位,或者会在返回的数组中包含空位:

而这些方法则会将空位视作值为 undefined 的元素:

如何创建密集数组

最简单的方式(性能不错,语义也清晰):

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;
}

Footnotes

  1. developer.mozilla.org/en-US/docs/…