【2025年版】JavaScriptでDOM操作の実践・書き換え・挿入・削除するコツ

JavaScriptを教える猫JavaScript
この記事は約19分で読めます。
本ページはプロモーション(広告)が含まれています

当記事は以前書いた記事を補填する内容になります。

こんにちは!

今日は「JavaScriptでHTMLをいじる」話を書きたいと思います。
古の時代は appendChild()innerHTML でだいたい何とかなったのですが、いまは選択肢がかなり増えています。

とはいえ、どれをいつ使うかは迷いやすいです。
この記事では、一般的なやり方の良し悪しを整理しつつ、実務で役立つテクニックを、具体例つきでまとめます。
結論だけ先に言うと、置き換えは replaceChildren()、挿入は append()before()after()、大量更新は DocumentFragment、文字列→DOMは「安全対策つきで」と覚えるとぐっと楽になります。


よくある一般論への疑問:「とりあえず innerHTML でいい?」に待ったです

「DOMを丸ごと差し替えたいなら innerHTML が手っ取り早い」というアドバイスは根強いです。
確かにコードは短く、初心者にもわかりやすいです。
ただし、XSSのリスクが高いこと、イベント・フォーカスを壊しがちなこと、そして再計算や再描画のコストが読みにくいことが問題になりやすいです。

特に外部やユーザー入力の文字列をそのまま innerHTML に渡すのは、現場では基本的に避けるべきだと考えられています。
安全に使えるのは、ビルド時に確定したテンプレート文字列や、自前で厳格にサニタイズした出力などの限定ケースです。
「速く見えるから」という理由だけで常用するより、意図が明確なAPIを選ぶほうが、コードの保守性も品質も上がりやすいです。


「挿入」は append / prepend / before / after ?

要素を追加するだけなら、モダンなメソッド群が素直で読みやすいです。
append() / prepend()複数ノードや文字列をまとめて追加でき、before() / after()兄弟関係として挿入できます。
古典的な appendChild() も依然使えますが、1個のノードしか追加できないなどの制約があり、意図を伝える力では一歩譲ることが多いです。

短くて意図が伝わるコードはレビューでも強いです。
実際の書き味は以下の通りです。

<ul id="list"></ul>
<script>
  const li = document.createElement('li');
  li.textContent = 'りんご';
  list.append(li, '(旬)');            // 文字列も一緒に追加できます
  list.before(document.createTextNode('在庫一覧:')); // 兄要素の前にテキスト
</script>

主要ブラウザでのサポートも安定しているため、日常使いの第一選択にしやすいです。


「書き換え」は replaceChildren() ?

コンテンツを丸ごと置き換えるなら element.replaceChildren(...) が最短で安全です。
引数にノードでも文字列でも渡せて、既存の子は一括で廃棄されます。
空で呼べばクリアもできます。innerHTML よりも意図が明確で、誤って「HTMLとして解釈させる」事故を避けやすいです。

container.replaceChildren(
  Object.assign(document.createElement('h2'), { textContent: 'お知らせ' }),
  Object.assign(document.createElement('p'),  { textContent: '発送が遅れています' })
);

// 全消し
container.replaceChildren();

丸ごと更新の第一選択として覚えておくと、整理整頓されたコードになりやすいです。


「削除」は remove() ?

要素削除は node.remove() を素直に使うのがいまどきです。
親からの removeChild() も動きますが、親ノードをわざわざ握らなくていい remove() のほうが読みやすく、ミスも減ります。

document.querySelectorAll('.tmp').forEach(el => el.remove());

「書き換え時に全部消したい」なら、先ほどの replaceChildren()(空呼び)でOKです。
削除の手段を1〜2個に絞ってチームで統一すると、レビューもトラブルシュートも楽になります。


大量追加は DocumentFragment と <template> でまとめ?

何百件もの項目を1個ずつDOMに挿すと、レイアウト計算が何度も走って重くなります。
DocumentFragment にまとめて組み立て、最後に1回だけ挿入すると、高速で安定します。
再利用可能なマークアップがあるなら <template> と組み合わせると、HTMLを画面に出さずに保管しておけて便利です。

<template id="row">
  <li class="row"><span class="name"></span> <span class="price"></span></li>
</template>

<ul id="list"></ul>

<script>
  const frag = new DocumentFragment();
  const tmpl = document.getElementById('row');

  for (const item of data) {
    const node = tmpl.content.firstElementChild.cloneNode(true);
    node.querySelector('.name').textContent  = item.name;
    node.querySelector('.price').textContent = item.price.toLocaleString();
    frag.append(node);
  }

  list.append(frag); // ここで1回だけDOMに反映されます
</script>

Fragmentは「仮のDOM」なので、ここでの操作は描画を起こしません。
<template>
非表示のHTML保管庫としてシンプルに使えます。


文字列→DOMにするときの安全策:innerHTML は最終手段?

外部入力を insertAdjacentHTMLinnerHTML に流し込むのは典型的なDOM XSSの入口です。
どうしても文字列をHTMLとして差し込みたい場合は、まずサニタイズしてください。
実務では DOMPurify のような実績あるライブラリが手堅い選択です。

さらに Trusted Types(CSPと組み合わせてDOM XSSを抑止する仕組み)を導入すると、安全でないHTMLの挿入をビルド時・実行時に抑止しやすくなります。
ただし、導入時は既存コードの洗い出しと互換性の確認が必要です。
ブラウザ組み込みの Sanitizer API は登場しましたが、プロダクションでの採用は段階的に進めるのが無難です。

// 例:Trusted Types + DOMPurify の方針
const policy = window.trustedTypes?.createPolicy('default', {
  createHTML: (dirty) => DOMPurify.sanitize(dirty, { RETURN_TRUSTED_TYPE: true })
});

const safeHTML = policy
  ? policy.createHTML(userInput)
  : DOMPurify.sanitize(userInput);

container.innerHTML = safeHTML; // TrustedHTML かサニタイズ済み文字列

要点は「HTMLとして解釈させる前に安全性を確保」することです。
これはチームの規約として明文化しておくと安心です。


Range / createContextualFragment は「細かく差し込みたい」場面で便利?

要素の特定位置にHTML片を差し込みたい、でも innerHTML で全部作り直すのは避けたい、というときは Range.createContextualFragment() が便利です。
このAPIはコンテキストに応じて文字列をパースし、DocumentFragment として返すため、仕上げの挿入は insertBefore() などでコントロールしやすいです。

XSSの観点ではこのAPIもサニタイズを自前で考える必要があります。
安全策をセットで覚えておくと、細かい差し込みの要件に柔軟に対応できます。


レイアウトスラッシングを避ける:読んで→書いて→読んで…の順番の見直しです

DOMを更新する際に、計測プロパティ(offsetWidth など)を読む→スタイルを書く→また読むを短時間で繰り返すと、強制レイアウト(Forced Synchronous Layout)が発生し、カクつきの原因になります。
「読むフェーズ」と「書くフェーズ」を分ける、大量更新はFragmentで一括
、場合によってはrequestAnimationFrame でフレーム境界に寄せるなどの基本で体感は大きく変わります。

さらに、CSSの content-visibility描画対象を限定するのも有効です。
Core Web Vitals(特にINP)改善にも効きます。
細かい計測にはPerformanceパネルやLong Tasksの可視化を活用すると効果的です。


要素の「移動」はコピーではなく再親化?

append()insertBefore()既存要素を別の場所へ移動すると、新規作成ではなく同じノードの再親化が起きます。
つまり イベントリスナーや datasetid などの状態はそのままです。
この振る舞いは便利な一方で、「古い場所にあるはず」という仮定が残っているコードがバグの原因になることがあります。

移動の設計では参照の持ち方(都度 querySelector するのか、変数で保持するのか)を決めておくと事故が減ります。
テストでは「移動後に機能しているか」も必ず確認すると安心です。


基本:動的更新はフォーカスと通知(aria-live)をセット?

DOMを動かすとキーボードフォーカスが迷子になりがちです。
モーダルやトースト、無限スクロールの挿入などでは、「どこにフォーカスを置くのが自然か」を決め、必要に応じて focus() で明示的に移動させます。

画面の別場所が更新されたときは aria-live を使ってスクリーンリーダーへ通知します。
たとえば「検索結果が20件見つかりました」のようなメッセージは role="status"aria-live="polite" で伝えると親切です。
やり過ぎると騒がしくなるので、重要な更新だけに絞るのがコツです。


実務スニペット:安全・高速・アクセシブルな「行の追加・削除」

以下は、テンプレート + Fragment + イベント委任 + aria-live を組み合わせたミニ実装例です。
ユーザー入力はDOMPurifyでサニタイズし、replaceChildren() で高速リセット、aria-live で追加結果を通知します。

<main>
  <h1>買い物リスト</h1>

  <form id="form" aria-describedby="help">
    <input id="name" autocomplete="off" placeholder="品目を入力" />
    <button type="submit">追加</button>
    <p id="help">Enterで追加できます</p>
  </form>

  <p id="announce" role="status" aria-live="polite" class="sr-only"></p>

  <template id="row">
    <li class="row">
      <span class="name"></span>
      <button type="button" class="del">削除</button>
    </li>
  </template>

  <ul id="list"></ul>
  <button id="clear" type="button">全部消す</button>
</main>

<script type="module">
  import DOMPurify from 'https://cdn.skypack.dev/dompurify';

  const form = document.getElementById('form');
  const name = document.getElementById('name');
  const list = document.getElementById('list');
  const tmpl = document.getElementById('row');
  const announce = document.getElementById('announce');

  form.addEventListener('submit', (e) => {
    e.preventDefault();
    const raw = name.value.trim();
    if (!raw) return;

    // ユーザー入力は必ずサニタイズ(Trusted Types併用も検討)
    const safe = DOMPurify.sanitize(raw, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });

    const frag = new DocumentFragment();
    const li = tmpl.content.firstElementChild.cloneNode(true);
    li.querySelector('.name').textContent = safe;
    frag.append(li);
    list.append(frag);

    announce.textContent = `「${safe}」を追加しました`;
    name.value = '';
    name.focus();
  });

  // イベント委任で削除
  list.addEventListener('click', (e) => {
    const btn = e.target.closest('.del');
    if (!btn) return;
    const item = btn.closest('.row');
    const label = item.querySelector('.name')?.textContent ?? '';
    item.remove();
    announce.textContent = `「${label}」を削除しました`;
  });

  // 全消しは replaceChildren() が最短
  document.getElementById('clear').addEventListener('click', () => {
    list.replaceChildren();
    announce.textContent = 'リストを空にしました';
  });
</script>

この例の肝は、DOMを触る回数を減らす(Fragment)全消しは一撃(replaceChildren)入力はサニタイズ更新は通知という4点です。
小さなアプリでも、ここを押さえるだけでトラブルが目に見えて減ります。


まとめ:選択肢が増えた今こそ「意図が伝わる方法」を選びます

最後に、用途別の超簡易チートシートを置いておきます。

  • 1個だけ末尾に足すappend(node)/古い癖なら appendChild(node)
  • 先頭に足すprepend(...)
  • 兄弟として前後に足すbefore(...)after(...)
  • 全部差し替えreplaceChildren(...)
  • 大量生成DocumentFragment + <template>
  • 文字列→HTMLサニタイズ必須(DOMPurify、Trusted Types)。
  • 性能が気になる読む→書くの順序Fragmentで一括強制レイアウト回避
  • ユーザーへの配慮:フォーカスを迷子にしない、重要な更新は aria-live で伝える。

というわけで、DOM操作は「できる」から「伝わる・速い・安全」にアップグレードしていく時代だと考えています。
無理なく取り入れられるところから、今日のコードに混ぜてみてください。
きっと明日のトラブルが一つ減るはずです。


参考リンク(一次情報・ドキュメント中心)

広告

自宅で現役エンジニアから学べる TechAcademy

1人ではプログラミング学習が続かない方のための、パーソナルメンターがつく「オンラインブートキャンプ」です。
オンラインブートキャンプの特徴は下記の3つがあります。

1.スクールに通わなくても、自宅などでオンライン学習できる
2.わからないことはいつでもチャットでメンターに質問できる
3.パーソナルメンターがついてオリジナルサービスやオリジナルアプリの公開までサポート

自分のオリジナルサービスやアプリを開発しながらプログラミングを実践的に学んでいただき、
わからないことはいつでも現役エンジニアのメンターに相談することができます。

詳細はこちらをご覧ください。
https://px.a8.net/svt/ejp?a8mat=45BXMR+GHL6WI+3GWO+5YZ77

タイトルとURLをコピーしました