DOMツリーの内部表現とノード操作のコスト
なぜそのDOM操作が遅いのかを内部構造から判断でき、再描画とリスト走査のムダを根本から削れる。ノードの連結リスト表現、live/static、挿入コスト、DocumentFragmentまで解説。
- 1.DOMは全要素が共通の Node を継承する木構造で、各ノードは parentNode・childNodes・firstChild・nextSibling などの双方向ポインタで連結された連結リストとして保持される。配列ではないため添字アクセスは O(n) になる。
- 2.querySelectorAll は static NodeList(その瞬間のスナップショット)を返し、getElementsByTagName / childNodes は live なコレクションでツリー変更が即反映される。live を for ループ内で書き換えると添字がずれて無限ループや取りこぼしが起きる。
- 3.appendChild / insertBefore はノード1個ごとに接続・スタイル無効化・リフロー候補化を伴う。N回のループ挿入は DocumentFragment に組み立ててから1回挿入すれば、ツリーへの接続を1回に集約できコストが激減する。
DOMは「木」だが、その実装は「連結リスト」
DOM はブラウザが HTML をメモリ上に展開した木構造です。基礎は DOM で扱っていますが、本稿はその一段下――ノードがメモリ上でどう表現され、なぜ特定の操作が重いのか――を内部から解きほぐします。
最初に外せない事実が1つあります。childNodes は配列に見えますが、DOM ツリーは内部的には兄弟どうしをポインタでつないだ連結リストとして保持されています。各ノードは少なくとも次のリンクを持ちます。
parentNode … 親へのポインタ
firstChild … 最初の子へのポインタ
lastChild … 最後の子へのポインタ
previousSibling … 1つ前の兄弟へのポインタ
nextSibling … 1つ後の兄弟へのポインタ
子の集合は「先頭から nextSibling を辿る」ことで列挙されます。つまり parent.childNodes[k] のような添字アクセスは内部的に先頭から k 個たどる O(n) の走査であり、配列のような O(1) ではありません。ループで childNodes[i] を回すと、各反復で先頭から数え直す実装では全体が O(n^2) に化けることがあります。
ノード・要素・属性・テキストの階層
DOM の型は1本の継承木にまとまっています。すべてのノードは抽象基底 Node を継承し、用途別に枝分かれします。この階層を知っていると、「なぜ textContent はあるのに children がないノードがあるのか」が一目で分かります。
| インターフェース | nodeType | 役割 | 代表例 |
|---|---|---|---|
| Element | 1 | タグ要素。属性と子を持つ | div, p, span |
| Attr | 2 | 属性。今は Element のプロパティ扱いで子ではない | class="x" |
| Text | 3 | 要素内のテキスト断片 | 「こんにちは」 |
| Comment | 8 | コメントノード | コメント |
| Document | 9 | ツリーの根 | document |
| DocumentFragment | 11 | 親を持たない軽量な部分木の入れ物 | — |
重要なのは属性(Attr)が子ノードではない点です。<div class="x" id="y"> の class や id は childNodes には現れず、element.attributes(live な NamedNodeMap)や getAttribute() 経由でアクセスします。一方テキストは独立した Text ノードとして子に並びます。だから <p>あ<b>い</b>う</p> の p は「Text(あ)・Element(b)・Text(う)」の3つの子を持ちます。
childNodes は Text や Comment を含む全ノード(NodeList)、children は Element だけ(HTMLCollection)を返します。HTML を整形して改行・インデントを入れると、その空白が Text ノードになり childNodes に紛れ込みます。「firstChild が要素のはずなのに空白テキストだった」という事故の正体はこれで、要素だけ欲しいときは firstElementChild を使います。
live コレクションと static NodeList
DOM のコレクション取得 API には、**取得後のツリー変更が反映されるもの(live)と、しないもの(static)**の2系統があります。この違いを取り違えると、ループ中の挙動が破綻します。
| API | 返す型 | live / static | ツリー変更の反映 |
|---|---|---|---|
| querySelectorAll() | NodeList | static | されない(スナップショット) |
| getElementsByTagName() | HTMLCollection | live | 即時に反映される |
| getElementsByClassName() | HTMLCollection | live | 即時に反映される |
| childNodes | NodeList | live | 即時に反映される |
| element.attributes | NamedNodeMap | live | 即時に反映される |
live コレクションは「結果を配列に固めて返す」のではなく、問い合わせ条件を保持したビューです。アクセスのたびに現在のツリーを評価して結果を作るため、ノードを足せばその場で length が増えます。これが次のような典型バグを生みます。
// 危険:getElementsByTagName は live。append で要素が増えると length も増え続ける
const items = document.getElementsByTagName('div');
for (let i = 0; i < items.length; i++) {
const clone = items[i].cloneNode(true);
document.body.appendChild(clone); // items が伸び、ループが止まらない
}
// 安全:querySelectorAll は static。その瞬間の集合で固定される
const snapshot = document.querySelectorAll('div');
for (const el of snapshot) {
document.body.appendChild(el.cloneNode(true)); // 元の集合だけを処理
}
live コレクションを for (let i=0; i<col.length; i++) で回しながら col[i].remove() すると、削除のたびに後続が前へ詰まり、1つおきに取りこぼします。live を順次削除するなら後ろから(for (let i=col.length-1; i>=0; i--))回すか、先に [...col] で static 配列へコピーしてから処理します。「全部消したはずが半分残る」の原因はほぼこれです。
querySelectorAll は CSS セレクタを評価して全ツリーを走査し、結果を一度だけ作って固定します。getElementsByClassName は単純なクラス一致に特化し、しばしば内部キャッシュを持つため繰り返し参照する用途で軽いことがあります。ただし live ゆえに DOM を変更しながら参照すると再評価が走り得ます。「1回取って回す」なら static、「変化を追い続けたい」なら live、と用途で選ぶのが本筋です。
appendChild と insertBefore のコスト
ノードをツリーへ挿入する操作は、ポインタの付け替えだけでは終わりません。appendChild(node) は内部でおおよそ次を行います。
nodeが既にどこかの子なら、まず元の位置から取り外す(DOM ノードは木の中で同時に2か所に存在できない)。- 親の子リストの末尾へ連結し、
parentNodeなどのポインタを張り替える。 - その部分木をスタイル無効化としてマークし、レイアウトとペイントの再計算候補に載せる。
- 接続状態が変わるため、関連する
MutationObserverの記録やconnectedCallback(カスタム要素)の起動対象になる。
ポインタ操作自体は O(1) ですが、3 のスタイル・レイアウト無効化が本当のコストです。可視ツリーへの1回の挿入は、その要素と影響範囲のジオメトリ再計算(リフロー)を招き得ます。どの変更がどれだけ重いかの分類は リフローとリペイントのコスト で詳説しています。
insertBefore(newNode, referenceNode) も計算量は同じ O(1) です。「途中挿入だから配列のように後続をずらす O(n) が要る」と誤解されがちですが、連結リストなので前後2本のポインタを張り替えるだけです。コストの本体は位置ではなく、やはり挿入に伴う無効化のほうにあります。
N 個のノードを for で1個ずつ body.appendChild すると、各挿入がそれぞれ独立にスタイル無効化を起こすため、再計算がN回に膨らみがちです。実測ではブラウザがフレーム末まで再計算をまとめる最適化も効きますが、途中で offsetTop などジオメトリを読むと強制同期レイアウトが割り込み、最悪 O(N^2) 的に悪化します。挿入は「まとめて1回」が鉄則です。
DocumentFragment が効く理由
DocumentFragment は、親を持たない、画面に出ない軽量な入れ物です(nodeType は 11)。ここに子を組み立てているあいだはメインの文書ツリーに接続されていないため、スタイル無効化もリフローも発生しません。そして fragment を appendChild すると、特別なルールが働きます。
// 良い:1000個を fragment 上で組み立て、可視ツリーへの接続は最後の1回だけ
const frag = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = '項目 ' + i;
frag.appendChild(li); // ここでは文書外。リフローは起きない
}
list.appendChild(frag); // フラグメントの“中身”だけが移動。接続は1回に集約
ポイントは、appendChild(fragment) がフラグメントそのものではなく、その子全部を移動させ、移動後の fragment は空になる、という仕様です。結果として文書ツリーへの接続イベントは1回にまとまり、スタイル無効化とリフローの起点も1回で済みます。
| 方式 | ツリーへの接続回数 | リフロー誘発 | 中間の可視状態 |
|---|---|---|---|
| ループで直接 appendChild | N 回 | 毎回の候補化(最悪 N 回) | 途中経過が画面に出る |
| DocumentFragment 経由 | 1 回 | 1 回に集約 | 完成形まで非表示 |
| innerHTML へ文字列連結 | 1 回(再パース) | 1 回 | 完成形まで非表示 |
innerHTML = '...' も接続を1回にまとめられますが、文字列を HTML パーサで再解釈し、既存の子を一度すべて破棄します。これにより既存ノードに付けたイベントリスナや参照が失われ、ユーザー入力中の状態も飛びます。対して DocumentFragment はノードオブジェクトをそのまま保持したまま移動するので、リスナや状態を維持できます。大量の静的 HTML を一括生成するなら innerHTML、ノードの同一性やリスナを保ちたいなら fragment、が基本指針です。
テキストとノード同一性の落とし穴
ノードは木の中で一意の実体であり、同じノードを2か所に置くことはできません。a.appendChild(node) の時点で node が別の親に属していれば、その親から自動的に外れて移動します。これは「コピーされる」のではなく「引っ越す」挙動で、cloneNode(true) を使わない限り元の場所からは消えます。
テキストの読み書きにも段差があります。textContent は子孫の Text ノードの中身を連結して返し、書き込むと子をすべて1つの Text に置き換えます(パースしない・安全)。innerText は CSS のレンダリング結果に依存し、非表示要素を除外したうえ読み取りでレイアウトを誘発し得ます。innerHTML は HTML として解釈するため、信頼できない文字列を入れると XSS になります。
押さえるべきは5点です。❶ DOM の子は配列でなく連結リストで、添字アクセスは O(n)。❷ querySelectorAll は static、getElementsByTagName / ClassName と childNodes は live。❸ live を書き換えながら正方向ループすると無限ループ・取りこぼしが起きる。❹ insertBefore も O(1)、重さは位置でなく無効化。❺ 大量挿入は DocumentFragment で接続を1回に集約する。
まとめ
DOM ツリーは内部的に双方向ポインタの連結リストで、childNodes[k] の添字アクセスは O(n) 走査になります。ノードは Node を頂点とする継承木で、属性は子ではなくテキストは独立した Text ノードです。コレクションには static(querySelectorAll)と live(getElementsByTagName / ClassName・childNodes)があり、live をループ中に書き換えると添字がずれて壊れます。appendChild / insertBefore の計算量はどちらも O(1) で、本当のコストはスタイル無効化とリフローにあるため、大量挿入は DocumentFragment に組み立てて接続を1回へ集約するのが定石です。再計算コストの分類は リフローとリペイントのコスト、ツリー上を流れるイベントの伝播は イベントの伝播モデル と合わせて押さえると、DOM 操作の良し悪しを内部から判断できるようになります。
Web/フロントエンド Article
DOMツリーの内部表現とノード操作のコストを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
DOM
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
querySelectorAll は static NodeList(その瞬間のスナップショット)を返し、getElementsByTagName / childNodes は live なコレクションでツリー変更が即反映される。live を for ループ内で書き換えると添字がずれて無限ループや取りこぼしが起きる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「DOM / JavaScript」に近いか確認する。
- 強みである「DOMは全要素が共通の Node を継承する木構造で、各ノードは parentNode・childNodes・firstChild・nextSibling などの双方向ポインタで連結された連結リストとして保持される。配列ではないため添字アクセスは O(n) になる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。