LoginSignup
3
2

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-03-10

TL;DR

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

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

単純なループ

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

<div>
  <dummy-a></dummy-a>
  <dummy-b></dummy-b>
  <dummy-c></dummy-c>
  <dummy-d></dummy-d>
  <dummy-e></dummy-e>
</div>
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回出力されます。

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回出力されます。

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

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要素が子に残った状態になります。

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()などで移動させても同様の現象が起こります。

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

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-bdata-dが残りました。

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

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"となります。

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

NodeListHTMLCollectionDOMTokenListNamedNodeMapも、すべて動的なオブジェクトです。

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

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

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

<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()を用いていても同様です。

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()メソッドなどを用いて、単純な配列にしてしまうのが簡単でしょう。

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

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

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

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に変換する必要はありません。

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

参考文献

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2