DOM
DOM ・ どむ
ブラウザがHTMLを解釈して組み立てる文書の木。JavaScriptから読み書きでき、イベントもこの木を伝わる。
✦ 30秒で試す — 航海ボタンを押すと、クリックは画面の一番外から降りてくる星4を押すと、一覧に付けた見張りは?予想してみる概要
DOM(Document Object Model)は、ブラウザが HTML を読み込んだときにメモリ上へ組み立てる、文書の「木構造のオブジェクト表現」です。HTMLはただの文字列ですが、ブラウザはそれを解析し、タグの入れ子をもとに木(ツリー)を作ります。この木の一つひとつの節(ノード)がオブジェクトになっていて、JavaScript から読み取ったり書き換えたりできる — これがDOMです。
「ボタンを押したら文言が変わる」「入力ミスで枠が赤くなる」といった動きのあるWebページは、すべてJavaScriptがDOMを書き換えることで実現されています。document.querySelector で要素を探し、textContent や classList を書き換える — フロントエンド開発の操作は、突き詰めればこの木への読み書きと、木の上を伝わってくるイベントへの反応です。
DOMは「いま表示されている画面の生きたモデル」でもあります。DOMを書き換えればブラウザは画面を描き直し、逆に開発者ツールの Elements パネルで見える要素ツリーはDOMそのものです。HTMLが設計図だとすれば、DOMはその設計図をブラウザが解釈して組み立て、あとから自由に増改築できる建物に当たります。
なぜ生まれたか
初期のWebページは、サーバから受け取った HTML をそのまま表示するだけの静的な文書でした。1995年に JavaScript が登場し「読み込んだ後にページの中身を動かしたい」という需要が生まれると、Netscape Navigator と Internet Explorer はそれぞれ独自の方法でページ要素へアクセスする仕組みを実装します(document.layers と document.all)。同じ表現を作るのにブラウザごとに別々のコードを書かなければならない、いわゆる「DHTML時代」の互換性地獄です。
この混乱を収拾するため、W3Cは1998年に DOM Level 1 として「文書を木構造のオブジェクトとして表し、言語にもブラウザにも依存しない共通のAPIで操作する」標準を定めました。2000年の DOM Level 2 ではイベントの伝わり方(捕獲とバブリング)もここで標準化され、いまは WHATWG の DOM Living Standard として更新が続いています。文書=木=オブジェクトという共通モデルが確立したことで、どのブラウザでも同じコードで文書を操作できるようになり、Ajax、そして SPA へと続く動的なWebの進化の土台になりました。
詳細
HTMLの解析からDOMツリーへ
ブラウザは HTML を上から順に解析(パース)し、開きタグを見つけるたびにノードを作って親子関係をつなぎ、木を育てていきます。タグの入れ子が深さに、タグの並びが兄弟関係にほぼ対応しますが、ソースの見た目どおりの木になるとは限りません。書かれていない <head> や <body>、表の <tbody> はパーサが補い、<p> の中に <div> を書くと <p> はそこで閉じられて兄弟に組み替えられます。タグとタグの間の改行や空白も、空白だけのテキストノードとして木に残ります。DOM は HTML の写しではなく、パーサが仕様に沿って解釈した結果なのです(この寛容な解釈の中身は HTML の記事で試せます)。
ノードの種類
木の根は文書全体を表す document ノードで、その下に要素ノード(タグに対応)、テキストノード(文字列)、コメントノードなどがぶら下がります。要素ノードは id や class などの属性を持ち、parentNode や children(要素だけ)・childNodes(テキストノードも含む)で木構造をたどれます。childNodes を数えると想像より多いのは、先ほどの空白のテキストノードが混ざっているからです。なお、ブラウザは CSS も解析して CSSOM というオブジェクトモデルを作り、DOM の各要素にスタイルを当てて画面を描画します。この描画の仕組み全体はブラウザレンダリングの語彙で扱います。
DOM操作のコストは「書き換えたあと」に来る
document.querySelector / createElement / appendChild / remove といったAPIを使えば、要素の検索・生成・挿入・削除が自由にできます。よく「DOM操作は重い」と言われますが、ノードを書き換えること自体は、ふつうの JavaScript のオブジェクト操作と大差ない速さです。重いのはそのあとで、要素の大きさや位置に影響する変更をすると、ブラウザは次に画面を描く前にスタイルの再計算・レイアウト(リフロー)・塗り直し(リペイント)をやり直さなければなりません。ブラウザはこの後始末をなるべく溜めて、1フレームに1回まとめて行います。
ところが、ループの中で「書き換えては offsetHeight などのレイアウト値を読む」を繰り返すと、読むたびに溜めていたレイアウトをその場で実行させることになります(強制同期レイアウト、連続するとレイアウトスラッシング)。書き換えはまとめて行う、読みを先に済ませる、DocumentFragment で組み立ててから一度に挿入する、といった工夫が古典的な対策です。そして「どこを最小限書き換えるかを手で管理するのが大変」という問題が、後の仮想DOMやリアクティビティという発明の出発点になりました。
イベント伝播 — 捕獲とバブリング
ユーザーの操作はDOMツリーの上を「伝播」します。ボタンをクリックすると、イベントはまず window から document を経て木を下り、ターゲットの要素に到達します(捕獲段階)。ターゲットで登録されたリスナーが呼ばれたあと、こんどは木を上って window まで戻ります(バブリング段階)。addEventListener(type, fn) のリスナーは既定ではバブリング段階で呼ばれ、第3引数に true(または { capture: true })を渡すと捕獲段階で呼ばれます。
リスナーの中では、event.target が実際に押された要素、event.currentTarget がいまリスナーを呼んでいる要素を指します。この2つが違うことを活かすのが「イベント委譲(イベントデリゲーション)」です。リスト項目が1000個あっても各項目にリスナーを付けず、親の ul に1つだけ付けて、バブリングしてきたイベントの target で押された項目を見分けます。リスナーの数が減るだけでなく、後から追加した項目にも自動で効くのが大きな利点です。項目ごとにリスナーを付ける方式では、新しく足した項目に付け忘れると、その項目だけ押しても何も起きません。
下の部品で確かめてください。左の箱は本物の要素で、押すとブラウザが本物の click を配ります。右の木の丸を押すとリスナーが付き(本物の addEventListener)、呼ばれた順番が粒の動きとともに再生されます。「星を追加」で後から項目を足して、ボタンごとのリスナーと ul に付けたリスナーの違いを比べてください。
本物の要素(押すとブラウザが click を配る)
- 呼ばれたリスナーがここに順に並びます
DOM ツリーとリスナー(丸を押すと付く)
左の箱は本物の HTML 要素です。まず 「星2」のボタンを押してください。クリックが木をどう伝わるかを右の粒が再生します。
意図しない祖先のリスナーが反応してしまうときは stopPropagation() で伝播を止めます。止まるのは「ほかのノードへの伝播」で、同じノードに付いたほかのリスナーは呼ばれます(それも止めたいときは stopImmediatePropagation())。ただし、下の階層で伝播を止めると、委譲で受けている祖先や、ページ全体のクリックを見ている分析ツールにも届かなくなるので、乱用は禁物です。リンクの遷移やフォームの送信のような「ブラウザ既定の動作」を止めるのは別物の preventDefault() です。また、focus や mouseenter のようにバブリングしないイベントもあり、委譲したいときはバブリングする版(focusin など)を使います。DOMが木であることは、表示構造だけでなくイベントの流れ方まで規定しているのです。
試問
一覧(ul)の中に星のボタンが3つ並んだ、本物の HTML 要素です。右の木で各ノードの丸を押すと、捕獲(クリックが木を下る途中)とバブリング(上る途中)のリスナーが「なし → あり → stopPropagation(伝播を止める)付き」と切り替わります。いまは各ボタンのリスナーが stopPropagation を呼ぶ状態です。「+ 星を追加」でボタンを後から足せます。
本物の要素(押すとブラウザが click を配る)
- 呼ばれたリスナーがここに順に並びます
DOM ツリーとリスナー(丸を押すと付く)
左の箱は本物の HTML 要素です。まず 「星2」のボタンを押してください。クリックが木をどう伝わるかを右の粒が再生します。
