T-CREATOR

JavaScript パフォーマンス最適化大全:レイアウトスラッシングを潰す実践テク

JavaScript パフォーマンス最適化大全:レイアウトスラッシングを潰す実践テク

Web アプリケーションのパフォーマンス改善に取り組む中で、「コードは正しく動いているのに、なぜかカクカクする」という経験はありませんか?その原因の多くは、レイアウトスラッシング(Layout Thrashing)と呼ばれる現象にあります。

本記事では、レイアウトスラッシングの仕組みを徹底的に理解し、実際のコードで発生する問題を特定して解決する実践的なテクニックをお伝えします。初心者の方でも理解できるよう、図解を交えながら段階的に解説していきますので、ぜひ最後までお付き合いください。

背景

ブラウザのレンダリングプロセス

まず、ブラウザが画面を描画する仕組みを理解しましょう。

ブラウザは HTML や CSS、JavaScript を受け取ると、以下のステップで画面を描画します。

#ステップ名説明
1ParseHTML/CSS を解析し DOM ツリーと CSSOM ツリーを構築
2StyleDOM と CSSOM を組み合わせ、各要素のスタイルを計算
3Layout各要素の位置とサイズを計算(リフロー)
4Paint実際のピクセルを描画
5Composite複数のレイヤーを合成して最終的な画面を生成

ブラウザの描画処理の流れを以下の図で示します。

mermaidflowchart LR
  html["HTML"] --> parseNode["Parse<br/>DOM/CSSOM生成"]
  css["CSS"] --> parseNode
  parseNode --> styleNode["Style<br/>スタイル計算"]
  styleNode --> layoutNode["Layout<br/>位置・サイズ計算"]
  layoutNode --> paintNode["Paint<br/>ピクセル描画"]
  paintNode --> compositeNode["Composite<br/>レイヤー合成"]
  compositeNode --> screen["画面表示"]

この流れの中で、Layout(レイアウト計算) は最も処理負荷が高い工程の一つです。要素の位置やサイズを計算するには、周辺の要素との関係も考慮する必要があるため、非常に計算コストが高くなります。

JavaScript による DOM 操作の影響

JavaScript で DOM を操作すると、ブラウザは画面の再描画が必要かどうかを判断します。

以下のような操作を行うと、レイアウトの再計算が発生するのです。

  • 要素の width、height、top、left などの変更
  • クラスの追加・削除によるスタイル変更
  • 要素の追加・削除
  • フォントサイズやテキスト内容の変更

適切に処理すれば問題ありませんが、読み取りと書き込みを交互に繰り返すと、深刻なパフォーマンス問題が発生してしまいます。

課題

レイアウトスラッシングとは何か

レイアウトスラッシング(Layout Thrashing)は、DOM の読み取りと書き込みを交互に繰り返すことで、ブラウザが何度もレイアウト計算を強制される現象です。別名「強制同期レイアウト(Forced Synchronous Layout)」とも呼ばれます。

通常、ブラウザは効率化のため、複数の変更をまとめて処理します。しかし、レイアウト情報の読み取り(offsetWidth や getBoundingClientRect() など)が発生すると、ブラウザは「正確な値を返すために、今すぐレイアウト計算をしなければならない」と判断し、バッチ処理を中断してしまうのです。

レイアウトスラッシングが発生する仕組みを図で示します。

mermaidsequenceDiagram
  participant JS as JavaScript
  participant Browser as ブラウザ
  participant Layout as レイアウトエンジン

  JS->>Browser: 要素のスタイルを変更(書き込み)
  Note over Browser: 変更をキューに追加<br/>まだ計算しない

  JS->>Browser: offsetWidth を読み取り(読み取り)
  Browser->>Layout: 強制的にレイアウト計算を実行!
  Layout-->>Browser: 計算結果
  Browser-->>JS: 値を返す

  JS->>Browser: 次の要素のスタイルを変更(書き込み)
  Note over Browser: また変更をキューに追加

  JS->>Browser: 再び offsetWidth を読み取り(読み取り)
  Browser->>Layout: また強制的にレイアウト計算!
  Layout-->>Browser: 計算結果
  Browser-->>JS: 値を返す

  Note over Layout: 繰り返しによって<br/>大量の計算が発生

図で理解できる要点:

  • 書き込み → 読み取り → 書き込み → 読み取り…のサイクルで、毎回レイアウト計算が強制される
  • ブラウザの最適化機能が無効化され、パフォーマンスが著しく低下
  • 処理が同期的に実行されるため、メインスレッドがブロックされる

レイアウトスラッシングを引き起こす典型的なコード

実際にレイアウトスラッシングが発生する悪い例を見てみましょう。

悪い例:複数要素の高さを揃える処理

typescript// レイアウトスラッシングが発生する悪いコード例
const boxes = document.querySelectorAll('.box');

boxes.forEach((box) => {
  // 1. 高さを読み取る(レイアウト計算が発生)
  const height = box.offsetHeight;

  // 2. 高さを書き込む(レイアウトを変更)
  box.style.height = `${height + 10}px`;

  // このサイクルが要素の数だけ繰り返される!
});

このコードでは、要素が 100 個あれば、レイアウト計算が 100 回も実行されてしまいます。

読み取り(offsetHeight)→ 書き込み(style.height)→ 読み取り → 書き込み…と交互に処理されるため、ブラウザは毎回レイアウト計算を強制されるのです。

レイアウト計算を引き起こす主なプロパティ

レイアウトスラッシングを避けるために、どのプロパティが危険かを把握しておきましょう。

#カテゴリプロパティ・メソッド
1オフセット系offsetTop, offsetLeft, offsetWidth, offsetHeight, offsetParent
2クライアント系clientTop, clientLeft, clientWidth, clientHeight
3スクロール系scrollTop, scrollLeft, scrollWidth, scrollHeight
4計算系メソッドgetBoundingClientRect(), getComputedStyle()
5その他innerText, scrollIntoView(), focus()

これらのプロパティやメソッドを使うこと自体は問題ありません。しかし、書き込み処理と交互に使うと、レイアウトスラッシングが発生する点に注意が必要です。

パフォーマンスへの影響

レイアウトスラッシングが発生すると、以下のような問題が起こります。

  • フレームレートの低下: 60fps を維持できず、アニメーションがカクカクする
  • 入力の遅延: スクロールやクリックの反応が遅くなる
  • ユーザー体験の悪化: 「重いサイト」という印象を与えてしまう
  • モバイル端末での深刻な影響: CPU 性能が低いデバイスでは特に顕著

パフォーマンス目標として、1 フレームあたり 16.6ms(60fps)以内に処理を完了させる必要があります。レイアウトスラッシングが発生すると、この時間を大幅に超過してしまうのです。

解決策

基本原則:読み取りと書き込みを分離する

レイアウトスラッシングを防ぐ最も基本的な方法は、読み取り処理と書き込み処理を分離することです。

先ほどの悪い例を改善してみましょう。

改善例:読み取りフェーズと書き込みフェーズを分離

typescript// 解決策:読み取りと書き込みを分離する
const boxes = document.querySelectorAll('.box');

// フェーズ1: すべての読み取り処理をまとめて実行
const heights = Array.from(boxes).map(
  (box) => box.offsetHeight
);

このコードでは、まず全要素の高さを一度に読み取ります。

ブラウザは 1 回のレイアウト計算で全要素の情報を取得できるため、効率的です。

typescript// フェーズ2: すべての書き込み処理をまとめて実行
boxes.forEach((box, index) => {
  box.style.height = `${heights[index] + 10}px`;
});

次に、読み取った値を使って書き込み処理を実行します。

この時点ではレイアウト計算は発生せず、変更がキューに追加されるだけです。ブラウザは最適なタイミングでまとめて処理してくれます。

パフォーマンス比較

#方法レイアウト計算回数(100 要素の場合)パフォーマンス
1悪い例(交互処理)100 回★☆☆☆☆
2改善例(分離処理)1〜2 回★★★★★

読み取りと書き込みを分離するだけで、レイアウト計算が 100 回から 1〜2 回に激減します。これは劇的なパフォーマンス改善ですね。

requestAnimationFrame を活用する

requestAnimationFrame() は、ブラウザの次の描画タイミングで処理を実行するメソッドです。

レイアウトスラッシングを防ぎながら、スムーズなアニメーションを実現できます。

requestAnimationFrame の仕組み

typescript// requestAnimationFrame の基本的な使い方
function updateAnimation() {
  // ここに描画処理を記述
  const box = document.querySelector('.box');
  box.style.transform = `translateX(${position}px)`;

  // 次のフレームでも実行する場合
  if (animationContinues) {
    requestAnimationFrame(updateAnimation);
  }
}

// アニメーション開始
requestAnimationFrame(updateAnimation);

requestAnimationFrame() を使うことで、ブラウザの描画サイクルに合わせて処理を実行できます。

通常、ディスプレイのリフレッシュレートに合わせて 60fps(約 16.6ms ごと)で呼び出されるため、無駄な処理を省けるのです。

スクロール処理での活用例

スクロールイベントは頻繁に発生するため、レイアウトスラッシングが起きやすい場所です。

typescript// スクロール位置を取得する処理
let ticking = false;

window.addEventListener('scroll', () => {
  // まだ処理が予約されていない場合のみ予約
  if (!ticking) {
    requestAnimationFrame(() => {
      // スクロール位置を読み取る
      const scrollY = window.scrollY;

      // DOM を更新する
      updateHeaderStyle(scrollY);

      // 処理完了フラグをリセット
      ticking = false;
    });

    ticking = true;
  }
});

この実装では、スクロールイベントが連続して発生しても、requestAnimationFrame() による処理は 1 フレームに 1 回だけ実行されます。

フラグ(ticking)を使って、処理が予約されているかを管理しているのがポイントです。

typescript// ヘッダーのスタイルを更新する関数
function updateHeaderStyle(scrollY: number): void {
  const header = document.querySelector(
    '.header'
  ) as HTMLElement;

  if (scrollY > 100) {
    header.classList.add('scrolled');
  } else {
    header.classList.remove('scrolled');
  }
}

実際の DOM 更新処理は別関数に分離します。

こうすることで、読み取り(scrollY)と書き込み(classList の操作)のタイミングを明確に制御できますね。

FastDOM ライブラリを使う

FastDOM は、読み取りと書き込みを自動的にバッチ処理してくれる便利なライブラリです。

レイアウトスラッシングを防ぐための専用ツールとして、多くのプロジェクトで採用されています。

FastDOM のインストール

bash# Yarn でインストール
yarn add fastdom

FastDOM の基本的な使い方

typescript// FastDOM のインポート
import fastdom from 'fastdom';

まず、FastDOM をインポートします。

TypeScript を使う場合は、型定義も自動的に読み込まれるので安心です。

typescript// 読み取り処理を measure に登録
fastdom.measure(() => {
  const box = document.querySelector('.box') as HTMLElement;
  const width = box.offsetWidth;

  // 読み取った値を使った書き込み処理を mutate に登録
  fastdom.mutate(() => {
    box.style.width = `${width * 2}px`;
  });
});

measure() メソッドで読み取り処理を登録し、mutate() メソッドで書き込み処理を登録します。

FastDOM は内部でこれらをバッチ処理し、最適なタイミングで実行してくれるのです。

複数要素の処理を FastDOM で最適化

typescript// 複数要素の高さを揃える処理を FastDOM で実装
const boxes = document.querySelectorAll('.box');
const heights: number[] = [];

// すべての読み取り処理を measure にまとめる
fastdom.measure(() => {
  boxes.forEach((box) => {
    heights.push(box.offsetHeight);
  });

  // 読み取り完了後、書き込み処理を mutate に登録
  fastdom.mutate(() => {
    boxes.forEach((box, index) => {
      (box as HTMLElement).style.height = `${
        heights[index] + 10
      }px`;
    });
  });
});

FastDOM を使うことで、コードの構造を変えずにレイアウトスラッシングを防げます。

measure() 内で全読み取りを完了してから、mutate() で全書き込みを実行する流れです。

CSS Transform と Opacity を活用する

JavaScript での DOM 操作を減らし、CSS のアニメーションに任せることも有効な戦略です。

特に transformopacity は、レイアウト計算を発生させずにアニメーションできる特別なプロパティなのです。

レイアウトを発生させないプロパティ

以下のプロパティは、合成(Composite)レイヤーで処理されるため、レイアウト計算が不要です。

#プロパティ用途パフォーマンス
1transform移動・回転・拡大縮小★★★★★
2opacity透明度の変更★★★★★
3filterぼかし・色調整など★★★★☆

レイアウト計算が不要なプロパティを使った処理の流れを図で示します。

mermaidflowchart TD
  js["JavaScript<br/>transform/opacityを変更"] --> composite["Composite<br/>合成レイヤーで処理"]
  composite --> gpu["GPU で高速処理"]
  gpu --> screen["画面表示"]

  style composite fill:#e1f5e1
  style gpu fill:#e1f5e1

  js2["JavaScript<br/>width/heightを変更"] --> layout["Layout<br/>レイアウト再計算"]
  layout --> paint["Paint<br/>再描画"]
  paint --> composite2["Composite<br/>合成"]
  composite2 --> screen2["画面表示"]

  style layout fill:#ffe1e1
  style paint fill:#ffe1e1

図で理解できる要点:

  • transformopacity は Layout と Paint をスキップできる
  • GPU アクセラレーションにより高速処理が可能
  • widthheight の変更は全工程を通る必要がある

移動アニメーションの実装比較

typescript// 悪い例:left プロパティで移動(レイアウト計算が発生)
function animateWithLeft(
  element: HTMLElement,
  targetX: number
): void {
  let currentX = 0;

  function step() {
    currentX += 2;
    element.style.left = `${currentX}px`; // レイアウト計算が毎フレーム発生!

    if (currentX < targetX) {
      requestAnimationFrame(step);
    }
  }

  requestAnimationFrame(step);
}

left プロパティを使った移動は、毎フレームでレイアウト計算が発生します。

これは避けるべきパターンですね。

typescript// 良い例:transform プロパティで移動(レイアウト計算不要)
function animateWithTransform(
  element: HTMLElement,
  targetX: number
): void {
  let currentX = 0;

  function step() {
    currentX += 2;
    element.style.transform = `translateX(${currentX}px)`; // 合成レイヤーで処理!

    if (currentX < targetX) {
      requestAnimationFrame(step);
    }
  }

  requestAnimationFrame(step);
}

transform: translateX() を使うと、レイアウト計算をスキップして合成レイヤーで処理されます。

GPU アクセラレーションも有効になり、非常にスムーズなアニメーションが実現できるのです。

will-change プロパティで最適化を促進

css/* アニメーションする要素に will-change を指定 */
.animated-box {
  will-change: transform, opacity;
}

will-change プロパティを使うと、ブラウザに「この要素はアニメーションする予定だ」と事前に伝えられます。

ブラウザは専用の合成レイヤーを作成し、より高速な処理を準備してくれるでしょう。

ただし、will-change は使いすぎるとメモリを大量に消費するので、本当にアニメーションする要素にのみ指定することが大切です。

IntersectionObserver で遅延処理を実装

画面外の要素に対しては、処理を遅延させることでパフォーマンスを改善できます。

IntersectionObserver API を使うと、要素が表示領域に入ったタイミングを効率的に検出できるのです。

IntersectionObserver の基本的な使い方

typescript// IntersectionObserver のオプション設定
const options: IntersectionObserverInit = {
  root: null, // ビューポートを基準にする
  rootMargin: '50px', // 50px 手前で検出開始
  threshold: 0.1, // 10% 見えたら発火
};

オプションで検出のタイミングを細かく制御できます。

rootMargin を設定すると、実際に見える前に処理を開始できるので便利です。

typescript// オブザーバーのコールバック関数
const callback: IntersectionObserverCallback = (
  entries
) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      // 要素が表示領域に入った時の処理
      const target = entry.target as HTMLElement;
      loadContent(target);

      // 一度処理したら監視を解除
      observer.unobserve(target);
    }
  });
};

コールバック関数では、各要素が表示領域に入ったかを isIntersecting プロパティで判定します。

処理が完了したら unobserve() で監視を解除することで、メモリを節約できますね。

typescript// オブザーバーを作成して要素を監視
const observer = new IntersectionObserver(
  callback,
  options
);

const lazyElements =
  document.querySelectorAll('.lazy-load');
lazyElements.forEach((element) => {
  observer.observe(element);
});

最後に、監視対象の要素を observe() メソッドで登録します。

これで、スクロールイベントを使わずに効率的な遅延読み込みが実現できるのです。

画像の遅延読み込みへの応用

typescript// 画像の遅延読み込みを実装する関数
function loadContent(element: HTMLElement): void {
  const img = element as HTMLImageElement;
  const src = img.dataset.src;

  if (src) {
    // data-src 属性から実際の画像 URL を取得
    img.src = src;

    // 読み込み完了後にクラスを追加
    img.onload = () => {
      img.classList.add('loaded');
    };
  }
}

画像要素の data-src 属性に実際の URL を格納しておき、表示領域に入ったタイミングで src に設定します。

この方法により、初期ページロードで読み込む画像を大幅に削減でき、レイアウトスラッシングのリスクも減らせるでしょう。

具体例

実践例 1:パララックススクロールの最適化

パララックススクロール(視差効果)は美しいエフェクトですが、実装を間違えるとレイアウトスラッシングの温床になります。

ここでは、問題のあるコードから最適化されたコードへと段階的に改善していきましょう。

問題のあるパララックス実装

typescript// レイアウトスラッシングが発生する悪い実装
const parallaxElements =
  document.querySelectorAll('.parallax');

window.addEventListener('scroll', () => {
  parallaxElements.forEach((element) => {
    // 読み取り:現在のスクロール位置
    const scrollY = window.scrollY;

    // 読み取り:要素の位置
    const rect = element.getBoundingClientRect(); // レイアウト計算!

    // 書き込み:要素を移動
    (
      element as HTMLElement
    ).style.transform = `translateY(${scrollY * 0.5}px)`; // スタイル変更!
  });
});

このコードでは、スクロールのたびに各要素で読み取りと書き込みが交互に発生します。

要素が 10 個あれば、1 回のスクロールで 10 回のレイアウト計算が起きてしまうのです。

最適化されたパララックス実装

typescript// 最適化されたパララックス実装
const parallaxElements =
  document.querySelectorAll('.parallax');
let ticking = false;
let scrollY = 0;

// スクロール位置の読み取りのみを行う
window.addEventListener('scroll', () => {
  scrollY = window.scrollY; // 読み取り処理のみ

  if (!ticking) {
    requestAnimationFrame(updateParallax);
    ticking = true;
  }
});

まず、スクロールイベントでは scrollY の読み取りだけを行います。

実際の DOM 更新は requestAnimationFrame() に委譲し、フレームごとに 1 回だけ実行されるようにしているのがポイントです。

typescript// 実際の更新処理は requestAnimationFrame で実行
function updateParallax(): void {
  // すべての書き込み処理をまとめて実行
  parallaxElements.forEach((element) => {
    const speed = parseFloat(
      (element as HTMLElement).dataset.speed || '0.5'
    );
    (
      element as HTMLElement
    ).style.transform = `translateY(${scrollY * speed}px)`;
  });

  ticking = false;
}

updateParallax() 関数では、読み取った scrollY の値を使って全要素のスタイルを更新します。

読み取りフェーズは既に完了しているため、ここでは書き込みだけが連続して実行され、レイアウトスラッシングが発生しません。

CSS による最適化も併用

css/* パララックス要素の基本スタイル */
.parallax {
  /* 合成レイヤーを作成してGPUアクセラレーションを有効化 */
  will-change: transform;

  /* 3D変換を有効にして合成レイヤーを確実に作成 */
  transform: translateZ(0);
}

CSS 側でも最適化を施します。

will-change: transformtranslateZ(0) により、専用の合成レイヤーが作成され、GPU で高速処理されるようになるのです。

実践例 2:無限スクロールリストの実装

無限スクロールは、大量のデータを扱う Web アプリケーションで欠かせない機能です。

しかし、実装を誤るとレイアウトスラッシングとメモリ問題が同時に発生してしまいます。

仮想スクロール(Virtual Scroll)の概念

仮想スクロールは、画面に表示される要素だけを DOM に存在させる手法です。

全体で 10,000 件のデータがあっても、実際に DOM に存在するのは画面に見える 20〜30 件だけという仕組みになります。

仮想スクロールの仕組みを図で示します。

mermaidflowchart TB
  data["全データ<br/>10,000件"] --> viewport["ビューポート<br/>表示領域"]
  viewport --> visible["表示中の要素<br/>20件のみDOM生成"]

  subgraph dom["DOM構造"]
    container["コンテナ<br/>height: 計算された全体高さ"]
    container --> visible
  end

  scroll["スクロール位置"] --> calc["表示範囲を計算"]
  calc --> visible

  style visible fill:#e1f5e1
  style data fill:#e1e1f5

図で理解できる要点:

  • 全データを保持しつつ、DOM には表示部分のみを生成
  • スクロール位置から表示範囲を計算し、動的に要素を生成・破棄
  • メモリ使用量とレイアウト計算を最小限に抑える

仮想スクロールの基本実装

typescript// 仮想スクロールの設定
interface VirtualScrollConfig {
  itemHeight: number; // 各アイテムの高さ(固定)
  containerHeight: number; // コンテナの高さ
  buffer: number; // 前後に余分に表示する要素数
}

class VirtualScroll {
  private container: HTMLElement;
  private config: VirtualScrollConfig;
  private data: any[];
  private scrollY: number = 0;
  private ticking: boolean = false;

  constructor(
    container: HTMLElement,
    data: any[],
    config: VirtualScrollConfig
  ) {
    this.container = container;
    this.data = data;
    this.config = config;

    this.init();
  }

まず、仮想スクロールの基本的なクラス構造を定義します。

itemHeight を固定にすることで、計算を単純化しているのがポイントです(可変高さの場合はより複雑な計算が必要になります)。

typescript  // 初期化処理
  private init(): void {
    // コンテナの高さを全体の高さに設定
    const totalHeight = this.data.length * this.config.itemHeight;
    this.container.style.height = `${totalHeight}px`;
    this.container.style.position = 'relative';

    // スクロールイベントをリスニング
    this.container.addEventListener('scroll', () => {
      this.scrollY = this.container.scrollTop;

      if (!this.ticking) {
        requestAnimationFrame(() => this.update());
        this.ticking = true;
      }
    });

    // 初回描画
    this.update();
  }

初期化では、コンテナの高さを全データ分の高さに設定します。

これにより、スクロールバーが正しい長さで表示されるのです。スクロールイベントでは例によって requestAnimationFrame() を使い、フレームごとに 1 回だけ更新します。

typescript  // 表示範囲を計算して更新
  private update(): void {
    const { itemHeight, containerHeight, buffer } = this.config;

    // 表示開始位置と終了位置を計算
    const startIndex = Math.max(
      0,
      Math.floor(this.scrollY / itemHeight) - buffer
    );
    const endIndex = Math.min(
      this.data.length,
      Math.ceil((this.scrollY + containerHeight) / itemHeight) + buffer
    );

    // 表示範囲のアイテムのみを描画
    this.renderItems(startIndex, endIndex);

    this.ticking = false;
  }

update() メソッドでは、現在のスクロール位置から表示すべき要素の範囲を計算します。

buffer を設けることで、スクロール時に一瞬要素が見えなくなる現象を防げるでしょう。

typescript  // アイテムを描画
  private renderItems(startIndex: number, endIndex: number): void {
    const fragment = document.createDocumentFragment();

    // 既存の要素をクリア
    this.container.innerHTML = '';

    // 表示範囲のアイテムを生成
    for (let i = startIndex; i < endIndex; i++) {
      const item = this.createItem(this.data[i], i);
      fragment.appendChild(item);
    }

    // 一括で DOM に追加
    this.container.appendChild(fragment);
  }

実際の描画では、DocumentFragment を使って要素を一括追加します。

これにより、レイアウト計算が最小限に抑えられるのです。

typescript  // 個別のアイテム要素を作成
  private createItem(data: any, index: number): HTMLElement {
    const item = document.createElement('div');
    item.className = 'virtual-item';

    // アイテムの位置を絶対配置で指定
    item.style.position = 'absolute';
    item.style.top = `${index * this.config.itemHeight}px`;
    item.style.height = `${this.config.itemHeight}px`;
    item.style.width = '100%';

    // データを表示
    item.textContent = data.text;

    return item;
  }
}

各アイテムは position: absolute で配置し、top の値でスクロール位置に応じた正確な位置を指定します。

これにより、見た目は通常のスクロールリストと変わらない動作が実現できるのです。

使用例

typescript// 仮想スクロールの使用例
const container = document.querySelector(
  '.scroll-container'
) as HTMLElement;

// 10,000 件のダミーデータを生成
const data = Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  text: `アイテム ${i + 1}`,
}));

// 仮想スクロールを初期化
const virtualScroll = new VirtualScroll(container, data, {
  itemHeight: 50, // 各アイテムの高さは 50px
  containerHeight: 500, // コンテナの高さは 500px
  buffer: 3, // 前後に3つずつ余分に表示
});

実際の使用は非常にシンプルです。

10,000 件のデータを渡しても、DOM に存在するのは常に 20〜30 件程度なので、パフォーマンスは全く問題ありません。

実践例 3:リアルタイムダッシュボードの最適化

複数のグラフやメーターが同時に更新されるダッシュボードでは、レイアウトスラッシングが深刻な問題になります。

FastDOM を活用した実装例を見ていきましょう。

ダッシュボードの構造

typescript// ダッシュボードのデータ型定義
interface DashboardData {
  metrics: {
    cpu: number;
    memory: number;
    network: number;
    disk: number;
  };
  timestamp: number;
}

// ウィジェットの基底クラス
abstract class DashboardWidget {
  protected element: HTMLElement;
  protected valueElement: HTMLElement;

  constructor(selector: string) {
    this.element = document.querySelector(
      selector
    ) as HTMLElement;
    this.valueElement = this.element.querySelector(
      '.value'
    ) as HTMLElement;
  }

  abstract update(value: number): void;
}

まず、ダッシュボードの基本的な型とウィジェットの基底クラスを定義します。

各ウィジェットは update() メソッドで値を更新する共通インターフェースを持つのです。

FastDOM を使ったバッチ更新

typescript// メーターウィジェットの実装
class MeterWidget extends DashboardWidget {
  private barElement: HTMLElement;

  constructor(selector: string) {
    super(selector);
    this.barElement = this.element.querySelector(
      '.bar'
    ) as HTMLElement;
  }

  update(value: number): void {
    // FastDOM の measure で読み取り
    fastdom.measure(() => {
      const maxWidth = this.element.offsetWidth;
      const percentage = Math.min(100, Math.max(0, value));

      // FastDOM の mutate で書き込み
      fastdom.mutate(() => {
        this.valueElement.textContent = `${percentage.toFixed(
          1
        )}%`;
        this.barElement.style.width = `${percentage}%`;

        // 閾値に応じてスタイルを変更
        this.barElement.className = 'bar';
        if (percentage > 80) {
          this.barElement.classList.add('danger');
        } else if (percentage > 60) {
          this.barElement.classList.add('warning');
        }
      });
    });
  }
}

メーターウィジェットでは、measure() で要素の幅を読み取り、mutate() でバーの幅とテキストを更新します。

FastDOM が自動的にバッチ処理してくれるため、複数のウィジェットを同時に更新してもレイアウトスラッシングは発生しません。

ダッシュボード全体の更新処理

typescript// ダッシュボード全体を管理するクラス
class Dashboard {
  private widgets: Map<string, DashboardWidget>;
  private updateInterval: number;
  private intervalId?: number;

  constructor(updateInterval: number = 1000) {
    this.widgets = new Map();
    this.updateInterval = updateInterval;

    this.initWidgets();
  }

  private initWidgets(): void {
    this.widgets.set('cpu', new MeterWidget('.widget-cpu'));
    this.widgets.set('memory', new MeterWidget('.widget-memory'));
    this.widgets.set('network', new MeterWidget('.widget-network'));
    this.widgets.set('disk', new MeterWidget('.widget-disk'));
  }

ダッシュボード全体を管理するクラスでは、複数のウィジェットを Map で管理します。

各ウィジェットのインスタンスを保持し、一括更新できる構造になっているのです。

typescript  // ダッシュボードの起動
  start(): void {
    this.intervalId = window.setInterval(() => {
      this.fetchData();
    }, this.updateInterval);

    // 初回データ取得
    this.fetchData();
  }

  // ダッシュボードの停止
  stop(): void {
    if (this.intervalId) {
      clearInterval(this.intervalId);
    }
  }

start() メソッドで定期的なデータ取得を開始し、stop() で停止します。

シンプルですが、リソースのクリーンアップも忘れずに行っているのがポイントですね。

typescript  // データを取得して更新
  private async fetchData(): Promise<void> {
    try {
      // API からデータを取得(実際の実装では fetch を使用)
      const data = await this.getData();

      // すべてのウィジェットを更新
      // FastDOM が内部でバッチ処理してくれる
      this.widgets.get('cpu')?.update(data.metrics.cpu);
      this.widgets.get('memory')?.update(data.metrics.memory);
      this.widgets.get('network')?.update(data.metrics.network);
      this.widgets.get('disk')?.update(data.metrics.disk);

    } catch (error) {
      console.error('データ取得エラー:', error);
    }
  }

  // データ取得(ダミー実装)
  private async getData(): Promise<DashboardData> {
    return {
      metrics: {
        cpu: Math.random() * 100,
        memory: Math.random() * 100,
        network: Math.random() * 100,
        disk: Math.random() * 100
      },
      timestamp: Date.now()
    };
  }
}

fetchData() メソッドでは、API からデータを取得して全ウィジェットを更新します。

各ウィジェットの update() が FastDOM を使っているため、4 つのウィジェットを同時に更新してもレイアウト計算は最小限で済むのです。

使用例

typescript// ダッシュボードの起動
const dashboard = new Dashboard(2000); // 2秒ごとに更新
dashboard.start();

// ページを離れる時は停止
window.addEventListener('beforeunload', () => {
  dashboard.stop();
});

実際の使用は非常にシンプルです。

更新間隔を指定してダッシュボードを起動するだけで、レイアウトスラッシングを気にせず複数のウィジェットをリアルタイム更新できます。

パフォーマンス計測とデバッグ

最適化の効果を確認するには、適切な計測が欠かせません。

Chrome DevTools を使った具体的な計測方法を見ていきましょう。

Performance タブでの計測手順

#ステップ操作内容
1記録開始Chrome DevTools の Performance タブで記録ボタンをクリック
2操作実行対象のページで問題が起きる操作(スクロールなど)を実行
3記録停止停止ボタンをクリックして記録を終了
4分析タイムラインでレイアウト(紫色)の発生箇所を確認
5詳細確認該当箇所をクリックして呼び出し元のコードを特定

レイアウトスラッシングの検出

Chrome DevTools では、レイアウト計算が発生すると紫色のバーで表示されます。

以下のような警告メッセージが表示されたら、レイアウトスラッシングが発生している証拠です。

typescript// DevTools で検出される警告の例
/*
Warning: Forced reflow is a likely performance bottleneck.
at HTMLElement.offsetHeight (script.js:42)
*/

この警告が出たら、該当行のコードを見直し、読み取りと書き込みを分離する必要があります。

Performance API での計測

typescript// Performance API を使った計測
function measurePerformance(
  name: string,
  callback: () => void
): void {
  // 計測開始
  performance.mark(`${name}-start`);

  // 処理を実行
  callback();

  // 計測終了
  performance.mark(`${name}-end`);

  // 計測結果を記録
  performance.measure(name, `${name}-start`, `${name}-end`);

  // 結果を取得
  const measures = performance.getEntriesByName(name);
  if (measures.length > 0) {
    console.log(
      `${name}: ${measures[0].duration.toFixed(2)}ms`
    );
  }

  // クリーンアップ
  performance.clearMarks();
  performance.clearMeasures();
}

Performance API を使うと、コード内で直接パフォーマンスを計測できます。

改善前後の処理時間を比較することで、最適化の効果を数値で確認できるでしょう。

typescript// 使用例
measurePerformance('update-layout', () => {
  const boxes = document.querySelectorAll('.box');

  // 読み取りフェーズ
  const heights = Array.from(boxes).map(
    (box) => box.offsetHeight
  );

  // 書き込みフェーズ
  boxes.forEach((box, i) => {
    (box as HTMLElement).style.height = `${
      heights[i] + 10
    }px`;
  });
});

// コンソール出力例: "update-layout: 2.34ms"

このように、最適化前後で計測を実行し、処理時間がどれだけ改善されたかを確認できます。

数値で効果が見えると、モチベーションも上がりますね。

まとめ

レイアウトスラッシングは、Web アプリケーションのパフォーマンスを著しく低下させる深刻な問題です。しかし、その仕組みを理解し、適切な対策を講じることで、驚くほどスムーズな動作を実現できます。

本記事で紹介した重要なポイントをまとめましょう。

レイアウトスラッシング対策の 5 つの原則

#原則具体的な方法
1読み取りと書き込みの分離まとめて読み取り、まとめて書き込む
2requestAnimationFrame の活用ブラウザの描画サイクルに合わせた処理
3FastDOM ライブラリの導入自動的なバッチ処理による最適化
4Transform/Opacity の優先使用レイアウト計算が不要なプロパティを選択
5適切な計測とデバッグDevTools と Performance API での継続的な監視

避けるべきアンチパターン

  • スクロールやリサイズイベント内での直接的な DOM 操作
  • ループ内での読み取り → 書き込み → 読み取りの繰り返し
  • offsetWidthgetBoundingClientRect() などの頻繁な呼び出し
  • アニメーションでの lefttopwidthheight の使用
  • 画面外の要素に対する不必要な処理

最適化の効果

適切な最適化を行うことで、以下のような改善が期待できます。

  • フレームレートが 20fps から 60fps に向上
  • スクロール時のジャンクがなくなり、スムーズな動作を実現
  • モバイル端末でも快適に動作
  • ユーザー満足度の大幅な向上

パフォーマンス最適化は、一度学べば様々なプロジェクトで活用できる貴重なスキルです。本記事で紹介したテクニックを実際のプロジェクトで試していただき、ユーザーに快適な体験を提供していただければ幸いです。

レイアウトスラッシングを理解し、適切に対処することで、あなたの Web アプリケーションはワンランク上のパフォーマンスを手に入れることができるでしょう。

関連リンク