JavaScript パフォーマンス最適化大全:レイアウトスラッシングを潰す実践テク
Web アプリケーションのパフォーマンス改善に取り組む中で、「コードは正しく動いているのに、なぜかカクカクする」という経験はありませんか?その原因の多くは、レイアウトスラッシング(Layout Thrashing)と呼ばれる現象にあります。
本記事では、レイアウトスラッシングの仕組みを徹底的に理解し、実際のコードで発生する問題を特定して解決する実践的なテクニックをお伝えします。初心者の方でも理解できるよう、図解を交えながら段階的に解説していきますので、ぜひ最後までお付き合いください。
背景
ブラウザのレンダリングプロセス
まず、ブラウザが画面を描画する仕組みを理解しましょう。
ブラウザは HTML や CSS、JavaScript を受け取ると、以下のステップで画面を描画します。
| # | ステップ名 | 説明 | 
|---|---|---|
| 1 | Parse | HTML/CSS を解析し DOM ツリーと CSSOM ツリーを構築 | 
| 2 | Style | DOM と CSSOM を組み合わせ、各要素のスタイルを計算 | 
| 3 | Layout | 各要素の位置とサイズを計算(リフロー) | 
| 4 | Paint | 実際のピクセルを描画 | 
| 5 | Composite | 複数のレイヤーを合成して最終的な画面を生成 | 
ブラウザの描画処理の流れを以下の図で示します。
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 のアニメーションに任せることも有効な戦略です。
特に transform と opacity は、レイアウト計算を発生させずにアニメーションできる特別なプロパティなのです。
レイアウトを発生させないプロパティ
以下のプロパティは、合成(Composite)レイヤーで処理されるため、レイアウト計算が不要です。
| # | プロパティ | 用途 | パフォーマンス | 
|---|---|---|---|
| 1 | transform | 移動・回転・拡大縮小 | ★★★★★ | 
| 2 | opacity | 透明度の変更 | ★★★★★ | 
| 3 | filter | ぼかし・色調整など | ★★★★☆ | 
レイアウト計算が不要なプロパティを使った処理の流れを図で示します。
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
図で理解できる要点:
transformとopacityは Layout と Paint をスキップできる- GPU アクセラレーションにより高速処理が可能
 widthやheightの変更は全工程を通る必要がある
移動アニメーションの実装比較
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: transform と translateZ(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 | 読み取りと書き込みの分離 | まとめて読み取り、まとめて書き込む | 
| 2 | requestAnimationFrame の活用 | ブラウザの描画サイクルに合わせた処理 | 
| 3 | FastDOM ライブラリの導入 | 自動的なバッチ処理による最適化 | 
| 4 | Transform/Opacity の優先使用 | レイアウト計算が不要なプロパティを選択 | 
| 5 | 適切な計測とデバッグ | DevTools と Performance API での継続的な監視 | 
避けるべきアンチパターン
- スクロールやリサイズイベント内での直接的な DOM 操作
 - ループ内での読み取り → 書き込み → 読み取りの繰り返し
 offsetWidth、getBoundingClientRect()などの頻繁な呼び出し- アニメーションでの 
left、top、width、heightの使用 - 画面外の要素に対する不必要な処理
 
最適化の効果
適切な最適化を行うことで、以下のような改善が期待できます。
- フレームレートが 20fps から 60fps に向上
 - スクロール時のジャンクがなくなり、スムーズな動作を実現
 - モバイル端末でも快適に動作
 - ユーザー満足度の大幅な向上
 
パフォーマンス最適化は、一度学べば様々なプロジェクトで活用できる貴重なスキルです。本記事で紹介したテクニックを実際のプロジェクトで試していただき、ユーザーに快適な体験を提供していただければ幸いです。
レイアウトスラッシングを理解し、適切に対処することで、あなたの Web アプリケーションはワンランク上のパフォーマンスを手に入れることができるでしょう。
関連リンク
- MDN Web Docs - Rendering Performance
 - Google Developers - Avoid Large, Complex Layouts and Layout Thrashing
 - FastDOM - GitHub Repository
 - What forces layout / reflow - Gist by Paul Irish
 - MDN Web Docs - requestAnimationFrame()
 - MDN Web Docs - IntersectionObserver API
 - Chrome DevTools - Performance Analysis
 - CSS Triggers - What CSS properties trigger what
 
articleJavaScript 時刻の落とし穴大全:タイムゾーン/DST/うるう秒の実務対策
articleJavaScript Web Animations API:滑らかに動く UI を設計するための基本と実践
articleJavaScript Service Worker 運用術:オフライン対応・更新・キャッシュ戦略の最適解
articleJavaScript パフォーマンス最適化大全:レイアウトスラッシングを潰す実践テク
articleJavaScript IntersectionObserver レシピ集:無限スクロール/遅延読込を最短実装
articleJavaScript Web Workers 実践入門:重い処理を別スレッドへ逃がす最短手順
articleWebSocket が「200 OK で Upgrade されない」原因と対処:プロキシ・ヘッダー・TLS の落とし穴
articleWebRTC 本番運用の SLO 設計:接続成功率・初画出し時間・通話継続率の基準値
articleAstro のレンダリング戦略を一望:MPA× 部分ハイドレーションの強みを図解解説
articleWebLLM が読み込めない時の原因と解決策:CORS・MIME・パス問題を総点検
articleVitest ESM/CJS 混在で `Cannot use import statement outside a module` が出る技術対処集
articleテスト環境比較:Vitest vs Jest vs Playwright CT ― Vite プロジェクトの最適解
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来