JavaScript 数组空洞:同样读到 undefined,为什么 map 少执行了一次

10-01 4阅读

长度为三,不代表三个位置都有元素

表格有三行,程序用 new Array(3) 准备容器,再调用 map 生成行对象,结果回调一次都没有执行。问题不在回调函数,而在这个数组只有长度,没有对应的索引属性。这样的缺位通常叫空洞;它与某个索引确实存在、只是值为 undefined,是两种状态。

读取一个空洞位置也可能得到 undefined,因此只比较读取值会把两者混在一起。本文使用普通数组,假设原型链没有额外数字索引,并且遍历期间不修改数组。先固定这些前提,才能准确观察常见的空洞行为,而不把继承属性或动态修改引入实验。

把访问次数与属性存在性分别打印

下面保存为 .cjs 文件,再用 node 命令运行,让示例明确采用 CommonJS 模块格式。hole 的第二项留空,explicit 则明确放入 undefined;两者长度都是三。代码记录 map 和 forEach 实际访问的下标,并单独检查原数组与结果数组的键,避免只看控制台对数组的压缩显示。

JavaScript 数组空洞:同样读到 undefined,为什么 map 少执行了一次

AI生成概念示意图,非真实界面

const assert = require('node:assert/strict');
const hole = [10, , 30];
const explicit = [10, undefined, 30];

function inspect(name, values, expectedIndexes) {
  const mapVisits = [];
  const mapped = values.map((value, index) => {
    mapVisits.push(index);
    return value === undefined ? 'U' : value * 2;
  });
  const eachVisits = [];
  values.forEach((value, index) => eachVisits.push(index));
  assert.deepEqual(mapVisits, expectedIndexes);
  assert.deepEqual(eachVisits, expectedIndexes);
  assert.deepEqual(Object.keys(values), expectedIndexes.map(String));
  assert.deepEqual(Object.keys(mapped), expectedIndexes.map(String));
  assert.equal(mapped.length, 3);
  console.log(name + ' length:', values.length);
  console.log(name + ' map:', mapVisits.join(','));
  console.log(name + ' forEach:', eachVisits.join(','));
  console.log(name + ' keys:', Object.keys(values).join(','));
  console.log(name + ' mapped keys:', Object.keys(mapped).join(','));
  console.log(name + ' index 1 in:', 1 in values);
  return mapped;
}

const holeMap = inspect('hole', hole, [0, 2]);
const explicitMap = inspect('explicit', explicit, [0, 1, 2]);
assert.equal(hole[1], undefined);
assert.equal(explicit[1], undefined);
assert.equal(1 in hole, false);
assert.equal(1 in explicit, true);
assert.equal(1 in holeMap, false);
assert.equal(explicitMap[1], 'U');

const spread = [...hole];
assert.deepEqual(Object.keys(spread), ['0', '1', '2']);
assert.equal(spread[1], undefined);
assert.equal(1 in hole, false);
console.log('spread keys:', Object.keys(spread).join(','));
console.log('spread index 1 in:', 1 in spread);

let calls = 0;
new Array(3).map(() => ++calls);
assert.equal(calls, 0);
const rows = Array.from({ length: 3 }, (_, index) => ({ id: index + 1 }));
assert.deepEqual(rows.map(row => row.id), [1, 2, 3]);
assert.notEqual(rows[0], rows[1]);
console.log('empty constructor calls:', calls);
console.log('generated ids:', rows.map(row => row.id).join(','));

const removed = [10, 20, 30];
delete removed[1];
assert.equal(removed.length, 3);
assert.equal(1 in removed, false);
console.log('after delete:', removed.length, Object.keys(removed).join(','));
console.log('all checks passed');

hole 的两种回调都只访问零和二,explicit 都访问零、一、二。map 生成的新数组仍然长度为三,但空洞位置没有得到返回值,也不会凭空补上元素。显式 undefined 会触发回调,并在本例中变成字符串 U,所以它在 mapped keys 中保留了下标一。

不同操作回答的问题不同

Object.keys 返回可枚举的自有字符串键。对于示例这种没有额外属性的普通数组,它列出实际存在的元素下标;length 本身不可枚举,因此不会出现。如果给数组额外挂上 note 等属性,键列表也可能包含它们,不能再把键数量直接当成业务元素数量。

in 检查某个属性名是否能沿对象及其原型链找到。示例中一在 hole 里为假,在 explicit 里为真。若原型链上存在数字索引,缺少自有元素的位置也可能让 in 为真,map 和 forEach 还可能处理继承来的值。因此“跳过空洞”的结论需要带上普通原型这一前提。

数组展开语法使用迭代器逐个取值。在这里,展开 hole 得到一个三个索引都存在的新数组,第二项的值为 undefined。原来的空洞被具体化了,但原数组没有被改变。展开之后再 map,回调次数就可能增加;这不是纯粹的外观复制,是否接受这种转换要由业务决定。

初始化与删除时,明确想保留什么

new Array(3).map 看起来像执行三次,实际没有可访问的元素。需要按长度生成独立行对象时,可以像示例这样使用 Array.from 的映射参数,每个位置都调用工厂。不要用 fill 放入同一个可变对象后,就假定三行已经各自拥有独立状态;那是另一种共享引用问题。

delete 删除数组某个索引属性,不会把后面的元素往前移动,长度也不因此缩短。示例删除第二项后长度仍为三,键只剩零和二。若需求是移除一行并让后面的行补位,应使用明确的数组编辑方式,并同步维护外部行标识,不能把数组下标当成永远稳定的业务编号。

需要保留位置、表达尚未填写时,通常应在数据契约里选定明确值或状态字段,再决定是否接受 undefined。需要稀疏结构时,则在统计、映射和导出前检查所用操作怎样处理缺位。不要为了让回调执行而随手展开一次,却没有记录缺位信息已经改变。

测试至少同时包含空洞、显式 undefined 和普通数值,并核对长度、存在性与回调下标。打印读取值只能回答“取到了什么”,不能回答“这个位置是否原本存在”。把这三项证据分开,才能看出数据转换在哪一步改变了数组结构。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。