Live NodeListとStatic NodeListの違いを調べた。一次情報として、MDNのNodeListのページには、取得時以降にDOMツリー内の変更がコレクションの内容に反映されるかされないかである、という説明があった。しかし、個人的な経験則に反する気がして落ち着かない。そういえば、NodeListHTML Collectionとの違いも曖昧である。この機会に調べてみることにした。

Live NodeListとStatic NodeListの違い

まず、把握しているLive NodeListとStatic NodeListの違いを整理する。

項目/タイプ Live Static
取得方法 document.getElementsByTagName()
Node.childNodes
document.querySelectorAll()
取得時以降の
DOM操作の影響
受ける 受けない

保持するプロパティやメソッドは、Live NodeListもStatic NodeListも変わらない。

以上である。整理するほどの情報でもなかった。

気になるのは、Static NodeListは、DOM操作の影響を受けないという点である。ブラウザベンダがそう言っている以上、疑問を挟む余地はない。ただ、その範囲がよくわからない。経験上、Static NodeListであっても、各ノードの子要素にはDOM操作による変更が反映されていたように思う(私のドドメ色の脳細胞が幻覚を見せていない限りは)。

どこからどこまでがStaticなのか知りたい。

DOM操作してStaticがどうなるか検証

盲滅法にあれこれ試して時間を無駄にした結果、以下のような方法で確認すれば良いと思いついた。

  1. 子ノードを持つ要素を1つ用意する。
  2. その要素を、LiveとStatic、それぞれで取得する。
  3. 要素のinnerHTMLを1文字ずつ消していき、各NodeListの中身を見る。

以下のデモがそれを形にしたものである。

html
<html>

<head>
  <meta charset="UTF-8">
  <style>
    body{
      padding:8px;
    }
    #message {
      height: 24px;
      margin: 16px
    }

    em {
      color:blue;
      font-style: bold;
    }
  </style>
</head>

<body>
  <button id="toggleBtn">実験スタート</button><button id="reset">現在の状態でNodeListを再取得</button>
  <div id="message">
    <p name="dear-jb">この文章はやがて<em>pタグごと</em>消滅する。</p>
  </div>
  <h2>Live NodeList</h2>
  <table class="live">
    <tbody>
      <tr>
        <th>NodeList.length</th>
        <td class="listLength">-</td>
      </tr>
      <tr>
        <th>NodeList[0].innerHTML</th>
        <td class="content">-</td>
      </tr>
      <tr>
        <th>NodeList[0].children.length</th>
        <td class="childrenLength">-</td>
      </tr>
    </tbody>
  </table>
  <h2>Static NodeList</h2>
  <table class="static">
    <tbody>
      <tr>
        <th>NodeList.length</th>
        <td class="listLength">-</td>
      </tr>
      <tr>
        <th>NodeList[0].innerHTML</th>
        <td class="content"></td>
      </tr>
      <tr>
        <th>NodeList[0].children.length</th>
        <td class="childrenLength"></td>
      </tr>
    </tbody>
  </table>

  <script>

    let liveNodeList = null;
    let staticNodeList = null;
    let intervalId = -1;

    const initialPText = document.querySelector('p').innerHTML;
    const resetBtn = document.querySelector('#reset');
    const toggleBtn = document.querySelector('#toggleBtn');

    resetBtn.addEventListener('click', initNodeListsAndStatusView);
    toggleBtn.addEventListener('click', toggleExperiment);

    initNodeListsAndStatusView();

    function initNodeListsAndStatusView() {
      liveNodeList = document.getElementsByName('dear-jb');
      staticNodeList = document.querySelectorAll('p');
      updateListsStatusView();
    }

    function toggleExperiment() {
      if (toggleBtn.classList.contains('on'))
      {
        stopExperiment();
      } else
      {
        startExperiment();
      }
    }

    function startExperiment() {
      toggleBtn.innerHTML = '実験ストップ';
      toggleBtn.classList.add('on');
      intervalId = setInterval(eatCharacters, 1000);
    }

    function stopExperiment() {
      toggleBtn.innerHTML = '実験スタート';
      toggleBtn.classList.remove('on');
      clearInterval(intervalId);
    }

    function eatCharacters() {
      let p = document.querySelector('p');

      if (p === null)
      {
        p = document.createElement('p');
        p.setAttribute('name', "dear-jb");
        p.innerHTML = initialPText;
        document.querySelector('#message').appendChild(p);
      } else
      {
        p.innerHTML = p.innerHTML.slice(4);
        if (p.innerHTML === '') p.remove();
      }

      updateListsStatusView();
    }

    function updateListsStatusView() {
      updateListStatusView(liveNodeList, 'live');
      updateListStatusView(staticNodeList, 'static');
    }

    function updateListStatusView(nodeList, listType) {
      const listLengthTd = document.querySelector(`.${listType} .listLength`);
      listLengthTd.innerHTML = nodeList.length;

      const contentTd = document.querySelector(`.${listType} .content`);
      contentTd.innerHTML = (nodeList.length === 0) ? '🙇 pタグがありませぬ 🙇' : nodeList[0].innerHTML;

      const childrenLengthTd = document.querySelector(`.${listType} .childrenLength`);
      childrenLengthTd.innerHTML = (nodeList.length === 0) ? '🙇 pタグがありませぬ 🙇' : nodeList[0].children.length;
    }

  </script>
</body>

</html>

「この文章はやがてpタグごと消滅する」は<p>タグで、このうちの青字の部分は<span>タグである。

「実験スタート」ボタンを押すと、<p>タグの中身(innerHTML)が1秒ごとに削られていく。文字がすべて削除されるのと同時に、<p>は消滅する。その後、新たに<p>を作り、ドキュメントに追加する。これが上記プログラムの大まかな概要である。

結果、StaticもLiveも、NodeList[0].innerHTML<p>タグの操作を反映して削られていった。また、<p>タグ内の<span>タグが壊れた時点(青字が黒字になる時点)で、両方ともNodeList[0].children.lengthが0になった。

<p>タグが削除されると、Live NodeListは長さが0になるのに対し、Static NodeListは1のままである。<p>タグが新たに生成されると、初期化してもいないのに、Live NodeListには生成した要素(ノード)の内容が反映される。対してStatic NodeListには変化がない。

Liveは定点カメラ映像、Staticはスナップショット

上記の事実から、Live NodeListは、要素のリストというより、DOMツリーの定点カメラのような役割を果たしていることがわかる。まさにLiveである。中身がゼロになっても、NodeListが生成された時点の条件に適う要素がDOMツリーに追加されたら、その参照を取り込むことができる。NodeListとは別に、そういうオブザーバーが生成されるようだ(これはChatGPT-4に聞いた)。

一方、Static NodeListの挙動はスナップショットを思わせる。生成時点で、各ノードをシャローコピーしてリストを作る。各プロパティへの参照は保持されるが、それを保持するのは別のNodeオブジェクトである。コピー元のノードのプロパティが変更されると、その変更はStatic NodeListに反映される。

嘘。

ドヤ顔でChatGPT-4に確認したら思いっきり間違っていた。すごく恥ずかしい。Static NodeListは、Live NodeListと同じく各Elementオブジェクトへの参照を直接保持する。ただ、ドキュメントからその要素が削除された後も、その要素への参照を保持し続ける。

これこそ嘘っぽいなと思ったら真実だった。以下のコードで確かめた。

js
// Static NodeListからpのDOMオブジェクトを取得する
const staticNodeList = document.querySelectorAll("p");
const pInStatic = staticNodeList[0];

// 直接pのDOMオブジェクトを取得する。
const p = document.querySelector("p");

// 2つは同じ参照を持つ
console.log(pInStatic === p); // true
p.remove();

document.body.appendChild(pInStatic);
const who = document.querySelector("p");
console.log(pInStatic === who);

//pInStatic = p、pInStatic = who、ゆえにp = who

上記の通り、Element.remove()しても、static NodeListでNodeオブジェクトを保持していれば復活できる。

Live NodeListと違い、同条件のノードが新たにドキュメントに追加されても、Static NodeListは影響を受けない。同時に、保持しているノードがドキュメントから削除されても、Static NodeListは影響を受けない。一度捕まえた参照を決して逃さず、メモリ上にガチホし続ける。とんでもない握力である。

なぜ2種類のNodeListがあるのか

よくわからない。歴史的な問題、パフォーマンスの問題、ユースケースの問題、いろいろ絡み合っていそうである(詳しい方がいれば教えていただきたい)。

DOMツリーの変化を追う必要がなければ、Static NodeListを使っておけば良い、とだけ覚えておくことにする。

HTMLCollectionは歴史的遺物?

NodeListと似たオブジェクトに、HTMLCollectionがある。しかし、両者は似て非なるものである。わかりやすいところではNodeListforEach()メソッドが使えるが、HTMLCollectionは使えない。

ただ、もともとNodeListにはforEach()メソッドはなかった。ECMAScript 2015 (ES6) で大量の配列メソッドが追加された際にサポートするようになった。名前と機能が同じだけで、Array.forEach()とは別物だが、それならHTMLCollectionforEach()を持たせても良い気がする。

なぜHTMLCollectionは拡張されなかったのか。謎だったが、MDNのHTMLCollectionのページに普通に書いてあった。

This interface was an attempt to create an unmodifiable list and only continues to be supported to not break code that’s already using it. Modern APIs use types that wrap around ECMAScript array types instead, so you can treat them like ECMAScript arrays, and at the same time impose additional semantics on their usage (such as making their items read-only).

引用元:HTMLCollection – Web APIs | MDN

訳:このインターフェイスは、変更不可能なリストを作成するための試みであり、既にそれを使用しているコードを壊さないためにサポートされ続けているだけである。最近のAPIでは、代わりにECMAScriptの配列型をラップする型を使用しているため、ECMAScriptの配列のように扱うことができ、同時にその使用方法に追加のセマンティクスを課すことができます(アイテムを読み取り専用にするなど)。

引用元:DeepL翻訳:高精度な翻訳ツール

最近のAPIというのは、例えばNodeListentries()forEacy()keys()などを指していると思われる。

また、WHATWGのHTML Collectionの項に、以下のような記述を見つけた。

HTMLCollection is a historical artifact we cannot rid the web of. While developers are of course welcome to keep using it, new API standard designers ought not to use it (use sequence<T> in IDL instead).

引用元:DOM StandardのHTMLCollectionインタフェースの項

訳:HTMLCollectionは、ウェブから取り除くことのできない歴史的な遺物である。開発者がこれを使い続けることはもちろん歓迎されるが、新しいAPI標準の設計者はこれを使うべきではない(代わりにIDLでsequence>T<>を使う)。

DeepL翻訳:高精度な翻訳ツール

HTMLCollectionは、積極的に排除されないまでも、どうやらはみだし者である。シンパシーを感じないでもない。

参考資料