◆ 配列は添字がずれるので splice など破壊的メソッドにすると forEach で全部でコールバック関数が実行されるとは限らない
◆ Map は key が変わることはないので途中で削除があってもいい感じ

Array


forEach の途中で要素を削除します

arr 自身とコールバック関数の第三引数の2パターン×spliceとfilterで変数置き換えの2パターンの4パターンです
var arr

arr = [1,2,3,4]
arr.forEach((e,i,a) => {
  console.log(e,i,a)
  a.splice(i,1)
})
console.log("***", arr)

arr = [1,2,3,4]
arr.forEach((e,i) => {
  console.log(e,i,arr)
  arr.splice(i,1)
})
console.log("***", arr)

arr = [1,2,3,4]
arr.forEach((e,i,a) => {
  console.log(e,i,a)
  a = a.filter((_, ii) => i !== ii)
})
console.log("***", arr)

arr = [1,2,3,4]
arr.forEach((e,i) => {
  console.log(e,i,arr)
  arr = arr.filter((_, ii) => i !== ii)
})
console.log("***", arr)
1 0 [1, 2, 3, 4]
3 1 [2, 3, 4]
*** [2, 4]

1 0 [1, 2, 3, 4]
3 1 [2, 3, 4]
*** [2, 4]

1 0 [1, 2, 3, 4]
2 1 [1, 2, 3, 4]
3 2 [1, 2, 3, 4]
4 3 [1, 2, 3, 4]
*** [1, 2, 3, 4]

1 0 [1, 2, 3, 4]
2 1 [2, 3, 4]
3 2 [2, 4]
4 3 [2, 4]
*** [2, 4]

splice の場合は 途中経過も含め同じで 2 回しかループが実行されていません
内部で index が保持されていて i が 1 になっているのに 配列の要素が減っているので 2 は 0 番目の要素なので実行されず 2 回目には 1番目の要素 3 が表示されます
次に i が 2 になったときには配列の要素数が 2 なのでそこで終了という流れです

ところで arr とコールバック関数の引数で 配列の要素数を減らした時に同じ動きをするので concat や slice で作られるコピーではなく arr そのものが コールバック関数に渡されているようです

splice は配列そのものの要素を変更しましたが 新しく配列を作って置き換える filter の場合を見てみると arr とコールバック関数の引数で結果が違います
毎ループの e と i は同じですが ループしている配列の第三引数が違っています

splice のときの挙動から コールバック関数の引数には単純に arr が渡されているのがわかるので あとは普通の関数と同じように考えればいいです

引数 a に値を代入してもその関数内のだけの話です
引数に渡した元の arr まで変わることはないです
参照で渡されているので プロパティを変えると splice のときのように影響しますが a 自体を別のものに置き換えるのは影響しません

次に arr を置き換えた場合は 次のコールバック関数呼び出しの第三引数が変わっています
コールバック関数を呼び出すときに毎回 arr をコピーせずに渡している以上 前のコールバック関数内で書き換えられたら変更されてるはずです

ですが splice みたいに 2 回で終了せずちゃんと 4 回ループしてデータも元の配列です
forEach 関数内では arr が置き換えられても 元の arr が残っているので 元の arr の中を書き換えられる splice でなければ影響はでないです

シンプルに自作で forEach 作っても同じ動きになります

Map

もともと Map で forEach 中に delete したらどうなるのかが気になってついでに配列も調べたので こっちが本題でした
Map では filter みたいな機能がないので delete を直接とコールバック関数の引数の 2 パターンで試してみます
var map = new Map([["a", 200],["b", "zzz"],["c", 2000],["d", true]])
map.forEach((e, i, m) => {
  console.log(e, i, m)
  m.delete(i)
})
console.log("***", map)

var map = new Map([["a", 200],["b", "zzz"],["c", 2000],["d", true]])
map.forEach((e, i) => {
  console.log(e, i, map)
  map.delete(i)
})
console.log("***", map)
200 "a" Map {"a" => 200, "b" => "zzz", "c" => 2000, "d" => true}
zzz b Map {"b" => "zzz", "c" => 2000, "d" => true}
2000 "c" Map {"c" => 2000, "d" => true}
true "d" Map {"d" => true}
*** Map {}

200 "a" Map {"a" => 200, "b" => "zzz", "c" => 2000, "d" => true}
zzz b Map {"b" => "zzz", "c" => 2000, "d" => true}
2000 "c" Map {"c" => 2000, "d" => true}
true "d" Map {"d" => true}
*** Map {}

結果はどちらも同じで ちゃんと全部に forEach できていて コールバック関数の第三引数も徐々に Map の要素数が減っている理想的な動きでした
配列だと 0,1,2,... と順番に添字が並んでいるので splice で削除すると添字が置き換わってしまうことが原因ですが Map だと添字に当たる key は勝手に変わることがないので綺麗な動きになります

ただ 自作 Map を作っている人は注意が必要です
内部で配列保持して forEach していると 削除したはずの要素にもコールバック関数が実行されたり削除の都合でスキップされるデータが出てきたりする場合があります

delete メソッドの中では filter や splice ではなく delete 演算子でその添字を消していくと forEach で実行されないので良いかもしれません

ただ その方法で長期間使っていると set と delete が繰り返されて 実際の要素数 10 くらいなのに 添字は 4294967294 みたいなことになるかもしれません
forEach は length の値まで 1 つずつ確認しているので そんなことになるとかなり時間がかかります
4294967294 回ループ回して in 演算子でプロパティあるかの確認をするようなものです(試してみたら20分くらいたっても終わる気配がないです)
この時点でつかいものになりませんが さらに 4294967295 からは配列の添字扱いされないので forEach では実行されなくなります
なので forEach のあとで filter で delete 済みを除去した配列に置き換えると良いかもです