DOM の選択・生成・更新

DOM API で要素を安全に選び、まとめて更新し、直接操作の責務を小さく保つ方法を学ぶ。

DOM を直接操作する基本は、「対象を選ぶ」「必要なノードを作る」「最小限の変更を反映する」の三つです。小さなウィジェットや、フレームワーク外のブラウザ API との連携では不可欠です。一方、複雑な画面で各所が DOM を勝手に変更すると、表示の正しさを保証しにくくなります。

この記事では API の暗記ではなく、DOM 操作の所有者を明確にし、更新を安全に組み立てる方法を扱います。

要素を選ぶ: 範囲を狭く、失敗を扱う

document.querySelector() は最初に一致した要素、querySelectorAll() は一致する要素群を返します。グローバルな document から毎回探すより、コンポーネントや機能のルート要素を起点にすると、同じ CSS セレクタが別の UI を誤って選ぶ事故を減らせます。

const dialog = document.querySelector('[data-dialog]');
if (!dialog) throw new Error('Dialog root was not found');

const closeButton = dialog.querySelector('[data-dialog-close]');
if (!closeButton) throw new Error('Dialog close button was not found');

querySelector() の返り値は null になり得ます。必ず存在するという設計なら、境界で明示的に失敗させる方が、後の Cannot read properties of null より原因が分かりやすくなります。条件付きで存在する要素なら、null を正常な分岐として扱います。

querySelectorAll() が返す NodeList は、取得時点の結果を持つ静的な集合です。後から追加した要素は自動では含まれません。動的な一覧を扱うなら、操作のたびに検索し直すか、後述するイベント委譲を使います。

textContent を既定にして、HTML 文字列の注入を避ける

ユーザー入力や API の値を表示するときは、原則として textContent を使います。

const status = document.querySelector('[data-status]');
status.textContent = `検索結果: ${count} 件`;

textContent は値をテキストとして扱うため、<script> のような文字列を HTML として解釈しません。一方、innerHTML は文字列を HTML として解析します。信頼できない値を連結して渡すとクロスサイトスクリプティング(XSS)につながるため、表示目的で安易に使ってはいけません。

テンプレート全体を置き換えることが正当な場面もありますが、その場合も値を安全にエスケープする仕組みを使います。多くのフレームワークが JSX やテンプレートで既定のエスケープを行うのは、この境界を安全にするためです。

ノードを組み立ててから追加する

リストの各項目を createElement で作る例です。表示する値は textContent に渡し、クリック時に何をするかはイベントハンドラへ閉じ込めます。

function createTodoItem(todo, onToggle) {
  const item = document.createElement('li');
  const button = document.createElement('button');

  button.type = 'button';
  button.textContent = todo.done ? '未完了に戻す' : '完了にする';
  button.addEventListener('click', () => onToggle(todo.id));

  item.append(todo.title, button);
  return item;
}

複数の要素を作るときは DocumentFragment を一時的な入れ物にできます。

const fragment = document.createDocumentFragment();
for (const todo of todos) {
  fragment.append(createTodoItem(todo, toggleTodo));
}

list.replaceChildren(fragment);

replaceChildren は、この一覧の内容を今回の結果で置き換える意図を表します。ただし無条件の全置換は、入力中の値、フォーカス、子要素に付けた状態を失わせることがあります。更新の粒度は、性能だけでなく UI の継続性で判断します。

class と属性で状態を表す

見た目の切り替えにはインラインスタイルを積み重ねるより、意味のある class や属性を付け外しし、表示規則は CSS に任せる方が変更しやすくなります。

saveButton.disabled = isSaving;
saveButton.classList.toggle('is-loading', isSaving);
status.hidden = !hasMessage;

disabled、hidden、aria-expanded のような属性は見た目だけでなく、ブラウザや支援技術に状態を伝えます。たとえば独自の開閉ボタンなら、開閉する要素の実際の表示と aria-expanded を同じ更新関数で変え、食い違いを作らないことが重要です。

フレームワークと直接操作の境界

React や Vue が管理する要素を、別のコードが直接書き換えると、次の再レンダーで変更が消えたり、フレームワークの想定と DOM がずれたりします。原則は「一つの DOM 範囲には一つの所有者」です。

直接操作が適するのは、次のように DOM が外部 API の入出力になる場面です。

  • 入力欄へのフォーカスやスクロール位置の制御
  • ResizeObserver、IntersectionObserver による観測
  • 地図、グラフ、エディタのような命令的ライブラリの初期化と破棄
  • フレームワークが管理しない小さな独立ウィジェット

これらは ref やライフサイクルフックで一か所に集め、生成と破棄を対にします。データから作れる通常の子要素まで命令的に組み立て始めたら、宣言的なレンダーへ戻せないかを検討する合図です。

まとめ

セレクタは機能のルートから絞り、存在しない場合を意識します。外部の値を表示するときは textContent を既定にし、ノード作成、状態属性、後始末を責務ごとにまとめます。次の記事では、DOM の変更がどのようにレイアウトと描画へつながるかを見ます。

Sources