for...of文がずれる！？NodeListやHTMLCollectionなどの動的オブジェクトを使うときに知っておかなければならないこと

## TL;DR

- `HTMLCollection`などの動的なイテラブルオブジェクトを回すときには`Array`に変換するべし
- Arrayに変換するにはスプレッド構文と使うか`Array.from()`を使うべし

## `for...of`文を動かしてみる

### 単純なループ

たとえば、次のような構造の`div`要素を作って、その子要素または子ノードの数だけループさせて処理がしたいような状況で次のコードをを実行します。

```html
<div>
  <dummy-a></dummy-a>
  <dummy-b></dummy-b>
  <dummy-c></dummy-c>
  <dummy-d></dummy-d>
  <dummy-e></dummy-e>
</div>
```

```javascript
const div = (() => {
  const node = document.createElement('div');

  node.innerHTML = `
      <dummy-a></dummy-a>
      <dummy-b></dummy-b>
      <dummy-c></dummy-c>
      <dummy-d></dummy-d>
      <dummy-e></dummy-e>
  `;

  return node;
})();
const iterable = div.children; // HTMLCollection

for (const elm of iterable) {
  console.log(0);
}
```

「0」は5回出力されます。


```javascript
const div = (() => {
  const node = document.createElement('div');

  node.innerHTML = `
      <dummy-a></dummy-a>
      <dummy-b></dummy-b>
      <dummy-c></dummy-c>
      <dummy-d></dummy-d>
      <dummy-e></dummy-e>
  `;

  return node;
})();
const iterable = div.childNodes; // NodeList

for (const node of iterable) {
  console.log(0);
}
```

「0」はテキストノード（改行）を含めて11回出力されます。

### 子要素や子ノードをすべて削除しようとしてみる

```javascript
const div = (() => {
  const node = document.createElement('div');

  node.innerHTML = `
      <dummy-a></dummy-a>
      <dummy-b></dummy-b>
      <dummy-c></dummy-c>
      <dummy-d></dummy-d>
      <dummy-e></dummy-e>
  `;

  return node;
})();
const iterable = div.children; // HTMLCollection

for (const elm of iterable) {
  elm.remove(); // 要素を削除
  console.log(0);
}
```

「0」は3回しか出力されず、`dummy-b`要素と`dummy-d`要素が子に残った状態になります。


```javascript
const div = (() => {
  const node = document.createElement('div');

  node.innerHTML = `
      <dummy-a></dummy-a>
      <dummy-b></dummy-b>
      <dummy-c></dummy-c>
      <dummy-d></dummy-d>
      <dummy-e></dummy-e>
  `;

  return node;
})();
const iterable = div.childNodes; // NodeList

for (const node of iterable) {
  node.remove(); // ノードを削除
  console.log(0);
}
```

テキストノードを含めて本来は11回ループするはずが、「0」は6回しか出力されずテキストノードだけが削除されて`dummy-*`要素が5つとも残った状態になります。

いずれも`div`要素の中身が空になることを期待していましたが、そうはなりませんでした。これは、オブジェクトの中身が1ループごとに更新されているために起こる現象です。

これは`iterable`の中に入っている子を他の要素やドキュメントに`append()`などで移動させても同様の現象が起こります。

### 属性を削除しようとしてみる

```javascript
const div = document.createElement('div');

div.setAttribute('data-a', '');
div.setAttribute('data-b', '');
div.setAttribute('data-c', '');
div.setAttribute('data-d', '');
div.setAttribute('data-e', '');

const iterable = div.attributes; // NamedNodeMap

for (const attr of iterable) {
    div.removeAttribute(attr.name); // 属性を削除
    console.log(0);
}
```

`attributes`で参照できる属性情報が詰まった`NamedNodeMap`も同様に、途中で中身を削除してしまうと「0」は3回しか出力されず、`data-b`と`data-d`が残りました。


### クラス名を削除しようとしてみる

```javascript
const div = document.createElement('div');

div.className = 'a b c d e';

const iterable = div.classList; // DOMTokenList

for (const name of iterable) {
    div.classList.remove(name); // クラス名を削除
    console.log(0);
}
```

`classList`で参照できるクラス名の情報が詰まった`DOMTokenList`も同様に、途中でクラス名を削除してしまうとループ回数がずれ、「0」は3回出力で最終的なクラス名は`class="b d"`となります。

## なぜすべてに対してループ処理が走らないのか？

`NodeList`や`HTMLCollection`、`DOMTokenList`や`NamedNodeMap`も、すべて動的なオブジェクトです。

動的なオブジェクトは、**構成する元になった情報**が変更された時点で、オブジェクト自体の中身も更新されます。

構成する元になった情報とは、「どの要素の子だったか」「どの要素の属性だったか」「どの条件で探索された要素だったか」などです。

たとえば、ページ内に存在する`hoge`というクラス名を持つ要素を`getElementsByClassName()`ですべて取得した後に、DOMからその中の要素を1つ削除すると、あらかじめ取得しておいたにもかかわらず`HTMLCollection`の長さは変化します。

```html
<div class="hoge a"></div>
<div class="hoge b"></div>
<div class="hoge c"></div>
<div class="hoge d"></div>
<div class="hoge e"></div>

<script>
const iterable = document.getElementsByClassName('hoge');

console.log(iterable.length); // > 5

document.querySelector('.hoge.b').remove(); // 外から2つめを削除

console.log(iterable.length); // > 4
</script>
```

ここまで登場してきた変数`iterable`はいずれも「特定の誰かの何か」というルールで構成されているオブジェクトですから、ループ内で「誰か」から削除されると「特手の誰かの何か」ではなくなってしまい、動的オブジェクトから削除されます。

1ループ目でDOM上で先頭の要素が削除されたとき、オブジェクトからも先頭の要素が削除されます。この時、オブジェクトの子たちは抜けた穴を埋めるように詰まります。しかしループ時には常に1週目には0番目、2週目には1番目という風に **n 週目には n - 1 番目の子を参照する**という振る舞いをするため、ループの最中でオブジェクトが更新されると、参照するべき子の位置がずれてしまいます。

ちなみにこの特性は`NodeList.prototype.forEach()`を用いていても同様です。

```plaintext
1週目
[a, b, c, d, e] // 1週目、0番目である a を削除。次は1番目を見る。

2週目
[b, c, d, e] // 2週目、1番目である c を削除。次は2番目を見る。

3週目
[b, d, e] // 3週目、2番目である e を削除。次は3番目を見る。

4週目
[b, d] // 4週目、3番目は存在しないのでループを終了。
```

## それでも`for...of`ですべての子をどうにかしてしまいたい

そういう時には、動的オブジェクトを静的オブジェクトに変換してしまえばOKです。
スプレッド構文や`Array.form()`メソッドなどを用いて、単純な配列にしてしまうのが簡単でしょう。

```javascript
[...document.getElementsByClassName('hoge')] // HTMLCollection -> Array
[...div.children] // HTMLCollection -> Array
[...div.childNodes] // NodeList -> Array
[...div.attributes] // NamedNodeMap -> Array
[...div.classList] // DOMTokenList -> Array
```

これだけでオブジェクトはただの`Array`になりますから、構成する元になる情報がどうなろうとも配列の中身が更新されることはありません。

しっかりすべての子を削除したり移動したりできます。

```javascript
const div = (() => {
  const node = document.createElement('div');

  node.innerHTML = `
      <dummy-a></dummy-a>
      <dummy-b></dummy-b>
      <dummy-c></dummy-c>
      <dummy-d></dummy-d>
      <dummy-e></dummy-e>
  `;

  return node;
})();
const iterable = [...div.children]; // Array

for (const elm of iterable) {
  elm.remove(); // 要素を削除
  console.log(0);
}
```

### ただし`querySelectorAll()`は特別

`NodeList`は基本的に動的オブジェクトですが、`querySelectorAll()`が返す`NodeList`は静的オブジェクトなため、`Array`に変換する必要はありません。

```javascript
const div = (() => {
  const node = document.createElement('div');

  node.innerHTML = `
      <dummy-a></dummy-a>
      <dummy-b></dummy-b>
      <dummy-c></dummy-c>
      <dummy-d></dummy-d>
      <dummy-e></dummy-e>
  `;

  return node;
})();
const iterable = div.querySelectorAll('*'); // 静的なNodeList

for (const elm of iterable) {
  elm.remove(); // 要素を削除
  console.log(0);
}
```

## 参考文献

- [NodeList - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/NodeList)
- [HTMLCollection - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/HTMLCollection)