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-b
とdata-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"
となります。
なぜすべてに対してループ処理が走らないのか?
NodeList
やHTMLCollection
、DOMTokenList
やNamedNodeMap
も、すべて動的なオブジェクトです。
動的なオブジェクトは、構成する元になった情報が変更された時点で、オブジェクト自体の中身も更新されます。
構成する元になった情報とは、「どの要素の子だったか」「どの要素の属性だったか」「どの条件で探索された要素だったか」などです。
たとえば、ページ内に存在する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);
}