[JavaScript講座] パフォーマンスとメモリ

当ページのリンクには広告が含まれています。
目次

何をもって「パフォーマンスが悪い」というのか

まず、測りたい指標を分けておきます。

指標

  • レイテンシ(応答時間)
    • ボタンを押してから画面が反応するまでの時間
    • APIリクエストからレスポンスまでの時間
  • スループット
    • サーバが1秒間にさばけるリクエスト数
    • バッチが1分間に処理できるレコード数
  • メモリ使用量
    • タブを開きっぱなしにしたときに、どんどんメモリが増えていないか(メモリリーク)
    • Nodeサーバがメモリを食い続けてOOM(Out Of Memory)にならないか
  • フレームレート(ブラウザ)
    • スクロール・アニメーションが60fps前後で滑らかか
    • JSやレイアウトが重すぎてカクカクしていないか

大雑把にいうと:

  • ブラウザ → 体感のサクサク感 / カクつき
  • Nodeサーバ → 1リクエストの遅さ+同時接続に対する強さ
  • どちらも → メモリリークがないか

を見ていくことになります。

「まずは測る」が大前提

最適化の鉄則:計測せずに最適化しない(premature optimizationを避ける)

手軽な計測の例

  • JSレベル:
    • console.time("label") / console.timeEnd("label")
    • performance.now()(高精度なタイムスタンプ)
  • ブラウザDevTools:
    • Performanceタブ(フレームごとの処理時間・レイアウト・スクリプト)
    • Networkタブ(APIレスポンス、静的ファイルのサイズ)
    • Memoryタブ(ヒープスナップショット、リーク検知の足がかり)
  • Node.js:
    • process.hrtime.bigint() など高精度タイマー
    • process.memoryUsage() でメモリ使用量
    • node --inspect でChrome DevToolsからCPUプロファイル / ヒープダンプ

「なんとなく遅い気がする」ではなく、
どの処理が何ミリ秒かかっているか を最初に数字に落とすのが本当に大事です。

アルゴリズムとデータ構造の視点

JS特有というよりプログラミング一般の話ですが、パフォーマンスの根本はやはりここです。

オーダー(Big-O)

  • O(1):定数時間(配列の arr[i] 参照など)
  • O(n):要素数に比例(1回ループ)
  • O(n²):2重ループ(全ペア比較など)
  • O(n log n):ソートなどでよく出る

「何万件もあるデータに O(n²) をかけていないか?」
というのが、まず最初に疑うポイント。

例:

// 悪い例:毎回indexOfで探索(O(n^2)になりがち)
function uniqueSlow(arr) {
  const result = [];
  for (const item of arr) {
    if (result.indexOf(item) === -1) {
      result.push(item);
    }
  }
  return result;
}

// 良い例:Setを使ってO(n)で重複排除
function uniqueFast(arr) {
  return [...new Set(arr)];
}

配列 vs Map / Set

  • 配列で「含まれているかどうか」を毎回 indexOf で調べる → O(n)
  • Set で管理 → 平均O(1)相当(ハッシュ構造)
const set = new Set(arr);
console.log(set.has(value));
  • 連番インデックスで順番に処理 → 配列が適している
  • 「キー→値」のマップ → Map が適している(Object より柔軟)

アルゴリズムとデータ構造をちゃんと選ぶだけで、
秒単位の処理が一瞬で終わる、ということもよくあります。

ブラウザ JS のパフォーマンス

メインスレッドをブロックしない

ブラウザは基本的に:

  • JSの実行
  • レイアウト(レイフロー)
  • ペイント
  • 入力イベント処理

を同じメインスレッドでやっています。

ここで重い同期処理を書くと:

  • その間、描画もイベント処理も止まる
  • 結果として「フリーズしたように見える」

やってはいけない例:

// 数百ミリ秒〜数秒かかる巨大なループ
button.addEventListener("click", () => {
  for (let i = 0; i < 1_000_000_000; i++) {
    // 重い計算
  }
});

回避パターン

  • 処理を「小分け」にして setTimeout / requestIdleCallback で分散
  • Web Worker を使って、別スレッドで重い計算を行う(メインスレッドは軽く)

シンプルな分散の例:

function processLargeArray(items, chunkSize = 1000) {
  let index = 0;

  function nextChunk() {
    const end = Math.min(index + chunkSize, items.length);
    for (; index < end; index++) {
      // 要素items[index]を処理
    }
    if (index < items.length) {
      setTimeout(nextChunk, 0); // 次のタスクとして回す
    }
  }

  nextChunk();
}

DOM操作は「回数」と「タイミング」がコスト

DOM操作自体はかなり高コストです:

  • 新しい要素の生成・挿入
  • レイアウト情報の参照(offsetWidth, getBoundingClientRect() など)
  • スタイル変更・クラスの変更

これを細かく何度も往復すると、レイアウト計算やペイントが頻発して重くなります。

悪い例(ループ中に毎回DOMいじる)

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

for (let i = 0; i < 10000; i++) {
  const li = document.createElement("li");
  li.textContent = `Item ${i}`;
  list.append(li); // 1万回挿入
}

少しマシな例(DocumentFragmentでまとめて)

const list = document.querySelector("#list");
const frag = document.createDocumentFragment();

for (let i = 0; i < 10000; i++) {
  const li = document.createElement("li");
  li.textContent = `Item ${i}`;
  frag.append(li);
}

list.append(frag); // 1回の挿入で済む

レイアウトスラッシングを避ける

「レイアウトの読み取り」と「書き込み」が交互に行われると負荷が増えます。

悪いパターン:

for (const el of items) {
  const width = el.offsetWidth; // レイアウト読み取り
  el.style.width = width / 2 + "px"; // 書き込み
}

より良いパターン:

  1. 先に一度レイアウト情報を全部読む
  2. そのあとで全部書き込む

スクロール/resize のハンドラは debounce / throttle

スクロールや resize イベントは、1秒間に何十回〜何百回 も発生します。

重い処理を直接ぶら下げると、すぐカクつきの原因になります。

debounce のイメージ(最後の1回だけ実行)

function debounce(fn, delay) {
  let timerId = null;
  return (...args) => {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn(...args), delay);
  };
}

window.addEventListener(
  "resize",
  debounce(() => {
    console.log("リサイズ終了っぽいので、ここで重い処理");
  }, 200)
);

throttle のイメージ(多くても一定間隔でしか実行されない)

function throttle(fn, interval) {
  let lastTime = 0;
  return (...args) => {
    const now = Date.now();
    if (now - lastTime >= interval) {
      lastTime = now;
      fn(...args);
    }
  };
}

window.addEventListener(
  "scroll",
  throttle(() => {
    console.log("スクロール中、最大でも100msごとに実行");
  }, 100)
);

メモリとGC:リークパターンを押さえる

JSのメモリ管理の前提(おさらい)

  • オブジェクトは「到達可能」である限りGCされない
  • 「到達不能」になったオブジェクトは、どこかのタイミングでGCにより回収される
  • 開発者が直接 free したりはできない

よくあるメモリリークパターン

グローバル配列・Mapに溜めっぱなし

// 悪い例:履歴を無限に溜め続ける
const cache = [];

function addLog(entry) {
  cache.push(entry);
}

対策:

  • サイズに上限を設ける(LRUキャッシュなど)
  • 本当に必要なものだけ保持する
  • いらなくなったタイミングで length = 0clear() で解放

DOM要素を削除したのに、JSから参照を残してしまう

let lastClickedElement = null;

document.addEventListener("click", (e) => {
  lastClickedElement = e.target;
});

// DOM上から要素をremoveしても、lastClickedElementが参照している間はGCされない可能性
  • 特に SPA で、「古い画面の DOM への参照」をどこかに残したまま新しい画面を作っていくと、
    タブを開きっぱなしでメモリが増え続ける、という問題になりやすい

対策:

  • 画面遷移時に不要な参照を null 代入で切る
  • 大きなDOMを掴んでいるオブジェクトの寿命を必要最小限にする

イベントリスナの付けっぱなし

function attach() {
  window.addEventListener("resize", onResize);
}

function detach() {
  window.removeEventListener("resize", onResize);
}

function onResize() {
  // ...
}

attach だけが何度も呼ばれ、detach が呼ばれないと:

  • リスナがどんどん増え続ける
  • コールバッククロージャが大きなオブジェクトを参照していると、それも生存し続ける

長寿命オブジェクト(window, document)にリスナを付ける時は特に要注意。

setInterval / setTimeout のクリア忘れ

function startPolling() {
  const id = setInterval(() => {
    // 重い処理
  }, 1000);

  // idをどこにも保存せず、clearIntervalも呼ばれない
}
  • 画面遷移やコンポーネント破棄後も、タイマーは生き続ける
  • そのコールバックがクロージャで大きなデータにアクセスしていると、それも永遠に解放されない

対策:

  • id をきちんと保持し、終了時に clearInterval(id) / clearTimeout(id) を呼ぶ
  • SPAフレームワーク(React/Vueなど)なら、アンマウント時に必ずcleanupを書く

クロージャが巨大オブジェクトを捕まえ続ける

function createHandler(hugeData) {
  return function () {
    // hugeData を少しだけ使う
  };
}

const handler = createHandler(veryBigArray);
button.addEventListener("click", handler);
  • veryBigArray を丸ごとクロージャで捕まえてしまうと、handler が生きている間は解放できない
  • 実際に必要な部分だけを抜き出して渡すなどの工夫が必要

Node.js におけるパフォーマンスとメモリ

ブラウザと共通する部分も多いですが、サーバならではのポイントを整理します。

Nodeの強みと弱み

  • 強み:
    • 非同期I/O(ネットワーク、ファイル)に強い
    • 多数の同時接続を軽いリソースでさばける
  • 弱み:
    • シングルスレッドでCPUバウンドな処理は苦手(大きなforループ、重い暗号化など)

やってはいけないパターン(ブロッキング)

// 悪い例:同期I/O
app.get("/data", (req, res) => {
  const text = fs.readFileSync("/big/file.txt", "utf8"); // ブロッキング
  res.send(text);
});
  • このリクエストを処理している間、同じプロセスの他のリクエスト処理もブロックされる
  • 高負荷時に一気にスループットが落ちる

対策:async/awaitで非同期処理する

app.get("/data", async (req, res) => {
  const text = await fs.promises.readFile("/big/file.txt", "utf8"); // 非同期
  res.send(text);
});

CPUバウンド処理の扱い

  • 長時間かかる計算(画像変換・動画エンコード・大規模な暗号化など)は
    • 別プロセス(child_process
    • worker_threads
    • 専門のバッチワーカーやキュー(Redis+専用プロセスなど)

に逃がす設計にする。

Node.js環境でのメモリリーク例

  • グローバルキャッシュにリクエストごとのデータを溜め続ける
  • Expressの req / res オブジェクトを、非同期処理用にどこかに保存してしまう
  • 永続的なMap/Arrayにどんどんpushし続けてclearしない

process.memoryUsage() でヒープ使用量を時系列で観察したり、ヒープダンプを取って分析するのが、本格的なリーク調査の入り口です。

パフォーマンスチューニングの「正しい進め方」

最後に、「どう進めるべきか」のプロセスをまとめます。

ステップ1:目的と指標を決める

  • ページロードを 3秒以内 にしたいのか
  • 特定APIのP95レスポンスを 200ms以下 にしたいのか
  • メモリ使用量を X MB以内 に抑えたいのか

「何を速くしたいのか」を明確化 しないと、どこまでも終わらない最適化地獄に入ります。

ステップ2:プロファイルしてボトルネックを特定

  • DevTools / Nodeのプロファイラ / 独自ログなどで、
    • どの関数がどれぐらい時間を食っているか
    • どのAPIが遅いか
    • メモリがどこで増え続けているか

を可視化する。

体感 よりも 数値 を優先する。

ステップ3:改善の優先順位

  1. アルゴリズム・データ構造
    • O(n²) → O(n log n) や O(n) に落とせないか
  2. I/Oパターン
    • 不必要なAPIアクセスやDBクエリを減らせないか
    • まとめて取れないか(N+1問題の解消など)
  3. 描画・DOM操作の最適化(ブラウザ)
    • バッチ更新・仮想リスト・画像lazy loadなど
  4. 同期処理の分割・Worker化
  5. 最後にマイクロ最適化
    • ループ内での無駄なオブジェクト生成を減らす
    • 文字列連結方法の微調整 など(影響が小さいことが多い)

80/20ルール:
全体時間の80%を食っている20%のコードを直すのが一番効く、という感覚を持っておくと良いです。

ステップ4:再計測+リグレッション防止

  • 修正前後で、同じ指標をもう一度計測
  • 「速くなったと思う」ではなく「◯%改善した」と言える状態にする
  • 重要なパフォーマンス指標は、簡易ベンチマークとしてテストに組み込むこともある
    (一定以上遅くなったらCIで警告、など)

<<前へ(コード品質とテスト)

>>次へ(セキュリティの基本)

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次