【JavaScript】高速化!パフォーマンスチューニングの裏技15選

Webサイトやアプリケーションの表示が遅い… ユーザーが離脱してしまう… そんな悩みを抱えていませんか? JavaScriptのパフォーマンスは、ユーザー体験(UX)やSEO、コンバージョン率に直結する重要な要素です。ボトルネックを解消し、コードを最適化することで、驚くほど快適な動作を実現できます。

しかし、「どこから手をつければいいかわからない」「よくある最適化は試したけど、効果が薄い」と感じている方もいるかもしれません。

この記事では、あなたのJavaScriptコードを「劇的に」高速化する可能性を秘めた、少しマニアックだけれども即効性のある「裏技」を15個、具体的なサンプルコードと共に厳選してご紹介します。明日からすぐに試せるものばかりですので、ぜひ参考にしてください。

1. DOM操作はまとめて行う(DocumentFragment)

何度もDOMにアクセスし要素を追加・変更すると、その度に再描画(リフロー・リペイント)が発生し、パフォーマンスが著しく低下します。DocumentFragment を使えば、メモリ上でDOMの変更をまとめてから、最後に一括で実際のDOMに追加できるため、再描画の回数を最小限に抑えられます。

<div id="myList"></div>
<div id="myListDirect"></div>

<script>
    const list = document.getElementById('myList');
    const fragment = document.createDocumentFragment();
    const startTime = performance.now();

    // DocumentFragment を使用
    for (let i = 0; i < 10000; i++) { // 要素数を増やして差を分かりやすく
        const li = document.createElement('li');
        li.textContent = `Item ${i}`;
        fragment.appendChild(li); // メモリ上のFragmentに追加
    }
    list.appendChild(fragment); // 1回のDOM追加で完了

    const endTime = performance.now();
    console.log(`DocumentFragment: ${endTime - startTime} ms`);

    // 比較: 直接追加
    const listDirect = document.getElementById('myListDirect');
    const startTimeDirect = performance.now();
    for (let i = 0; i < 10000; i++) {
        const li = document.createElement('li');
        li.textContent = `Item ${i}`;
        listDirect.appendChild(li); // 毎回DOMに追加
    }
    const endTimeDirect = performance.now();
    console.log(`Direct Append: ${endTimeDirect - startTimeDirect} ms`);
</script>

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

ループ内で要素のスタイルを変更した直後に、その要素のサイズや位置を取得(offsetTop, clientWidth など)しようとすると、ブラウザは正確な値を返すために強制的にレイアウト計算を実行します。これがループ内で繰り返されると「レイアウトスラッシング」となり、非常に重くなります。

対策

  • スタイル変更と値の取得を分離する(例:最初にまとめてスタイル変更、次にまとめて値を取得)。
  • ループの前に必要な値を変数にキャッシュしておく。
<div id="box"></div>
<script>
    const elements = document.querySelectorAll('.box');

    // 遅い例: 書き込みと読み取りが交互に発生
    function forceLayoutThrashing() {
        console.time('Thrashing');
        elements.forEach(el => {
            const width = el.offsetWidth; // 読み取り
            el.style.width = (width * 1.1) + 'px'; // 書き込み
        });
        console.timeEnd('Thrashing');
    }

    // 速い例: 最初にまとめて読み取り、次にまとめて書き込み
    function avoidLayoutThrashing() {
        console.time('Optimized');
        const widths = [];
        // 1. まとめて読み取り
        elements.forEach(el => {
            widths.push(el.offsetWidth);
        });
        // 2. まとめて書き込み
        elements.forEach((el, index) => {
            el.style.width = (widths[index] * 1.1) + 'px';
        });
        console.timeEnd('Optimized');
    }

    // 実行してコンソールで時間を確認
    forceLayoutThrashing();
    avoidLayoutThrashing();
</script>

3. イベントハンドラを間引く (Debounce / Throttle)

scroll, resize, mousemove といったイベントは、短時間に大量に発生します。これらのイベントハンドラ内で重い処理を行うと、ブラウザが固まってしまうことも。

  • Throttle(スロットリング):イベントが連続した場合でも、一定時間ごとに処理を実行する。(例:スクロール位置に応じたアニメーション) これらを実装するライブラリ(Lodashなど)を使うか、自作することも可能です。
  • Debounce(デバウンス):イベントが連続した場合、最後のイベントから一定時間後に処理を実行する。(例:検索候補の表示)
function throttle(func, delay) {
  let timeoutId = null;
  let lastExecTime = 0;
  return function(...args) {
    const currentTime = Date.now();
    const timeSinceLastExec = currentTime - lastExecTime;

    if (!timeoutId) {
      // 初回または前回のThrottle期間終了後
      func.apply(this, args);
      lastExecTime = currentTime;
      timeoutId = setTimeout(() => {
        timeoutId = null; // 次の実行を許可
      }, delay);
    } else if (timeSinceLastExec >= delay) {
        // Throttle期間が過ぎていれば即実行
        func.apply(this, args);
        lastExecTime = currentTime;
        clearTimeout(timeoutId); // 既存のタイマーは不要に
         timeoutId = setTimeout(() => {
            timeoutId = null;
        }, delay);
    }
    // delay期間中は新たなタイマーはセットしない(実行をスキップ)
  };
}

function handleScroll() {
  // ここにスクロール時の重い処理を書く
  console.log('Scroll event processed at:', Date.now());
}

// 250msごとに handleScroll を実行
window.addEventListener('scroll', throttle(handleScroll, 250));

4. 高コストな計算結果を記憶する(メモ化)

同じ引数で何度も呼び出される可能性のある、計算コストの高い関数(例:フィボナッチ数列、複雑なデータ変換)は、「メモ化」が有効です。一度計算した結果を引数と共にキャッシュしておき、次回同じ引数で呼び出された際にはキャッシュした結果を返すことで、計算処理をスキップします。

function memoize(fn) {
  const cache = new Map(); // Mapを使うとオブジェクトキーなども扱える
  return function(...args) {
    const key = JSON.stringify(args); // 簡単なキー生成 (注意: オブジェクト引数などでは不十分な場合あり)
    if (cache.has(key)) {
      console.log('Returning from cache for args:', args);
      return cache.get(key);
    } else {
      console.log('Calculating for args:', args);
      const result = fn.apply(this, args);
      cache.set(key, result);
      return result;
    }
  };
}

// 例: 少し時間のかかる計算
const complexCalculation = (a, b) => {
  let result = 0;
  for(let i = 0; i < 10000000; i++) { // 時間のかかる処理を模倣
      result += Math.sqrt(i);
  }
  return a + b + Math.round(result / 10000000); // 計算結果に意味はない
};

const memoizedCalc = memoize(complexCalculation);

console.time('First call');
console.log(memoizedCalc(5, 10));
console.timeEnd('First call');

console.time('Second call (cached)');
console.log(memoizedCalc(5, 10)); // キャッシュから返される
console.timeEnd('Second call (cached)');

console.time('Third call (different args)');
console.log(memoizedCalc(6, 12)); // 新しい引数なので計算される
console.timeEnd('Third call (different args)');

5. 適切なデータ構造を選ぶ(Map/Set の活用)

大量のデータの中から特定の要素を検索したり、ユニークな値を管理したりする場合、Arrayfind, includes, filter などをループで使うよりも、MapSet を使う方が高速な場合があります。特に要素数が多い場合に効果を発揮します。

  • Set:ユニークな値を効率的に管理(存在チェックが高速)。
  • Map:キーと値のペアを効率的に管理・検索。
const dataSize = 1000000;
const arrayData = Array.from({ length: dataSize }, (_, i) => `item-${i}`);
const setData = new Set(arrayData);
const target = `item-${dataSize - 1}`; // 探す要素 (最後尾)

// 配列での存在チェック
console.time('Array.includes');
const arrayIncludesResult = arrayData.includes(target);
console.timeEnd('Array.includes');
console.log('Array includes result:', arrayIncludesResult);

// Setでの存在チェック
console.time('Set.has');
const setHasResult = setData.has(target);
console.timeEnd('Set.has');
console.log('Set has result:', setHasResult);

// Map はキーによる検索が高速
const mapData = new Map(arrayData.map((item, i) => [item, { value: i }]));
const mapTargetKey = `item-${dataSize - 1}`;

console.time('Map.has');
const mapHasResult = mapData.has(mapTargetKey);
console.timeEnd('Map.has');
console.log('Map has result:', mapHasResult);

console.time('Map.get');
const mapGetResult = mapData.get(mapTargetKey);
console.timeEnd('Map.get');
// console.log('Map get result:', mapGetResult); // { value: 999999 }

6. アニメーションには requestAnimationFrame

setTimeoutsetInterval でアニメーションを実装すると、フレームレートと同期せず、カクつきの原因になることがあります。requestAnimationFrame はブラウザの描画タイミングに合わせてコールバック関数を実行するため、よりスムーズで効率的なアニメーションを実現できます。

const element = document.getElementById('animateMe');
let start;
let progress = 0;

function step(timestamp) {
  if (start === undefined) {
    start = timestamp;
  }
  const elapsed = timestamp - start;

  // 例: 2秒かけて左に100px移動するアニメーション
  progress = Math.min(elapsed / 2000, 1); // 0から1へ
  element.style.transform = `translateX(${progress * 100}px)`;

  if (progress < 1) {
    // アニメーションが完了していなければ、次のフレームを要求
    requestAnimationFrame(step);
  } else {
      console.log('Animation finished');
  }
}

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

// 比較: setInterval (カクつく可能性がある)
/*
let progressInterval = 0;
const intervalId = setInterval(() => {
    progressInterval += 16 / 2000; // 60fps想定で進捗を計算 (不正確)
    if(progressInterval > 1) {
        progressInterval = 1;
        clearInterval(intervalId);
         console.log('Interval animation finished');
    }
    element.style.transform = `translateX(${progressInterval * 100}px)`;
}, 16); // 約60fpsを狙うが保証されない
*/

(注意:animateMe というIDを持つ要素がHTMLに必要です)

7. 重い処理はバックグラウンドへ(Web Workers)

メインスレッドで時間のかかる計算やデータ処理を行うと、UIがフリーズしてしまいます。Web Workers を使えば、これらの処理をバックグラウンドのスレッドで実行できるため、メインスレッドはUI操作への応答性を保つことができます。

main.js

const worker = new Worker('heavy_worker.js'); // Workerスクリプトを指定

const dataToSend = { count: 100000000 };

console.log('Sending data to worker:', dataToSend);
worker.postMessage(dataToSend); // Workerにデータを送信

worker.onmessage = function(e) {
  console.log('Message received from worker:', e.data);
  // ここでUI更新などを行う
};

worker.onerror = function(error) {
  console.error('Error from worker:', error);
};

// メインスレッドは他の処理を続けられる
console.log('Main thread continues...');

heavy_worker.js

self.onmessage = function(e) {
  console.log('Worker received data:', e.data);
  const count = e.data.count;
  let result = 0;

  // 重い計算処理の例
  console.log('Worker starting heavy calculation...');
  for (let i = 0; i < count; i++) {
    result += Math.sqrt(i);
  }
  console.log('Worker finished calculation.');

  // 結果をメインスレッドに送信
  self.postMessage({ result: result });
};

8. コード分割で初期ロードを高速化(Dynamic Imports)

アプリケーションの全コードを最初にまとめて読み込むと、初期表示が遅くなります。import() を使ったダイナミックインポート(動的インポート)を利用し、必要なコードを必要なタイミングで読み込む(コード分割)ことで、初期ロードに必要なファイルサイズを削減し、表示開始時間を短縮できます。WebpackやViteなどのモダンなバンドラはこれをサポートしています。

const loadButton = document.getElementById('loadModuleBtn');

loadButton.addEventListener('click', async () => {
  try {
    // './utils.js' をクリック時に動的に読み込む
    const utils = await import('./utils.js');
    console.log('Module loaded:', utils);
    utils.someFunction(); // 読み込んだモジュールの関数を実行
    loadButton.textContent = 'Module Loaded!';
    loadButton.disabled = true;
  } catch (error) {
    console.error('Failed to load module:', error);
  }
});

utils.js(読み込まれるモジュール例)

export function someFunction() {
  console.log('This function is from the dynamically imported module!');
}

export const someData = { value: 123 };

console.log('utils.js module evaluated'); // 読み込まれた時にコンソールに出力

(注意:loadModuleBtn というIDを持つボタンがHTMLに必要です。また、import() は通常、Webpack, Vite, Parcel などのモジュールバンドラ環境で適切に動作します)

9. 不要なコードを削除する (Tree Shaking)

ライブラリ全体をインポートしても、実際に使っているのは一部の機能だけ、というケースはよくあります。WebpackやRollupなどのバンドラが提供する Tree Shaking 機能は、実際に使われていないコード(デッドコード)をビルド時に検出し、最終的なバンドルファイルから削除してくれます。これにより、ファイルサイズを削減できます。

良い例(Tree Shakingが効きやすい)

// my-library.js
export function functionA() { console.log('A'); }
export function functionB() { console.log('B'); }

// main.js
import { functionA } from './my-library.js'; // functionA のみインポート
functionA();
// ビルドツールは functionB が未使用だと判断し、最終的なバンドルから削除できる

悪い例(Tree Shakingが効きにくい)

// my-library.js
const library = {
  functionA: () => { console.log('A'); },
  functionB: () => { console.log('B'); }
};
export default library;

// main.js
import lib from './my-library.js'; // ライブラリ全体をインポート
lib.functionA();
// ビルドツールは lib.functionB が使われる可能性があると判断し、削除できない場合がある

(Webpack, Rollup, Parcel などの mode: 'production' 設定で有効になります)

10. ループは賢く中断する

配列を検索して最初の該当要素を見つけたい場合、forEachfilter ですべての要素をチェックするのは無駄です。for...of ループや find, findIndex, some などを使い、条件に合致した時点で breakreturn でループを中断しましょう。

const users = Array.from({length: 1000}, (_, i) => ({ id: i + 1, name: `User ${i + 1}` }));
const targetId = 500;

// find (推奨)
console.time('find');
const foundUserFind = users.find(user => user.id === targetId);
console.timeEnd('find');
// console.log(foundUserFind);

// for...of + break
console.time('for...of + break');
let foundUserForOf;
for (const user of users) {
  if (user.id === targetId) {
    foundUserForOf = user;
    break; // 見つかったら抜ける
  }
}
console.timeEnd('for...of + break');
// console.log(foundUserForOf);

// filter (非効率: 全てのスキャンが必要)
console.time('filter');
const foundUserFilter = users.filter(user => user.id === targetId)[0];
console.timeEnd('filter');
// console.log(foundUserFilter);

// forEach (非効率: 中断できない)
console.time('forEach');
let foundUserForEach;
users.forEach(user => {
    if(!foundUserForEach && user.id === targetId) { // 見つかってもループは続く
        foundUserForEach = user;
    }
});
console.timeEnd('forEach');
// console.log(foundUserForEach);

11. 画像やコンポーネントを遅延読み込み (Lazy Loading)

ページの初期表示時に、画面に見えていない画像やコンポーネントまで読み込むのは非効率です。Intersection Observer API やライブラリを活用し、要素がビューポート内に入ったタイミングで読み込む「遅延読み込み」を実装することで、初期ロード時間を大幅に改善できます。HTMLの img タグや iframe タグには loading="lazy" 属性を指定するだけでも効果があります。

<img src="image1.jpg" alt="Lazy loaded image" loading="lazy" width="300" height="200">
<img src="image2.jpg" alt="Another lazy image" loading="lazy" width="300" height="200">

<div style="height: 2000px;">Scroll down...</div>

<img src="image3.jpg" alt="Yet another lazy image" loading="lazy" width="300" height="200">

(JavaScript で Intersection Observer を使うと、画像以外(コンポーネント初期化など)も遅延実行できます)

12. オブジェクトのプロパティアクセスを最適化

ループ内でネストが深いオブジェクトのプロパティに繰り返しアクセスすると、僅かですがオーバーヘッドが発生します。ループの外でアクセスしたいプロパティを変数にキャッシュするか、より浅い階層で保持するようにデータ構造を見直すことで、パフォーマンスが向上する場合があります。

const deepData = Array.from({length: 1000}, (_, i) => ({
    config: { settings: { user: { profile: { name: `User ${i}` } } } }
}));

// 遅い可能性
console.time('Deep Access');
let namesSlow = [];
for (let i = 0; i < deepData.length; i++) {
  namesSlow.push(deepData[i].config.settings.user.profile.name);
}
console.timeEnd('Deep Access');

// 速い可能性 (プロパティをキャッシュ)
console.time('Cached Access');
let namesFast = [];
for (let i = 0; i < deepData.length; i++) {
  const userName = deepData[i].config.settings.user.profile.name; // ループ内でアクセスする前にキャッシュ
  namesFast.push(userName);
}
console.timeEnd('Cached Access');

(この最適化の効果は環境やネストの深さによります)

13. スクロールイベントのパッシブ化(passive: true)

スクロールやタッチイベントのリスナー内で event.preventDefault() を呼び出す可能性がある場合、ブラウザは処理が完了するまでスクロールを開始できません。もし preventDefault() を使わないのであれば、イベントリスナー登録時に { passive: true } オプションを指定することで、ブラウザは処理の完了を待たずにスクロールを開始でき、スクロールがスムーズになります。

const scrollContainer = document.getElementById('scrollable'); // スクロール可能な要素

function myScrollHandler(event) {
  // この関数内で event.preventDefault() を呼ばない場合
  console.log('Scrolling...');
}

// passive: true を指定
scrollContainer.addEventListener('scroll', myScrollHandler, { passive: true });

// 指定しない場合 (デフォルトは false の場合が多い)
// scrollContainer.addEventListener('scroll', myScrollHandler);
// or
// scrollContainer.addEventListener('scroll', myScrollHandler, { passive: false });

(注意:scrollable というIDを持つスクロール可能な要素がHTMLに必要です)

14. 巨大なインラインSVGやJSONを避ける

HTML内に直接巨大なSVGコードやJSONデータを埋め込むと、HTMLファイルのパースとレンダリングに時間がかかります。可能な限り、これらは外部ファイル(.svg, .json)として保存し、必要に応じて Workspace などで非同期に読み込むようにしましょう。

// HTML内に巨大なJSONを書く代わりに...
// <script> const hugeData = { /* ... 巨大なJSON ... */ }; </script>

// JavaScriptで外部ファイルを読み込む
async function loadConfig() {
  try {
    const response = await fetch('./config.json'); // 外部JSONファイルを指定
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const configData = await response.json();
    console.log('Config loaded:', configData);
    // configData を使った処理
  } catch (error) {
    console.error('Failed to load config:', error);
  }
}

loadConfig();

config.json (外部ファイル)

{
  "apiKey": "12345abcdef",
  "featureFlags": {
    "newUI": true,
    "betaFeature": false
  },
  "theme": "dark"
}

(SVGの場合も同様に Workspace でテキストとして読み込み、innerHTML などで挿入できます)

15. 計測なくして最適化なし!(プロファイリング)

これが最も重要な「裏技」かもしれません。パフォーマンスの問題を推測で判断せず、必ず開発者ツールのプロファイラ(Performanceタブなど)を使ってボトルネックを特定しましょう。 どこが遅いのかを正確に把握しなければ、的外れな最適化に時間を浪費してしまう可能性があります。計測に基づいた改善こそが、効果的なパフォーマンスチューニングの鍵です。

function somePotentiallySlowFunction() {
  console.time('myFunctionTimer'); // タイマー開始

  // ... 時間がかかる可能性のある処理 ...
  let sum = 0;
  for (let i = 0; i < 10000000; i++) {
    sum += Math.sqrt(i);
  }
  console.log('Calculation done.');
  // ...

  console.timeEnd('myFunctionTimer'); // タイマー終了、経過時間を表示
}

somePotentiallySlowFunction();

// 重要: これは簡易計測です。
// Chrome DevTools の "Performance" タブなどでプロファイリングし、
// CPU使用率、実行時間、ボトルネック関数を特定することが推奨されます。

まとめ

いかがでしたか? JavaScriptのパフォーマンスチューニングは、地道な作業に見えるかもしれませんが、一つ一つの改善が積み重なることで、ユーザー体験に大きな差を生み出します。

今回ご紹介した15の「裏技」とサンプルコードは、様々な状況で役立つ可能性があります。ぜひあなたのプロジェクトでボトルネックとなっている箇所を見つけ、これらのテクニックを試してみてください。そして、改善による速度向上を実感していただければ幸いです。