先记住这个答案
forEach 和 map 这类方法开始时读一次 length,之后最多只访问到这个长度为止,所以回调里 push 进去的新元素本轮不会被处理。但它们并没有复制数组,每访问一个索引,都会先检查这个位置还在不在,再读取当时的值。所以还没轮到的位置被改了值,后面的回调读到的是新值;被 delete 掉的位置会直接跳过。工程上最好别在遍历里改原数组,要改就先复制一份,或者改完再遍历。
- 开始时记下长度,末尾追加的元素本轮不访问
- 没访问到的位置,读到的是当时的最新值
- 访问前被
delete的位置会被跳过 - 回调里
splice删除当前项会漏掉下一项
在第一次回调里同时追加、删除、改值
下面这段把三种修改都塞进处理 A 的那次回调里,看看后面的回调分别看到了什么。
const values = ['A', 'B', 'C']
const seen = []
values.forEach((value, index) => {
seen.push(index + ':' + value)
if (index === 0) {
values.push('D') // 追加到末尾
delete values[1] // 删掉还没访问的 B
values[2] = 'C*' // 改掉还没访问的 C
}
})
console.log(JSON.stringify(seen))
console.log(values.length, values[3], Object.hasOwn(values, 1))查看输出与解释
["0:A","2:C*"]
4 D false一共只访问了两次。索引 1 在轮到它之前被删了,forEach 检查发现这个位置不存在,就跳过。索引 2 读到的是改过的 C*,说明值是访问时才读的。D 确实加进去了,数组长度变成 4,但开始时记下的长度是 3,所以索引 3 不在这次遍历的范围里。
可以把它想成一个按工位编号点名的班长:开始时数了一下有 3 个工位,就只点 3 个号。点名途中来了新人坐到第 4 个工位,不会被点到;还没点到的工位换了人,点到时看到的就是新人;工位空了,就跳过。
splice 删除当前项为什么会漏掉下一项
比 delete 更常见的坑是在回调里用 splice 删元素。比如要删掉所有无效项,写成 list.forEach((item, i) => { if (!item.valid) list.splice(i, 1) })。
splice 会把后面的元素整体往前挪一位,而 forEach 的索引照常加 1。这时刚挪到当前位置的那个元素就被跳过了。如果两个无效项挨在一起,第二个就会漏删。delete 不挪位置,只留一个空位,所以不会有这个问题,但会留下稀疏数组,也不是好办法。
- 三种安全的改法
- 最省事的是不改原数组,直接用
filter生成新数组:list = list.filter(item => item.valid)。如果必须原地删,就倒着遍历,从最后一个索引往前splice,前面的元素位置不受影响。第三种是先收集要删除的索引,遍历结束后再统一处理。 另外,复制数组只复制了外层。元素是对象时,[...list]里的对象和原数组是同一批,在回调里改对象字段,原数组照样能看到。
容易答错的地方
- 以为遍历开始时整个数组被冻结了
- 固定的只有长度。没访问到的位置,改值和删除都会被后面的回调看到。需要一份稳定的输入,要自己先复制,比如
[...values].forEach(...)。 - 以为遍历中新增的元素一律看不到
- 追加到原长度之外确实看不到。但如果原数组在索引 1 有个空位,处理索引 0 时给它补上值,轮到索引 1 时这个位置已经存在,回调就会执行。判断时要看新增发生在哪个位置、是不是已经访问过。
面试官还会怎么问?
map 的结果长度会因为回调里的 push 变长吗?
不会。map 在开始时就按原长度创建了结果数组,追加的元素不会被映射,结果长度还是原来的长度。被跳过的空位在结果里也还是空位。
for 循环里修改数组和 forEach 有什么不同?
普通 for 循环的条件 i < arr.length 每轮都会重新读 length,所以循环里 push 进去的元素会被访问到,写得不好还会变成死循环。forEach 只在开始时读一次长度,不会有这个问题。
回调里修改已经访问过的元素会怎样?
值会被改掉,但回调不会为这个位置重新执行。如果后面的逻辑依赖前面处理过的结果,这类修改很容易让数据和处理记录对不上。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。