TypeScript移行 - Pinia storeのメモリリーク対策とVitestセットアップ
はじめに
Vue 3プロジェクトをJavaScriptからTypeScriptに移行する際、Pinia storeのメモリリーク問題に遭遇しました。本記事では、この問題を解決し、さらにVitestでテスト環境を構築した過程を共有します。
背景
Toast通知を管理するPinia storeを実装していたのですが、元のJavaScriptコードには潜在的なメモリリークの問題がありました。
export const useToastStore = defineStore('toast', () => {
const toasts = ref([])
let nextId = 0
function showSuccess(message, duration = 3000) {
const id = nextId++
toasts.value.push({ id, message, type: 'success', duration })
// 自動削除タイマーを設定
setTimeout(() => {
dismissToast(id)
}, duration)
}
function dismissToast(id) {
toasts.value = toasts.value.filter(t => t.id !== id)
}
return { toasts, showSuccess, dismissToast }
})
問題点:
-
setTimeoutのタイマーIDを保存していない - 手動で
dismissToastを呼んでも、タイマーがキャンセルされない - ページ遷移時にタイマーがクリーンアップされない
解決策1: タイマーIDの管理
まず、タイマーIDをMapで管理するようにしました。
const timeoutMap = new Map<number, ReturnType<typeof setTimeout>>()
function createToast(message: string, type: ToastType, duration: number): void {
const id = nextId++
toasts.value.push({ id, message, type, duration })
// タイマーIDを保存
const timeoutId = setTimeout(() => {
dismissToast(id)
}, duration)
timeoutMap.set(id, timeoutId)
}
function dismissToast(id: number): void {
// 保存したタイマーをキャンセル
const timeoutId = timeoutMap.get(id)
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
timeoutMap.delete(id)
}
toasts.value = toasts.value.filter(t => t.id !== id)
}
ポイント: ReturnType<typeof setTimeout>
setTimeoutの戻り値の型は環境によって異なります:
- ブラウザ:
number - Node.js:
NodeJS.Timeout
ReturnType<typeof setTimeout>を使うことで、環境に応じて自動的に正しい型が適用されます。
解決策2: onScopeDisposeでクリーンアップ
ページ遷移時にタイマーをクリーンアップするため、onScopeDisposeを追加しました。
import { onScopeDispose } from 'vue'
export const useToastStore = defineStore('toast', (): ToastStore => {
// ... 前述のコード
// ストア破棄時のクリーンアップ
onScopeDispose(() => {
// 全タイマーをキャンセル
timeoutMap.forEach(timeoutId => {
clearTimeout(timeoutId)
})
timeoutMap.clear()
toasts.value = []
})
return { toasts, showSuccess, showError, dismissToast }
})
これにより、ページ遷移やアプリ終了時に残っているタイマーが自動的にクリーンアップされます。
Vitestのセットアップ
次に、TypeScript化したコードのテスト環境を構築しました。
インストール
npm install -D vitest @vue/test-utils @pinia/testing happy-dom
vite.config.js設定
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'happy-dom',
},
})
package.json
{
"scripts": {
"test": "vitest"
}
}
テストコードの作成
Toast Storeのテスト
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useToastStore } from './toast'
describe('useToastStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers() // タイマーをモック
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should add a success toast', () => {
const store = useToastStore()
store.showSuccess('Success message')
expect(store.toasts).toHaveLength(1)
expect(store.toasts[0]).toMatchObject({
message: 'Success message',
type: 'success',
duration: 3000
})
})
it('should auto-dismiss after duration', () => {
const store = useToastStore()
store.showSuccess('Auto dismiss')
expect(store.toasts).toHaveLength(1)
// 3秒後にタイマーを進める
vi.advanceTimersByTime(3000)
expect(store.toasts).toHaveLength(0)
})
it('should cancel auto-dismiss when manually dismissed', () => {
const store = useToastStore()
store.showSuccess('Manual dismiss')
const toastId = store.toasts[0].id
store.dismissToast(toastId)
expect(store.toasts).toHaveLength(0)
// タイマーを進めてもエラーにならない
vi.advanceTimersByTime(3000)
expect(store.toasts).toHaveLength(0)
})
})
テストファイルの配置
テストファイルはソースファイルと同じディレクトリに配置しました:
src/
├── stores/
│ ├── toast.ts
│ └── toast.test.ts ← 同じディレクトリ
├── api/
│ ├── client.ts
│ └── client.test.ts
└── utils/
├── formatters.ts
└── formatters.test.ts
この配置には以下のメリットがあります:
- テストとソースが近い
- インポートパスが短い
- リファクタリング時に一緒に移動しやすい
- Vue/Viteの推奨パターン
テスト結果
npm test
✓ src/utils/formatters.test.ts (16 tests)
✓ src/utils/signalr.test.ts (4 tests)
✓ src/stores/toast.test.ts (11 tests)
✓ src/api/client.test.ts (5 tests)
✓ src/api/devices.test.ts (4 tests)
✓ src/api/users.test.ts (13 tests)
Test Files 6 passed (6)
Tests 53 passed (53)
学んだこと
1. 型の理解が重要
TypeScriptの基本概念(Ref<T>, ジェネリクス、ユーティリティ型など)を理解することで、より安全なコードが書けます。
const toasts = ref<Toast[]>([])
// ^^^ ^^^^^^ ^^
// 関数 型 初期値
refは関数、Refは型、Toast[]は配列型という3つの要素を理解することが大切です。
2. 環境依存の型を吸収する
ReturnType<typeof setTimeout>のようなユーティリティ型を使うことで、環境(ブラウザ/Node.js)による型の違いを吸収できます。
3. テストでメモリリークを検証
Vitestのvi.useFakeTimers()を使うことで、タイマーが適切にクリーンアップされているかをテストで検証できます。
it('should cancel auto-dismiss when manually dismissed', () => {
store.dismissToast(toastId)
// タイマーを進めてもエラーにならない = クリーンアップ成功
vi.advanceTimersByTime(3000)
expect(store.toasts).toHaveLength(0)
})
まとめ
-
メモリリーク対策:
MapでタイマーIDを管理し、onScopeDisposeでクリーンアップ -
型安全性:
ReturnType<typeof setTimeout>で環境依存を吸収 - テスト環境: Vitestでモダンなテスト環境を構築
- テスト駆動: タイマーのクリーンアップをテストで検証
TypeScript移行は単なる型付けではなく、コードの品質向上の機会です。メモリリーク対策とテストを組み合わせることで、安心して運用できるコードになりました。
Discussion