はじめに

Webアプリケーションを開発していると、「なんだか動作が重い」「スクロールがカクつく」「ボタンを押しても反応が遅い」といった問題に遭遇することがあります。これらの問題の多くは、JavaScriptのパフォーマンスに起因しています。

ユーザー体験において、パフォーマンスは非常に重要です。Googleの調査によると、ページの読み込みが1秒遅れるだけで、コンバージョン率が7%低下するとされています。快適なユーザー体験を提供するためには、パフォーマンスを意識したコーディングが欠かせません。

本記事では、JavaScriptのパフォーマンス最適化について、以下の内容を初心者向けに解説します。

  • パフォーマンス最適化の考え方と計測方法
  • 計算量(時間計算量)の基礎知識
  • メモリ管理とメモリリークの防止
  • DOM操作の効率化テクニック
  • デバウンスとスロットルによる遅延処理
  • キャッシュ戦略の活用
  • ネットワーク通信の最適化

パフォーマンス最適化の基本原則

パフォーマンス最適化を始める前に、重要な原則を押さえておきましょう。

flowchart LR
    A[計測] --> B[分析]
    B --> C[最適化]
    C --> D[検証]
    D --> A

「推測するな、計測せよ」

最適化の第一原則は「推測に基づいて最適化しない」ことです。実際にボトルネックとなっている箇所を計測し、データに基づいて改善を行います。

1
2
3
4
5
// console.time()を使った処理時間の計測
console.time('配列処理');
const result = largeArray.map(item => item * 2).filter(item => item > 100);
console.timeEnd('配列処理');
// 出力例: 配列処理: 15.234ms

Chrome DevToolsによる計測

ブラウザのDevToolsを活用することで、より詳細なパフォーマンス分析が可能です。

1
2
3
4
5
6
7
8
// Performance APIを使った高精度な計測
const startTime = performance.now();

// 計測対象の処理
heavyComputation();

const endTime = performance.now();
console.log(`処理時間: ${endTime - startTime}ms`);

DevToolsのPerformanceタブでは、以下の項目を確認できます。

項目 説明
Scripting JavaScriptの実行時間
Rendering レイアウト計算とスタイル適用
Painting 画面への描画処理
Idle アイドル状態の時間

早すぎる最適化は諸悪の根源

有名な格言「早すぎる最適化は諸悪の根源である」(ドナルド・クヌース)を忘れないでください。まずは読みやすく保守しやすいコードを書き、計測によって問題が発見された箇所のみを最適化するのが正しいアプローチです。

計算量(時間計算量)の基礎

アルゴリズムの効率を評価する指標として「計算量」があります。データ量が増えたときに処理時間がどのように増加するかを表します。

Big-O記法の基本

計算量はBig-O記法で表現されます。よく使われる計算量を小さい順に並べると以下のようになります。

graph LR
    A["O(1)<br>定数時間"] --> B["O(log n)<br>対数時間"]
    B --> C["O(n)<br>線形時間"]
    C --> D["O(n log n)<br>線形対数時間"]
    D --> E["O(n²)<br>二乗時間"]

具体的なコード例

各計算量の具体例を見ていきましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// O(1) - 定数時間:データ量に関係なく一定時間
function getFirstElement(array) {
  return array[0];
}

// O(n) - 線形時間:データ量に比例して時間が増加
function findElement(array, target) {
  for (let i = 0; i < array.length; i++) {
    if (array[i] === target) {
      return i;
    }
  }
  return -1;
}

// O(n²) - 二乗時間:データ量の二乗に比例(避けるべき)
function hasDuplicate(array) {
  for (let i = 0; i < array.length; i++) {
    for (let j = i + 1; j < array.length; j++) {
      if (array[i] === array[j]) {
        return true;
      }
    }
  }
  return false;
}

計算量を改善する例

O(n²)のアルゴリズムをO(n)に改善する例を見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 改善前:O(n²) - 二重ループで重複チェック
function hasDuplicateSlow(array) {
  for (let i = 0; i < array.length; i++) {
    for (let j = i + 1; j < array.length; j++) {
      if (array[i] === array[j]) {
        return true;
      }
    }
  }
  return false;
}

// 改善後:O(n) - Setを使用して重複チェック
function hasDuplicateFast(array) {
  const seen = new Set();
  for (const item of array) {
    if (seen.has(item)) {
      return true;
    }
    seen.add(item);
  }
  return false;
}

データ量が10,000件の場合、O(n²)では約1億回の比較が必要ですが、O(n)では10,000回で済みます。

ループの早期終了

不要な処理を避けるため、条件が満たされたらループを終了させましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 改善前:全要素を走査
function findUser(users, id) {
  let result = null;
  users.forEach(user => {
    if (user.id === id) {
      result = user;
    }
  });
  return result;
}

// 改善後:見つかったら即座に終了
function findUserFast(users, id) {
  for (const user of users) {
    if (user.id === id) {
      return user; // 見つかったら即終了
    }
  }
  return null;
}

// さらに良い方法:Array.findを使用
function findUserBest(users, id) {
  return users.find(user => user.id === id) ?? null;
}

メモリ管理とメモリリーク防止

JavaScriptには自動的にメモリを管理するガベージコレクション(GC)機能がありますが、不適切なコードはメモリリークを引き起こす可能性があります。

メモリリークの主な原因

flowchart TB
    A[メモリリークの原因] --> B[グローバル変数の乱用]
    A --> C[解除されないイベントリスナー]
    A --> D[クリアされないタイマー]
    A --> E[クロージャによる参照保持]
    A --> F[DOM要素への参照保持]

イベントリスナーの適切な管理

イベントリスナーを追加したまま解除しないと、メモリリークの原因になります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 悪い例:イベントリスナーを解除しない
class BadComponent {
  constructor() {
    this.handleScroll = this.handleScroll.bind(this);
    window.addEventListener('scroll', this.handleScroll);
  }

  handleScroll() {
    console.log('スクロール中');
  }

  // destroyメソッドがないため、リスナーが残り続ける
}

// 良い例:コンポーネント破棄時にリスナーを解除
class GoodComponent {
  constructor() {
    this.handleScroll = this.handleScroll.bind(this);
    window.addEventListener('scroll', this.handleScroll);
  }

  handleScroll() {
    console.log('スクロール中');
  }

  destroy() {
    // 必ずリスナーを解除
    window.removeEventListener('scroll', this.handleScroll);
  }
}

タイマーのクリア

setIntervalsetTimeoutも不要になったらクリアしましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 悪い例:タイマーをクリアしない
function startPolling() {
  setInterval(() => {
    fetchData();
  }, 5000);
}

// 良い例:タイマーIDを保持してクリア可能にする
class DataPoller {
  constructor() {
    this.timerId = null;
  }

  start() {
    this.timerId = setInterval(() => {
      this.fetchData();
    }, 5000);
  }

  stop() {
    if (this.timerId) {
      clearInterval(this.timerId);
      this.timerId = null;
    }
  }

  fetchData() {
    console.log('データ取得中');
  }
}

大きなデータの参照解放

不要になったデータへの参照は明示的に解放しましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
let largeData = null;

async function processLargeData() {
  // 大きなデータを取得
  largeData = await fetchHugeDataset();

  // 処理を実行
  const result = transform(largeData);

  // 処理完了後、参照を解放してGC対象にする
  largeData = null;

  return result;
}

DOM操作の効率化

DOM操作はJavaScriptの中で最もコストの高い処理の一つです。効率的なDOM操作はパフォーマンス改善に大きく貢献します。

リフローとリペイントの理解

DOMを変更すると、ブラウザは以下のプロセスを実行します。

flowchart LR
    A[JavaScript] --> B[スタイル計算]
    B --> C[レイアウト<br>リフロー]
    C --> D[ペイント<br>リペイント]
    D --> E[コンポジット]

リフロー(レイアウト再計算)は特にコストが高いため、できるだけ発生回数を減らすことが重要です。

バッチ処理によるDOM更新

DOM操作は一度にまとめて行いましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 悪い例:ループ内で何度もDOMを更新
function addItemsSlow(items) {
  const list = document.getElementById('list');
  items.forEach(item => {
    const li = document.createElement('li');
    li.textContent = item;
    list.appendChild(li); // 毎回リフローが発生
  });
}

// 良い例:DocumentFragmentを使ってまとめて追加
function addItemsFast(items) {
  const list = document.getElementById('list');
  const fragment = document.createDocumentFragment();

  items.forEach(item => {
    const li = document.createElement('li');
    li.textContent = item;
    fragment.appendChild(li); // フラグメントに追加(リフローなし)
  });

  list.appendChild(fragment); // 1回のリフローで済む
}

レイアウトスラッシングの回避

読み取りと書き込みを交互に行うと、強制的なリフローが発生します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 悪い例:レイアウトスラッシング
function resizeElements(elements) {
  elements.forEach(el => {
    const width = el.offsetWidth; // 読み取り(リフロー強制)
    el.style.width = (width * 2) + 'px'; // 書き込み
  });
}

// 良い例:読み取りと書き込みを分離
function resizeElementsFast(elements) {
  // まず全ての読み取りを実行
  const widths = elements.map(el => el.offsetWidth);

  // その後、書き込みをまとめて実行
  elements.forEach((el, i) => {
    el.style.width = (widths[i] * 2) + 'px';
  });
}

CSSクラスによるスタイル変更

個別のスタイルプロパティを変更するより、CSSクラスを切り替える方が効率的です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 悪い例:個別にスタイルを変更
function highlightElementSlow(element) {
  element.style.backgroundColor = 'yellow';
  element.style.border = '2px solid orange';
  element.style.fontWeight = 'bold';
  element.style.padding = '10px';
}

// 良い例:CSSクラスを切り替え
function highlightElementFast(element) {
  element.classList.add('highlighted');
}
1
2
3
4
5
6
7
/* CSSでスタイルを定義 */
.highlighted {
  background-color: yellow;
  border: 2px solid orange;
  font-weight: bold;
  padding: 10px;
}

要素の表示切り替え

大量のDOM操作を行う場合は、要素を一時的に非表示にすることでリフローを抑えられます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function updateManyElements(container, updates) {
  // 一時的に非表示にする
  container.style.display = 'none';

  // 大量の更新を実行
  updates.forEach(update => {
    const element = container.querySelector(update.selector);
    if (element) {
      element.textContent = update.text;
      element.className = update.className;
    }
  });

  // 再表示
  container.style.display = '';
}

デバウンスとスロットル

頻繁に発生するイベント(スクロール、リサイズ、キー入力など)に対して、処理の実行頻度を制御するテクニックです。

デバウンス(Debounce)

最後のイベントから一定時間経過後に処理を実行します。検索入力のオートコンプリートなどに適しています。

sequenceDiagram
    participant U as ユーザー入力
    participant D as デバウンス
    participant F as 関数実行

    U->>D: 入力1
    Note over D: 待機開始
    U->>D: 入力2
    Note over D: 待機リセット
    U->>D: 入力3
    Note over D: 待機リセット
    Note over D: 300ms経過
    D->>F: 関数実行(1回のみ)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// デバウンス関数の実装
function debounce(func, delay) {
  let timeoutId = null;

  return function(...args) {
    // 前回のタイマーをクリア
    if (timeoutId) {
      clearTimeout(timeoutId);
    }

    // 新しいタイマーをセット
    timeoutId = setTimeout(() => {
      func.apply(this, args);
      timeoutId = null;
    }, delay);
  };
}

// 使用例:検索入力
const searchInput = document.getElementById('search');
const handleSearch = debounce((event) => {
  const query = event.target.value;
  console.log(`検索実行: ${query}`);
  // API呼び出しなど
}, 300);

searchInput.addEventListener('input', handleSearch);

スロットル(Throttle)

一定間隔で処理を実行します。スクロールイベントの処理などに適しています。

sequenceDiagram
    participant U as ユーザー操作
    participant T as スロットル
    participant F as 関数実行

    U->>T: イベント1
    T->>F: 関数実行
    Note over T: 100ms間隔制限中
    U->>T: イベント2(無視)
    U->>T: イベント3(無視)
    Note over T: 100ms経過
    U->>T: イベント4
    T->>F: 関数実行
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// スロットル関数の実装
function throttle(func, interval) {
  let lastExecuted = 0;

  return function(...args) {
    const now = Date.now();

    if (now - lastExecuted >= interval) {
      func.apply(this, args);
      lastExecuted = now;
    }
  };
}

// 使用例:スクロールイベント
const handleScroll = throttle(() => {
  const scrollY = window.scrollY;
  console.log(`スクロール位置: ${scrollY}`);
  // スクロール連動の処理
}, 100);

window.addEventListener('scroll', handleScroll);

末尾実行付きスロットル

スロットル中に発生した最後のイベントも確実に処理したい場合の実装です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function throttleWithTrailing(func, interval) {
  let lastExecuted = 0;
  let timeoutId = null;

  return function(...args) {
    const now = Date.now();
    const remaining = interval - (now - lastExecuted);

    // タイマーをクリア
    if (timeoutId) {
      clearTimeout(timeoutId);
      timeoutId = null;
    }

    if (remaining <= 0) {
      // 間隔を超えていれば即座に実行
      func.apply(this, args);
      lastExecuted = now;
    } else {
      // そうでなければ末尾実行をスケジュール
      timeoutId = setTimeout(() => {
        func.apply(this, args);
        lastExecuted = Date.now();
      }, remaining);
    }
  };
}

デバウンスとスロットルの使い分け

シナリオ 推奨手法 理由
検索のオートコンプリート デバウンス 入力が止まってから検索したい
ウィンドウリサイズ後の再レイアウト デバウンス 最終的なサイズで計算したい
スクロール中のアニメーション スロットル 定期的に状態を更新したい
無限スクロールの読み込み スロットル 一定間隔でチェックしたい
ボタン連打の防止 デバウンス 最後のクリックのみ処理したい

キャッシュ戦略

計算結果やデータを再利用することで、不要な処理を省けます。

メモ化(Memoization)

同じ引数に対する計算結果をキャッシュします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// メモ化関数の実装
function memoize(func) {
  const cache = new Map();

  return function(...args) {
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      console.log('キャッシュヒット');
      return cache.get(key);
    }

    console.log('計算実行');
    const result = func.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

// 使用例:フィボナッチ数の計算
const fibonacci = memoize(function(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
});

console.log(fibonacci(40)); // 初回:計算実行
console.log(fibonacci(40)); // 2回目:キャッシュヒット

LRUキャッシュの実装

キャッシュサイズに制限を設けて、古いデータから削除する方式です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class LRUCache {
  constructor(maxSize) {
    this.maxSize = maxSize;
    this.cache = new Map();
  }

  get(key) {
    if (!this.cache.has(key)) {
      return undefined;
    }

    // アクセスされたアイテムを末尾に移動
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);

    return value;
  }

  set(key, value) {
    // 既存のキーを削除(順序を更新するため)
    if (this.cache.has(key)) {
      this.cache.delete(key);
    }

    // サイズ上限を超えていれば最古のアイテムを削除
    if (this.cache.size >= this.maxSize) {
      const oldestKey = this.cache.keys().next().value;
      this.cache.delete(oldestKey);
    }

    this.cache.set(key, value);
  }

  has(key) {
    return this.cache.has(key);
  }
}

// 使用例:APIレスポンスのキャッシュ
const apiCache = new LRUCache(100);

async function fetchUserWithCache(userId) {
  const cacheKey = `user_${userId}`;

  if (apiCache.has(cacheKey)) {
    return apiCache.get(cacheKey);
  }

  const response = await fetch(`/api/users/${userId}`);
  const user = await response.json();

  apiCache.set(cacheKey, user);
  return user;
}

DOM要素のキャッシュ

頻繁にアクセスするDOM要素は変数に保持しましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 悪い例:毎回DOMを検索
function updateDisplayBad() {
  document.getElementById('header').textContent = 'ヘッダー更新';
  document.getElementById('header').style.color = 'blue';
  document.getElementById('content').textContent = 'コンテンツ更新';
  document.getElementById('content').style.color = 'green';
}

// 良い例:DOM要素をキャッシュ
const elements = {
  header: document.getElementById('header'),
  content: document.getElementById('content')
};

function updateDisplayGood() {
  elements.header.textContent = 'ヘッダー更新';
  elements.header.style.color = 'blue';
  elements.content.textContent = 'コンテンツ更新';
  elements.content.style.color = 'green';
}

ネットワーク通信の最適化

APIリクエストはパフォーマンスに大きな影響を与えます。効率的なネットワーク通信を心がけましょう。

リクエストのバンドリング

複数の小さなリクエストを1つにまとめます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 悪い例:個別にリクエスト
async function fetchUsersBad(userIds) {
  const users = [];
  for (const id of userIds) {
    const response = await fetch(`/api/users/${id}`);
    const user = await response.json();
    users.push(user);
  }
  return users;
}

// 良い例:バッチリクエスト
async function fetchUsersGood(userIds) {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ids: userIds })
  });
  return response.json();
}

並列リクエスト

独立したリクエストはPromise.allで並列に実行します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 悪い例:直列リクエスト
async function fetchAllDataBad() {
  const users = await fetch('/api/users').then(r => r.json());
  const products = await fetch('/api/products').then(r => r.json());
  const orders = await fetch('/api/orders').then(r => r.json());

  return { users, products, orders };
}

// 良い例:並列リクエスト
async function fetchAllDataGood() {
  const [users, products, orders] = await Promise.all([
    fetch('/api/users').then(r => r.json()),
    fetch('/api/products').then(r => r.json()),
    fetch('/api/orders').then(r => r.json())
  ]);

  return { users, products, orders };
}

遅延読み込み(Lazy Loading)

必要になるまでリソースの読み込みを遅延させます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 画像の遅延読み込み
const imageObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      img.classList.remove('lazy');
      observer.unobserve(img);
    }
  });
});

// 遅延読み込み対象の画像を監視
document.querySelectorAll('img.lazy').forEach(img => {
  imageObserver.observe(img);
});
1
2
<!-- HTML側の設定 -->
<img class="lazy" data-src="large-image.jpg" src="placeholder.jpg" alt="説明">

動的インポート

JavaScriptモジュールを必要時に読み込みます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 静的インポート(初期読み込み時にすべて読み込む)
// import { heavyFunction } from './heavyModule.js';

// 動的インポート(必要時に読み込む)
async function handleComplexOperation() {
  // ボタンクリック時にモジュールを読み込む
  const { heavyFunction } = await import('./heavyModule.js');
  heavyFunction();
}

document.getElementById('complexBtn').addEventListener('click', handleComplexOperation);

パフォーマンス計測のチェックリスト

最後に、パフォーマンス改善時に確認すべきポイントをまとめます。

計算効率

  • ループ内で不要な処理をしていないか
  • 適切なデータ構造(Set、Map)を使用しているか
  • 計算量の大きいアルゴリズムを避けているか

メモリ管理

  • イベントリスナーを適切に解除しているか
  • タイマーをクリアしているか
  • 不要な参照を保持していないか

DOM操作

  • DocumentFragmentを活用しているか
  • レイアウトスラッシングを避けているか
  • CSSクラスでスタイル変更しているか

イベント処理

  • 頻発イベントにデバウンス/スロットルを適用しているか
  • イベント委譲を活用しているか
  • 不要なイベントリスナーを削除しているか

ネットワーク

  • 並列リクエストを活用しているか
  • 遅延読み込みを実装しているか
  • キャッシュを適切に使用しているか

まとめ

本記事では、JavaScriptのパフォーマンス最適化について、基本的なテクニックを解説しました。

重要なポイントをおさらいします。

  • 計測が最優先:推測ではなくDevToolsやPerformance APIで実際に計測する
  • 計算量を意識:O(n²)のアルゴリズムはできるだけ避け、O(n)やO(1)を目指す
  • メモリリークを防ぐ:イベントリスナーとタイマーは確実に解除する
  • DOM操作を最小化:バッチ処理、DocumentFragment、CSSクラスを活用する
  • イベント実行を制御:デバウンスとスロットルで頻発イベントを最適化する
  • キャッシュを活用:計算結果、DOM要素、APIレスポンスを適切にキャッシュする
  • ネットワークを効率化:並列リクエスト、遅延読み込み、動的インポートを活用する

パフォーマンス最適化は一度行えば終わりではありません。継続的な計測と改善を通じて、ユーザーにとって快適なWebアプリケーションを提供していきましょう。

参考リンク