T-CREATOR

Vue.js でメモリリーク?watch/effect/イベント登録の落とし穴と検知法

Vue.js でメモリリーク?watch/effect/イベント登録の落とし穴と検知法

Vue.js を使った開発では、リアクティブシステムの恩恵を受けながらも、実は気づかないうちにメモリリークを引き起こしているケースが少なくありません。特に watchwatchEffect、そして DOM イベントリスナーの登録周りは、適切なクリーンアップ処理を忘れてしまうと、アプリケーションのパフォーマンスが徐々に悪化していく原因となります。

この記事では、Vue.js におけるメモリリークの典型的なパターンと、その検知方法、そして具体的な対策について、実践的なコード例を交えながら詳しく解説していきます。

背景

メモリリークとは何か

メモリリークとは、アプリケーションが使用したメモリ領域を適切に解放せず、不要になったデータが残り続ける現象を指します。JavaScript のようなガベージコレクションを持つ言語でも、参照が残り続けることでメモリが解放されず、結果的にメモリ使用量が増加し続けることがあるのです。

Web アプリケーションにおいては、ページ遷移やコンポーネントの mount/unmount が頻繁に発生しますよね。この際、適切にリソースがクリーンアップされないと、見えない形でメモリが蓄積されていきます。

Vue.js のリアクティブシステムとメモリ管理

Vue.js は強力なリアクティブシステムを持っており、データの変更を自動的に検知して UI を更新してくれます。しかし、この便利さの裏側では、以下のような仕組みが動いています。

mermaidflowchart TB
    reactive["リアクティブオブジェクト"] -->|変更検知| dep["依存関係トラッカー<br/>(Dep)"]
    dep -->|通知| watcher1["Watcher 1"]
    dep -->|通知| watcher2["Watcher 2"]
    dep -->|通知| watcher3["Watcher N"]
    watcher1 -->|コールバック実行| effect1["副作用関数"]
    watcher2 -->|コールバック実行| effect2["副作用関数"]
    watcher3 -->|コールバック実行| effect3["副作用関数"]

図で理解できる要点:

  • リアクティブオブジェクトは依存関係トラッカーを持つ
  • 各 Watcher が依存関係に登録される
  • データ変更時に全ての Watcher に通知が行われる

この仕組みにより、データと UI が同期されます。しかし、コンポーネントが破棄された後も Watcher が残り続けると、不要な参照が保持され続けることになるのです。

一般的なメモリリーク発生のメカニズム

Vue.js アプリケーションでメモリリークが発生する主なシナリオは以下の通りです。

#シナリオ発生原因影響度
1watch の解除忘れwatchEffectwatch の戻り値を使わない★★★
2イベントリスナーの解除忘れaddEventListenerremoveEventListener 忘れ★★★
3タイマーの解除忘れsetInterval / setTimeout のクリア忘れ★★☆
4グローバル状態への参照Store や EventBus への永続的な参照★★☆
5クロージャによる参照保持コールバック内での大きなオブジェクト参照★☆☆

これらのパターンは、開発中には気づきにくく、本番環境で長時間稼働して初めて問題が顕在化することも珍しくありません。

課題

watch と watchEffect でのメモリリーク

Vue 3 の Composition API では、watchwatchEffect を使ってリアクティブなデータの変更を監視できます。しかし、これらは内部的に Watcher インスタンスを作成し、リアクティブシステムに登録されるため、適切に停止しないと参照が残り続けてしまうのです。

以下は典型的な問題のあるコードです。

typescriptimport { ref, watchEffect } from 'vue';

export default {
  setup() {
    const count = ref(0);

    // この watchEffect は停止されない
    watchEffect(() => {
      console.log(`count is now: ${count.value}`);
    });

    // コンポーネントが破棄されても watchEffect は動き続ける
  },
};

上記のコードは一見問題なさそうですが、Composition API の setup() 関数内で作成された watchEffect は、通常コンポーネントのライフサイクルと自動的に紐づけられます。しかし、以下のような場合は自動的にクリーンアップされません。

問題が発生するケース

#ケース詳細
1非同期処理後の watch 作成setTimeoutfetch のコールバック内での watch 作成
2条件付きの watch 作成if 文などの条件分岐内での watch 作成
3ループ内での watch 作成v-for や配列の forEach 内での watch 作成
4setup 外での watch 作成グローバルスコープやユーティリティ関数内での watch 作成

イベントリスナーの登録と解除

DOM イベントリスナーは、Vue のリアクティブシステムの外側で動作するため、より明示的な管理が必要です。addEventListener で登録したイベントは、必ず removeEventListener で解除しなければなりません。

typescriptimport { onMounted } from 'vue';

export default {
  setup() {
    // 問題のあるコード
    onMounted(() => {
      window.addEventListener('resize', handleResize);
      // removeEventListener が呼ばれない!
    });

    const handleResize = () => {
      console.log('Window resized');
    };
  },
};

上記のコードでは、コンポーネントが破棄された後も handleResize 関数への参照が残り続けます。特に、イベントハンドラーが大きなオブジェクトやクロージャを参照している場合、メモリリークの影響は深刻になるでしょう。

タイマー関数の管理

setIntervalsetTimeout も同様の問題を引き起こします。

typescriptimport { onMounted } from 'vue';

export default {
  setup() {
    onMounted(() => {
      // 5秒ごとにデータを取得
      setInterval(() => {
        fetchData();
      }, 5000);
      // clearInterval が呼ばれない!
    });
  },
};

このコードは、コンポーネントが破棄された後も 5 秒ごとに fetchData() を実行し続けてしまいます。

グローバル EventBus パターンの罠

Vue 2 の時代によく使われていた EventBus パターンも、メモリリークの温床となります。

typescript// eventBus.ts
import { ref } from 'vue';

export const eventBus = ref<Map<string, Function[]>>(
  new Map()
);

export function emit(event: string, data: any) {
  const handlers = eventBus.value.get(event);
  handlers?.forEach((handler) => handler(data));
}

export function on(event: string, handler: Function) {
  const handlers = eventBus.value.get(event) || [];
  handlers.push(handler);
  eventBus.value.set(event, handlers);
  // off する仕組みがない、または使われていない
}

このような実装では、コンポーネントが破棄されてもイベントハンドラーが EventBus に残り続けてしまいます。

以下の図は、メモリリークが発生する流れを示しています。

mermaidsequenceDiagram
    participant C as コンポーネント
    participant W as Watcher/Listener
    participant M as メモリ

    C->>W: watch/effect/listener 登録
    W->>M: 参照を保持
    Note over C,M: コンポーネント使用中

    C->>C: コンポーネント破棄
    Note over W,M: しかし参照は残る
    W->>M: まだ参照を保持中
    Note over W,M: メモリリーク発生!

    rect rgb(255, 200, 200)
    Note over W,M: クリーンアップされない<br/>Watcher や Listener が<br/>メモリに残り続ける
    end

図で理解できる要点:

  • コンポーネント破棄後も Watcher やリスナーが残る
  • メモリ上の参照が解放されない
  • 時間とともにメモリ使用量が増加する

解決策

watch と watchEffect の適切な停止

watchwatchEffect は、呼び出し時に停止関数を返します。この関数を適切に呼び出すことで、メモリリークを防ぐことができるのです。

基本的な停止パターン

typescriptimport { ref, watchEffect, onUnmounted } from 'vue';

上記は、必要なインポート文です。onUnmounted はコンポーネントが破棄される際に実行されるライフサイクルフックになります。

typescriptexport default {
  setup() {
    const count = ref(0)

    // watchEffect は停止関数を返す
    const stopWatch = watchEffect(() => {
      console.log(`count is now: ${count.value}`)
    })

watchEffect の戻り値として停止関数が返されます。この関数を変数に保存しておくことで、後で明示的に停止できるようになるのです。

typescript    // コンポーネント破棄時に停止
    onUnmounted(() => {
      stopWatch()
    })
  }
}

onUnmounted フックで停止関数を呼び出すことにより、コンポーネントが破棄される際に確実に watch を停止できます。

条件付き watch の安全な実装

条件分岐内で watch を作成する場合は、以下のように実装します。

typescriptimport { ref, watchEffect, onUnmounted } from 'vue'

export default {
  setup() {
    const isEnabled = ref(true)
    const data = ref(null)
    let stopWatch: (() => void) | null = null

停止関数を保存するための変数を宣言します。型は (() => void) | null とし、初期値は null です。

typescript// 条件によって watch を開始
if (isEnabled.value) {
  stopWatch = watchEffect(() => {
    console.log('Data:', data.value);
  });
}

条件が満たされた場合のみ watch を作成し、停止関数を保存しておきます。

typescript    // クリーンアップ
    onUnmounted(() => {
      if (stopWatch) {
        stopWatch()
      }
    })
  }
}

onUnmounted で、停止関数が存在する場合のみ呼び出すようにします。

非同期処理後の watch 作成

非同期処理のコールバック内で watch を作成する場合は、コンポーネントが既に破棄されている可能性を考慮する必要があります。

typescriptimport { ref, watchEffect, onUnmounted, getCurrentInstance } from 'vue'

export default {
  setup() {
    const data = ref(null)
    const stopWatchers: Array<() => void> = []

複数の停止関数を管理するための配列を用意します。

typescript// 現在のインスタンスを取得
const instance = getCurrentInstance();

setTimeout(() => {
  // コンポーネントがまだマウントされているかチェック
  if (instance && !instance.isUnmounted) {
    const stopWatch = watchEffect(() => {
      console.log('Delayed watch:', data.value);
    });
    stopWatchers.push(stopWatch);
  }
}, 1000);

getCurrentInstance() を使ってコンポーネントインスタンスを取得し、isUnmounted プロパティで破棄済みかどうかを確認します。これにより、非同期処理が完了した時点でコンポーネントが既に破棄されている場合は、watch を作成しないようにできるのです。

typescript    // すべての watch を停止
    onUnmounted(() => {
      stopWatchers.forEach(stop => stop())
    })
  }
}

配列に保存した全ての停止関数を実行することで、確実にクリーンアップが行われます。

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

イベントリスナーは、登録と解除をペアで行う必要があります。Vue 3 では、これを簡潔に実装できるパターンがいくつかあります。

基本的なイベントリスナーの管理

typescriptimport { onMounted, onUnmounted } from 'vue'

export default {
  setup() {
    // イベントハンドラーを関数として定義
    const handleResize = () => {
      console.log('Window resized')
    }

イベントハンドラー関数を定義します。関数を変数に代入することで、同じ関数の参照を addEventListenerremoveEventListener の両方で使用できるようになります。

typescript// マウント時に登録
onMounted(() => {
  window.addEventListener('resize', handleResize);
});

コンポーネントがマウントされた際にイベントリスナーを登録します。

typescript    // アンマウント時に解除
    onUnmounted(() => {
      window.removeEventListener('resize', handleResize)
    })
  }
}

コンポーネントがアンマウントされる際に、同じ関数の参照を使ってイベントリスナーを解除します。これにより、メモリリークを防げるのです。

複数のイベントリスナーを管理する Composable

複数のイベントリスナーを扱う場合は、Composable として抽象化すると便利です。

typescript// useEventListener.ts
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(
  target: Window | HTMLElement,
  event: string,
  handler: EventListener
) {

この Composable は、イベントリスナーの登録と解除を自動化してくれます。引数として、対象要素、イベント名、ハンドラー関数を受け取ります。

typescript  onMounted(() => {
    target.addEventListener(event, handler)
  })

  onUnmounted(() => {
    target.removeEventListener(event, handler)
  })
}

onMountedonUnmounted でイベントの登録と解除を行うことで、呼び出し側では意識せずともクリーンアップが行われるようになります。

使用例

typescriptimport { useEventListener } from './useEventListener';

export default {
  setup() {
    // シンプルな使い方
    useEventListener(window, 'resize', () => {
      console.log('Window resized');
    });

    // 複数のイベントも簡単に管理
    useEventListener(window, 'scroll', () => {
      console.log('Window scrolled');
    });
  },
};

この Composable を使うことで、イベントリスナーの解除を忘れる心配がなくなります。

タイマーの適切な管理

setIntervalsetTimeout も、同様にクリーンアップが必要です。

setInterval の管理

typescriptimport { onMounted, onUnmounted } from 'vue'

export default {
  setup() {
    let intervalId: number | null = null

タイマー ID を保存するための変数を宣言します。

typescriptonMounted(() => {
  // 5秒ごとにデータを取得
  intervalId = setInterval(() => {
    fetchData();
  }, 5000);
});

setInterval の戻り値であるタイマー ID を保存しておきます。

typescript    onUnmounted(() => {
      // タイマーをクリア
      if (intervalId !== null) {
        clearInterval(intervalId)
      }
    })
  }
}

コンポーネントが破棄される際に、保存しておいたタイマー ID を使って clearInterval を呼び出すことで、タイマーを停止できます。

タイマー管理用 Composable

タイマーも Composable として抽象化できます。

typescript// useInterval.ts
import { onUnmounted } from 'vue';

export function useInterval(
  callback: () => void,
  delay: number
) {
  const intervalId = setInterval(callback, delay);

  // 自動的にクリーンアップ
  onUnmounted(() => {
    clearInterval(intervalId);
  });

  // 手動で停止したい場合のために停止関数を返す
  return () => clearInterval(intervalId);
}

この Composable は、タイマーの開始と自動クリーンアップを行いつつ、必要に応じて手動で停止できる関数も返してくれます。

使用例

typescriptimport { useInterval } from './useInterval';

export default {
  setup() {
    // 自動的にクリーンアップされる
    useInterval(() => {
      console.log('Polling...');
    }, 5000);
  },
};

とてもシンプルに、安全にタイマーを使うことができますね。

グローバル状態管理でのメモリリーク対策

グローバル状態(Store や EventBus)を使う場合も、適切なクリーンアップが必要です。

Pinia を使った安全な状態管理

typescript// stores/user.ts
import { defineStore } from 'pinia';

Pinia は Vue 公式の状態管理ライブラリです。まず、defineStore をインポートします。

typescriptexport const useUserStore = defineStore('user', {
  state: () => ({
    users: [] as User[],
    subscribers: [] as Function[]
  }),

Store の状態として、ユーザーデータと購読者のコールバック関数を管理します。

typescript  actions: {
    subscribe(callback: Function) {
      this.subscribers.push(callback)

      // 購読解除関数を返す
      return () => {
        const index = this.subscribers.indexOf(callback)
        if (index > -1) {
          this.subscribers.splice(index, 1)
        }
      }
    }
  }
})

subscribe メソッドは購読解除関数を返すため、呼び出し側で確実にクリーンアップできます。

コンポーネントでの使用例

typescriptimport { onUnmounted } from 'vue'
import { useUserStore } from '@/stores/user'

export default {
  setup() {
    const userStore = useUserStore()

    // 購読と同時に解除関数を取得
    const unsubscribe = userStore.subscribe((users) => {
      console.log('Users updated:', users)
    })

購読時に解除関数を受け取ることで、後でクリーンアップできるようにします。

typescript    // コンポーネント破棄時に購読解除
    onUnmounted(() => {
      unsubscribe()
    })
  }
}

このパターンにより、グローバル状態を安全に使用できるのです。

以下の図は、適切なクリーンアップが行われた場合のフローを示します。

mermaidflowchart TB
    mount["コンポーネント<br/>マウント"] --> register["watch/effect/<br/>listener 登録"]
    register --> store["停止関数を<br/>保存"]
    store --> use["コンポーネント<br/>使用中"]
    use --> unmount["コンポーネント<br/>破棄"]
    unmount --> cleanup["onUnmounted<br/>実行"]
    cleanup --> stop["停止関数<br/>呼び出し"]
    stop --> release["メモリ解放"]

    style release fill:#90EE90
    style cleanup fill:#87CEEB
    style stop fill:#87CEEB

図で理解できる要点:

  • マウント時に登録、アンマウント時に解除のペア
  • 停止関数を確実に保存しておく
  • onUnmounted でクリーンアップを実行
  • メモリが適切に解放される

具体例

メモリリークを検知する方法

メモリリークが発生しているかどうかを確認するには、Chrome DevTools の Memory プロファイラーが非常に有効です。

Chrome DevTools での検知手順

  1. Chrome DevTools を開く(F12 または Cmd+Option+I)
  2. Memory タブを選択
  3. Heap snapshot を選択
  4. 「Take snapshot」をクリックして初期状態を記録
  5. アプリケーションを操作(ページ遷移、コンポーネントの表示/非表示など)
  6. 再度「Take snapshot」をクリック
  7. 2 つのスナップショットを比較

メモリリーク判定の目安

#指標正常メモリリーク疑い
1Heap サイズ安定または微増継続的に増加
2Detached DOM0 または少数多数存在
3イベントリスナー数安定増加し続ける
4オブジェクト数安定増加し続ける

コンソールでの簡易チェック

以下のコードを Console に貼り付けて実行することで、現在のメモリ使用量を確認できます。

javascript// メモリ使用量を確認
if (performance.memory) {
  console.log(
    'Used JS Heap:',
    (performance.memory.usedJSHeapSize / 1048576).toFixed(
      2
    ) + ' MB'
  );
  console.log(
    'Total JS Heap:',
    (performance.memory.totalJSHeapSize / 1048576).toFixed(
      2
    ) + ' MB'
  );
}

このコードは、現在の JavaScript ヒープメモリの使用量と合計量を MB 単位で表示します。performance.memory は Chrome でのみ利用可能ですので、注意してください。

javascript// イベントリスナーの数を確認
const listeners = getEventListeners(window);
console.log('Window event listeners:', listeners);

getEventListeners() は Chrome DevTools Console でのみ使える関数で、指定した要素に登録されているイベントリスナーの一覧を取得できます。

実践的なデバッグ例

実際のアプリケーションでメモリリークをデバッグする具体例を見ていきましょう。

問題のあるコンポーネント

typescript// UserList.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'

const users = ref<User[]>([])
let intervalId: number

onMounted(() => {
  // 問題1: setInterval のクリアがない
  intervalId = setInterval(() => {
    fetchUsers()
  }, 3000)

  // 問題2: イベントリスナーの解除がない
  window.addEventListener('resize', handleResize)
})

このコードには、タイマーとイベントリスナーのクリーンアップがありません。

typescript// 問題3: watch の停止がない
watch(users, (newUsers) => {
  // 重い処理
  processUsers(newUsers)
})

const handleResize = () => {
  console.log('Resized')
}

async function fetchUsers() {
  // API呼び出し
  const response = await fetch('/api/users')
  users.value = await response.json()
}
</script>

watch も停止されていないため、コンポーネント破棄後も動作し続ける可能性があります。

修正後のコンポーネント

それでは、上記の問題を修正していきましょう。

typescript// UserList.vue (修正版)
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'

const users = ref<User[]>([])
let intervalId: number | null = null
let stopWatch: (() => void) | null = null

停止関数を保存するための変数を宣言します。

typescriptonMounted(() => {
  // タイマーIDを保存
  intervalId = setInterval(() => {
    fetchUsers();
  }, 3000);

  // イベントリスナーを登録
  window.addEventListener('resize', handleResize);
});

マウント時の処理です。タイマー ID を保存しておくことを忘れないでください。

typescript// watch の停止関数を保存
stopWatch = watch(users, (newUsers) => {
  processUsers(newUsers);
});

watch の戻り値である停止関数を保存します。

typescript// クリーンアップ処理
onUnmounted(() => {
  // タイマーを停止
  if (intervalId !== null) {
    clearInterval(intervalId);
  }

  // イベントリスナーを解除
  window.removeEventListener('resize', handleResize);

  // watch を停止
  if (stopWatch) {
    stopWatch();
  }
});

onUnmounted で全てのリソースをクリーンアップします。これにより、メモリリークを防げるようになりました。

typescriptconst handleResize = () => {
  console.log('Resized')
}

async function fetchUsers() {
  const response = await fetch('/api/users')
  users.value = await response.json()
}
</script>

その他の関数は変更なしです。

汎用的なクリーンアップ用 Composable

複数のクリーンアップ処理を統一的に管理するための Composable を作成してみましょう。

typescript// useCleanup.ts
import { onUnmounted } from 'vue'

export function useCleanup() {
  // クリーンアップ関数を保存する配列
  const cleanupFunctions: Array<() => void> = []

複数のクリーンアップ関数を管理するための配列を用意します。

typescript// クリーンアップ関数を登録
function addCleanup(cleanup: () => void) {
  cleanupFunctions.push(cleanup);
}

この関数を使って、クリーンアップ処理を登録していきます。

typescript// コンポーネント破棄時に全て実行
onUnmounted(() => {
  cleanupFunctions.forEach((cleanup) => cleanup());
});

onUnmounted で、登録された全てのクリーンアップ関数を実行します。

typescript  return {
    addCleanup
  }
}

外部から使えるように、addCleanup 関数を返します。

使用例

typescriptimport { useCleanup } from './useCleanup'
import { ref, watchEffect } from 'vue'

export default {
  setup() {
    const { addCleanup } = useCleanup()
    const count = ref(0)

useCleanup を呼び出して、addCleanup 関数を取得します。

typescript// watch の停止関数を登録
const stopWatch = watchEffect(() => {
  console.log('Count:', count.value);
});
addCleanup(stopWatch);

watch の停止関数を addCleanup に登録することで、自動的にクリーンアップされるようになります。

typescript// タイマーの停止関数を登録
const intervalId = setInterval(() => {
  count.value++;
}, 1000);
addCleanup(() => clearInterval(intervalId));

タイマーのクリーンアップ処理も登録できます。

typescript    // イベントリスナーの解除関数を登録
    const handleClick = () => console.log('Clicked')
    document.addEventListener('click', handleClick)
    addCleanup(() => {
      document.removeEventListener('click', handleClick)
    })

    // onUnmounted で自動的に全て実行される
  }
}

この Composable を使うことで、様々なクリーンアップ処理を一元管理できるようになります。

パフォーマンス監視の自動化

開発中にメモリリークを早期発見するため、パフォーマンス監視を自動化することも有効です。

typescript// useMemoryMonitor.ts
import { onMounted, onUnmounted } from 'vue'

export function useMemoryMonitor(componentName: string) {
  let intervalId: number | null = null
  const memorySnapshots: number[] = []

メモリスナップショットを保存する配列を用意します。

typescriptonMounted(() => {
  // 開発環境でのみ監視
  if (process.env.NODE_ENV === 'development') {
    console.log(`[${componentName}] メモリ監視開始`);

    intervalId = setInterval(() => {
      if (performance.memory) {
        const used =
          performance.memory.usedJSHeapSize / 1048576;
        memorySnapshots.push(used);

        // 過去5回の平均と比較
        if (memorySnapshots.length > 5) {
          const recent = memorySnapshots.slice(-5);
          const average =
            recent.reduce((a, b) => a + b) / 5;
          const growth = ((used - average) / average) * 100;

          // 10%以上増加していたら警告
          if (growth > 10) {
            console.warn(
              `[${componentName}] メモリ増加検知: ${growth.toFixed(
                2
              )}%`
            );
          }
        }
      }
    }, 5000);
  }
});

5 秒ごとにメモリ使用量をチェックし、急激な増加を検知した場合に警告を出力します。

typescript  onUnmounted(() => {
    if (intervalId !== null) {
      clearInterval(intervalId)
      console.log(`[${componentName}] メモリ監視終了`)
    }
  })
}

コンポーネント破棄時に監視を停止します。

使用例

typescriptimport { useMemoryMonitor } from './useMemoryMonitor';

export default {
  setup() {
    // コンポーネント名を指定して監視開始
    useMemoryMonitor('UserList');

    // 通常のコンポーネントロジック
  },
};

開発中にこの Composable を使うことで、メモリリークの兆候を早期に発見できるようになります。

以下の図は、メモリリークのデバッグフローを示しています。

mermaidflowchart TD
    start["メモリリーク<br/>疑いあり"] --> snapshot1["初期スナップ<br/>ショット取得"]
    snapshot1 --> operation["アプリケーション<br/>操作実施"]
    operation --> snapshot2["2回目スナップ<br/>ショット取得"]
    snapshot2 --> compare["2つのスナップ<br/>ショット比較"]

    compare --> check_heap{"Heapサイズ<br/>増加?"}
    check_heap -->|はい| check_detached["Detached DOM<br/>確認"]
    check_heap -->|いいえ| normal["正常"]

    check_detached --> check_listeners["イベントリスナー<br/>数確認"]
    check_listeners --> identify["原因特定"]
    identify --> fix["クリーンアップ<br/>処理追加"]
    fix --> verify["再テスト"]
    verify --> check_heap

    style normal fill:#90EE90
    style fix fill:#FFB6C1
    style identify fill:#FFD700

図で理解できる要点:

  • スナップショット比較で問題を特定
  • Heap サイズ、Detached DOM、リスナー数を確認
  • 原因を特定してクリーンアップ処理を追加
  • 再テストで効果を検証

まとめ

Vue.js アプリケーションにおけるメモリリークは、watchwatchEffect、イベントリスナー、タイマーなどの適切なクリーンアップ処理を怠ることで発生します。本記事で解説した以下のポイントを押さえることで、メモリリークを防ぎ、パフォーマンスの高いアプリケーションを構築できるでしょう。

重要なポイント

  • watch と watchEffect:必ず停止関数を保存し、onUnmounted で呼び出す
  • イベントリスナーaddEventListenerremoveEventListener をペアで使用
  • タイマーsetIntervalsetTimeout は必ず clearIntervalclearTimeout で停止
  • Composable の活用:クリーンアップロジックを Composable として抽象化し、再利用性を高める
  • 定期的な監視:Chrome DevTools の Memory プロファイラーを使って定期的にメモリ状態を確認
  • 自動検知:開発環境でメモリ監視 Composable を使い、早期発見する

メモリリークは目に見えない問題だからこそ、開発の初期段階から意識的にクリーンアップ処理を実装することが重要です。今回紹介した Composable のパターンを活用することで、安全で保守性の高いコードを書けるようになりますよ。

適切なメモリ管理により、ユーザーにとって快適で、長時間使用しても安定したアプリケーションを提供していきましょう。

関連リンク