【JavaScript】DOM操作のパフォーマンスに関する注意点

javascript-icon JavaScript

こんにちは、かつコーチです。

前回は、HTML要素に独自データを持たせるdata属性の活用方法を解説しました。

今回はDOM操作基礎シリーズの締めくくりとして、パフォーマンスに関する注意点を扱います。

「動くには動くけど、なんだか画面がカクつく」というコードの多くは、DOM操作のやり方に原因があります。

仕組みを理解しておくと、無駄な処理を書かずに済むようになります。

なぜDOM操作は重いのか

レンダリングとリフローの仕組み

ブラウザは、HTML・CSS・JavaScriptから画面を描画するまでに、いくつかの内部処理を行っています。

その中でも特に重い処理が、リフロー(レイアウトの再計算)リペイント(見た目の再描画)です。

DOM要素のサイズや位置に影響する変更(要素の追加・削除、幅や高さの変更など)を行うと、ブラウザはページ全体、あるいは一部の要素の配置を計算し直す必要があります。

この再計算がリフローで、その後に画面へ実際に描き直す処理がリペイントです。

DOM操作の回数がボトルネックになる理由

DOM(Document Object Model)とは、HTMLの構造をブラウザが扱いやすい形に変換したデータのことです。

JavaScriptから document.querySelector などで要素を取得したり書き換えたりする操作は、すべてこのDOMに対して行われています。

このDOM操作自体は、JavaScript側の単純な変数操作に比べて何十倍、何百倍も時間がかかります。

1回1回の処理は数ミリ秒でも、ループの中で何百回、何千回とDOM操作を繰り返すと、合計の処理時間が無視できないレベルまで積み上がってしまいます。

つまずきやすいポイント:ループ内でのDOM操作

かつコーチが実際につまずいた話

以前、100件のデータをリストに表示する処理を書いていて、思った以上に画面の表示が遅いことに気づきました。

const list = document.querySelector("#itemList");
const items = ["商品A", "商品B", "商品C" /* ...合計100件 */];

items.forEach((item) => {
  const li = document.createElement("li");
  li.textContent = item;
  list.appendChild(li);
});

見た目には特に問題のないコードに思えましたが、Chromeの開発者ツールでパフォーマンスを計測してみると、appendChild の呼び出しごとにリフローが発生していることが分かりました。

つまりこのコードは、100件のデータを表示するために、リフローを100回も引き起こしていたことになります。

❌ Before:ループの中で直接DOMに追加する

const list = document.querySelector("#itemList");
const items = ["商品A", "商品B", "商品C"]; // 実際は100件

items.forEach((item) => {
  const li = document.createElement("li");
  li.textContent = item;
  list.appendChild(li); // ループのたびにDOMへ追加 → その都度リフローが発生
});

✅ After:DocumentFragmentにまとめてから一括で追加する

const list = document.querySelector("#itemList");
const items = ["商品A", "商品B", "商品C"]; // 実際は100件

const fragment = document.createDocumentFragment();

items.forEach((item) => {
  const li = document.createElement("li");
  li.textContent = item;
  fragment.appendChild(li); // ここではDOMに追加していないのでリフローが起きない
});

list.appendChild(fragment); // 最後に1回だけ実際のDOMへ追加

DocumentFragmentは、実際の画面には表示されない「仮のコンテナ」です。

このフラグメントの中に要素を追加している間はリフローが発生せず、最後に list.appendChild(fragment) で本物のDOMへまとめて追加したときに、リフローが1回だけ発生します。

このコードに書き換えてから計測し直したところ、表示にかかる時間が体感できるレベルで短縮されました。

「ループの中でDOM操作を繰り返さない」という意識を持つだけで、パフォーマンスが大きく変わることを実感した出来事でした。

その他のパフォーマンス改善パターン

classListでスタイルをまとめて切り替える

要素のスタイルを1つずつ style.〇〇 で変更するより、あらかじめ用意したCSSクラスを classList で切り替える方が効率的です。

const box = document.querySelector("#box");

// スタイルを個別に変更すると、その都度スタイル計算が走りやすい
box.style.width = "200px";
box.style.height = "200px";
box.style.backgroundColor = "skyblue";
.highlighted {
  width: 200px;
  height: 200px;
  background-color: skyblue;
}
const box = document.querySelector("#box");

// クラスの切り替えなら1回の操作でまとめて反映できる
box.classList.add("highlighted");

CSS側にスタイルをまとめておき、JavaScript側ではクラス名の付け外しだけを行う形にすると、DOM操作の回数自体を減らせます。

getBoundingClientRectを不用意にループで呼ばない

要素のサイズや位置を取得する getBoundingClientRect も、呼び出すたびに最新のレイアウト情報を計算するため、リフローを引き起こしやすいメソッドです。

const boxes = document.querySelectorAll(".box");

// NG: 読み取りと書き込みを交互に行うと、リフローが繰り返し発生しやすい
boxes.forEach((box) => {
  const width = box.getBoundingClientRect().width;
  box.style.width = `${width + 10}px`;
});

「値を読み取る処理」と「値を書き込む処理」をループの中で交互に行うと、ブラウザはそのたびに最新のレイアウトを計算し直そうとします。

読み取り処理をまとめて先に行い、書き込み処理は後でまとめて行うようにすると、リフローの発生回数を抑えられます。

const boxes = document.querySelectorAll(".box");

// OK: 読み取りをまとめてから、書き込みをまとめて行う
const widths = Array.from(boxes).map((box) => box.getBoundingClientRect().width);

boxes.forEach((box, index) => {
  box.style.width = `${widths[index] + 10}px`;
});

応用・一歩先の使い方

頻繁に変わる値はrequestAnimationFrameと組み合わせる

スクロール位置の監視やアニメーションのように、短時間に何度もDOM操作が発生する処理では、requestAnimationFrame を使ってブラウザの描画タイミングに合わせるという方法もあります。

let latestScrollY = 0;
let ticking = false;

window.addEventListener("scroll", () => {
  latestScrollY = window.scrollY;

  if (!ticking) {
    requestAnimationFrame(() => {
      document.querySelector("#header").style.opacity = latestScrollY > 100 ? "0.8" : "1";
      ticking = false;
    });
    ticking = true;
  }
});

scroll イベントは発生頻度が非常に高いため、そのたびにDOM操作を行うと処理が追いつかなくなります。

requestAnimationFrame を使うことで、実際の画面更新のタイミングに合わせて処理をまとめられるので、無駄な再描画を減らせます。

DOM操作の知識を組み合わせる先へ

ここまで addEventListener、イベントの伝播、フォームの値取得、data属性、そしてパフォーマンスと、DOM操作の基礎を一通り解説してきました。

これらは単体でも便利ですが、本当に力を発揮するのは組み合わせたときです。

イベント委譲でクリックを検知し、data属性で対象のデータを取り出し、classListでスタイルを切り替える。

こうした知識を組み合わせることで、実際に画面上で動く一つのUIパーツが作れるようになります。

まとめ

この記事のポイント

  • DOM操作はJavaScriptの通常の処理より重く、リフローとリペイントを引き起こす
  • ループの中で繰り返しDOMへ追加するのではなく、DocumentFragmentにまとめて一括追加する
  • スタイル変更は style.〇〇 の個別指定より、classList によるクラス切り替えが効率的
  • レイアウト情報の読み取りと書き込みをループ内で交互に行わず、まとめて処理する
  • 高頻度なイベント処理には requestAnimationFrame を組み合わせる

次に読むべき記事

ここまでのDOM操作の知識を組み合わせると、実際のUIが作れるようになります。

次回からは、その第一歩として実践的なUI実装に取り組んでいきましょう。

→ 次の記事:ハンバーガーメニューの開閉を実装する

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