git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/vue-skill-h5y1m141" ~/.claude/skills/diegosouzapw-awesome-omni-skill-vue-skill && rm -rf "$T"
skills/development/vue-skill-h5y1m141/SKILL.mdあなたはVue 3 + TypeScriptプロジェクトのAIアシスタントです。
単独での実行、他のSubagentからの呼び出し、どちらのケースでも適切に動作し、明確な結果を返します。
Vue/TypeScript実装ガイドライン
このガイドラインは、AI実装で発生しがちな問題パターンとその解決方法をまとめたものです。
プロジェクト概要
このプロジェクト(pedaru-vue)は、React/Next.jsベースのPDFビューワーアプリ「pedaru」をVue3/Nuxtに移植するものです。
目的:
- Vue3の仕組みを効果的に使った実装
- 単なる機能移植ではなく、Vue3のベストプラクティスに沿った設計
技術スタック:
- Vue 3 + Composition API(
)<script setup> - Nuxt 3
- TypeScript
- Pinia(状態管理)
React から Vue3 への変換ガイド
React Hooks → Vue3 Composition API マッピング
| React | Vue 3 | 備考 |
|---|---|---|
| / | プリミティブはref、オブジェクトはreactive |
| / / | 依存配列の有無で使い分け |
| 通常の関数 | Vueでは不要(必要に応じてcomputed) |
| | |
| / | DOM参照はuseTemplateRef |
| / または Pinia | グローバルはPinia推奨 |
| Pinia store |
変換例
React (useState + useEffect):
const [count, setCount] = useState(0); const [doubled, setDoubled] = useState(0); useEffect(() => { setDoubled(count * 2); }, [count]);
Vue3 (ref + computed):
const count = ref(0); const doubled = computed(() => count.value * 2);
useEffectの変換パターン
依存配列なし(マウント時のみ):
// React useEffect(() => { console.log('mounted'); }, []);
// Vue3 onMounted(() => { console.log('mounted'); });
依存配列あり(値の変更を監視):
// React useEffect(() => { fetchData(id); }, [id]);
// Vue3 watch(() => id.value, (newId) => { fetchData(newId); }, { immediate: true });
クリーンアップあり:
// React useEffect(() => { const timer = setInterval(() => {}, 1000); return () => clearInterval(timer); }, []);
// Vue3 onMounted(() => { const timer = setInterval(() => {}, 1000); onUnmounted(() => clearInterval(timer)); });
移植しない機能
以下に該当するものは移植対象外とする:
- Electron/Tauri固有の処理: デスクトップアプリ固有のAPI呼び出し
- 不要な互換性維持コード: 後方互換のためだけのコード
- 過剰なエラーハンドリング: 発生し得ないケースの処理
注意!!!
- 当ファイルを読む際は必ず全文読み込んでください。断片的に読んでも良い作業はできません。
目次
- プロジェクト概要
- React から Vue3 への変換ガイド
- Composables設計のベストプラクティス
- 既存コンポーネントへの影響を最小化する設計
- コンポーネント設計のベストプラクティス
- TypeScript型安全性のベストプラクティス
- テスト戦略とベストプラクティス
- 実装チェックリスト
- まとめ
pages/配下のコンポーネント肥大化防止
原則: pages/配下のコンポーネントを修正する際は、Atomic Designの考えに基づき、新規コンポーネントを作成してロジックを分離する。
pedaru-vue/ ├── components/ │ ├── atoms/ # 基本的なUI要素 │ ├── molecules/ # 複合コンポーネント │ └── organisms/ # 複雑な機能 ├── composables/ # ビジネスロジック(型定義も同じファイルに配置) ├── pages/ # ルートコンポーネント(薄く保つ) └── stores/ # Pinia stores
Composables設計のベストプラクティス
単一責任の原則
ガイドライン:
- 1つのcomposableは1つの関心事のみを扱う
- ファイル名はその責任を明確に表す(
で始まる)use* - 50〜100行を目安とし、それを超える場合は分割を検討
- 複数のcomposableを組み合わせて使用する設計を推奨(Compose)
❌ Bad: 複数の責任を持つ巨大なComposable
// useVideoManagement.ts(悪い例) export function useVideoManagement() { // ポーリング、時間計算、セッション記録、Zoom SDK操作が混在 // 100行以上の複雑なロジック... }
✅ Good: 責任を分離
// useVideoStatus.ts - ビデオステータスのポーリング専用 export function useVideoStatus() { const videoStatus = ref<VideoStageStatusResponse | null>(null); const fetchVideoStatus = async (id: number) => { /* ... */ }; const startPolling = (id: number) => { /* ... */ }; const stopPolling = () => { /* ... */ }; return { videoStatus, fetchVideoStatus, startPolling, stopPolling }; } // useSessionElapsedTime.ts - 時間計算専用 export function useSessionElapsedTime(sessionStartTime: Ref<string | null>) { const elapsedTime = computed(() => { /* ... */ }); return { elapsedTime }; }
✅ Good: 複数のComposableを組み合わせる(Compose)
重要な判断基準:責任範囲の正しい分離
例:症状選択機能で「選択」と「送信履歴管理」を1つのcomposableに混在させてはいけません。
// ❌ Bad: 責任範囲が混在 // useSymptomSelection.ts export function useSymptomSelection() { // 症状選択の責任 const toggleSymptomSelection = (key: SymptomItemKeyType) => { /* ... */ }; // 送信履歴管理の責任(別の関心事!) const markAsSent = (key: SymptomItemKeyType) => { /* ... */ }; const onSendSuccess = () => { /* 混在している */ }; return { toggleSymptomSelection, markAsSent, onSendSuccess }; }
正しい設計:各関心事を独立したcomposableに分離し、組み合わせて使用
// useSymptomSelection.ts - 症状選択のみに集中 export function useSymptomSelection() { const selectedSymptomItems = ref<Set<SymptomItemKeyType>>(new Set()); const toggleSymptomSelection = (key: SymptomItemKeyType) => { /* ... */ }; const resetSelectedSymptoms = () => { /* ... */ }; const selectedSymptoms = computed(() => { /* ... */ }); return { selectedSymptomItems, selectedSymptoms, toggleSymptomSelection, resetSelectedSymptoms }; } // useSymptomSendHistory.ts - 送信履歴管理専用(新規ファイル) export function useSymptomSendHistory() { const sentSymptomItems = ref<Set<SymptomItemKeyType>>(new Set()); const markAsSent = (keys: SymptomItemKeyType[]) => { keys.forEach(key => sentSymptomItems.value.add(key)); }; const isSent = (key: SymptomItemKeyType): boolean => { return sentSymptomItems.value.has(key); }; return { sentSymptomItems, markAsSent, isSent }; } // useChatMessageGenerator.ts - メッセージ生成専用(新規ファイル) export function useChatMessageGenerator() { const generateSymptomMessage = (symptoms: SymptomItem[]): string => { const mainMessage = '症状に合わせたホームケアのPDFをお送りします。'; const contents = symptoms.map((s) => `・${s.title}\n${s.url}\n`).join('\n'); return `${mainMessage}\n${contents}`; }; return { generateSymptomMessage }; } // ChatAttachedPdfSelect.vue - 複数のcomposableを組み合わせる(Compose) const { selectedSymptoms, resetSelectedSymptoms } = useSymptomSelection(); const { markAsSent } = useSymptomSendHistory(); const { generateSymptomMessage } = useChatMessageGenerator(); const handleSendChat = async () => { const message = generateSymptomMessage(selectedSymptoms.value); const success = await props.onSendChat(message); if (success) { markAsSent(selectedSymptoms.value.map(s => s.key)); resetSelectedSymptoms(); await showToast(); } };
メリット:
- ✅ 単一責任の原則: 各composableが1つの関心事のみ
- ✅ Composeの原則: 複数のcomposableを組み合わせて使用
- ✅ テスト容易性: 各関心事を独立してテスト可能
- ✅ 再利用性: 送信履歴管理は他の機能でも再利用可能
- ✅ 明確な責任範囲: ファイル名から役割が明確
レイヤー分離(技術層とビジネス層)
ガイドライン:
- 技術層: 外部ライブラリ(Zoom SDK、Twilio)の操作のみ
- アプリケーション層: ビジネスロジック、Pinia操作、DB記録
- 依存方向は常に「アプリケーション層 → 技術層」
- コメントで設計意図を明示(
など)// NOTE: Piniaにアクセスしない
❌ Bad: 技術的な詳細とビジネスロジックが混在
// useZoomVideoSession.ts(悪い例) export function useZoomVideoSession() { const startSession = async () => { // Zoom SDKの初期化 zoomClient.value = ZoomVideo.createClient(); await zoomClient.value.init('ja-JP', 'Global'); // ビジネスロジック(DB記録)が混在 await videoStageRepository.create({ status: 'active' }); // Piniaへのアクションも混在 pdfStore.updateStatus('active'); }; }
✅ Good: レイヤーを明確に分離
// useZoomVideo.ts - 技術層(Zoom SDK操作のみ) export function useZoomVideo() { const createSession = async (sessionId: string) => { /* Zoom SDKのみ */ }; const joinSession = async (sessionId: string) => { /* Zoom SDKのみ */ }; // NOTE: Piniaにアクセスしない return { createSession, joinSession }; } // useOnlineReservationVideoSession.ts - アプリケーション層 export function useOnlineReservationVideoSession() { const { createSession, joinSession } = useZoomVideo(); const startTalking = async (sessionId: string, id: number) => { await createSession(sessionId); // 1. Zoom SDK await videoStageRepository.create({ id }); // 2. DB記録 pdfStore.updateStatus({ ... }); // 3. Pinia await joinSession(sessionId); // 4. Zoom SDK }; return { startTalking }; }
状態管理とスコープ
ガイドライン:
- 個別インスタンスが必要: composable内でstateを定義
- 親子間で共有が必要: Provide/Injectパターン
- アプリ全体で共有が必要: Pinia
- テストでは常に独立したインスタンスを使用できるようにする
❌ Bad: グローバルなステート共有(シングルトン)
// composable外でstateを定義 const isVideoSessionActive = ref(false); export function useVideoStatus() { // 複数のコンポーネントで同じインスタンスを共有 return { isVideoSessionActive }; }
✅ Good: composable内でstateを定義(個別インスタンス)
export function useVideoStatus() { // composable内でstateを定義(呼び出しごとに新しいインスタンス) const isVideoSessionActive = ref(false); const preparationState = reactive({ onlineReservationId: null as number | null, }); return { isVideoSessionActive, preparationState }; }
クリーンアップ処理
ガイドライン:
- タイマー、イベントリスナー、WebSocketなどは必ずクリーンアップ
でコンポーネント外での使用を考慮getCurrentInstance()
でリソース解放を保証onUnmounted- 手動停止メソッドも提供して柔軟性を確保
❌ Bad: クリーンアップ処理の欠如
export function useVideoStatus() { let pollingTimer: number | null = null; const startPolling = (id: number) => { pollingTimer = window.setInterval(() => { /* ... */ }, 10000); }; // クリーンアップ処理がない! return { startPolling }; }
✅ Good: 適切なクリーンアップ処理
export function useVideoStatus() { let pollingTimer: number | null = null; const startPolling = (id: number) => { if (pollingTimer !== null) return; // 重複防止 pollingTimer = window.setInterval(() => { /* ... */ }, 10000); }; const stopPolling = () => { if (pollingTimer !== null) { clearInterval(pollingTimer); pollingTimer = null; } }; // コンポーネント外で使用される可能性を考慮 const instance = getCurrentInstance(); if (instance) { onUnmounted(() => stopPolling()); } return { startPolling, stopPolling }; }
データ駆動設計
ガイドライン:
- マスターデータは
で定義し、型推論を活用as const - UIはデータから自動生成する(
/map
を使用)filter - ビジネスロジックは汎用的に設計(特定の値に依存しない)
- 新規追加はデータ定義のみで完結するようにする
- URLなどの派生データは関数で生成
❌ Bad: UIとロジックが密結合
export function useBadSymptomSelection() { const selectedSymptoms = ref<string[]>([]); // 症状ごとにメソッドを追加する必要がある const addCough = () => { selectedSymptoms.value.push('咳'); }; const addFever = () => { selectedSymptoms.value.push('発熱'); }; return { selectedSymptoms, addCough, addFever }; }
✅ Good: マスターデータから自動生成
// 1. マスターデータの定義(as constで型推論) const symptomItems = { seki: { title: '咳', category: '咳' }, netsu_jyunyu: { title: '発熱(授乳期)', category: '発熱' }, hanamizu: { title: '鼻水', category: '鼻水' }, } as const; // 2. 型の自動生成 export type SymptomItemKeyType = keyof typeof symptomItems; // 3. データからUI構造を自動生成 const symptoms = categories.map((category) => ({ category, items: Object.entries(symptomItems) .filter(([, item]) => item.category === category) .map(([key, { title }]) => ({ key: key as SymptomItemKeyType, title, url: generateUrl(key as SymptomItemKeyType), })), })); // 4. 汎用的なビジネスロジック export const toggleSymptomSelection = (key: SymptomItemKeyType) => { if (selectedSymptomItems.value.has(key)) { selectedSymptomItems.value.delete(key); } else { selectedSymptomItems.value.add(key); } };
拡張性の実例:
// ✅ 新しい症状を追加(データ定義のみ、1箇所の変更) const symptomItems = { // ... 既存の定義 atopy: { title: 'アトピー性皮膚炎', category: '皮膚トラブル' }, // 追加 } as const; // → UIは自動的に更新される
既存コンポーネントへの影響を最小化する設計
ガイドライン:
- 新機能は新規コンポーネントに隔離する
- 親コンポーネントへの変更は最小限に(10行以内を目標)
- 既存のメソッドを再利用できる場合はコールバック関数Propsを使う
- Props/Emitsはシンプルに保つ(2〜3個まで)
- ビジネスロジックはComposableに委譲する
- UIロジックは新規コンポーネント内で完結させる
影響範囲の比較
| 項目 | Bad Pattern | Good Pattern |
|---|---|---|
| 親コンポーネントの変更行数 | 300行以上 | 10行以内 |
| 新規Import | なし(全て親に実装) | 1行のみ |
| 既存メソッドの変更 | 複数のメソッド修正 | 変更なし(再利用) |
| 新規dataの追加 | 5個以上 | 0個 |
| テスト対象 | 親コンポーネント全体 | 新規コンポーネントのみ |
シンプルなPropsインターフェース
interface Props { onSendChat: (message: string) => void; // コールバック関数 isDoctorPage: boolean; // 表示制御フラグ }
なぜEmitではなくコールバック関数を使うのか:
// ❌ Bad: Emitを使う場合(親側の変更が必要) // 親コンポーネント(新規メソッドが必要) <ChatAttachedPdfSelect @send-chat="handleSymptomChatSend" /> methods: { handleSymptomChatSend(message) { this.handleChatSend(message); // 既存メソッドを呼ぶだけ } } // ✅ Good: コールバック関数を使う場合(親側の変更不要) // 親コンポーネント(既存メソッドをそのまま渡す) <ChatAttachedPdfSelect :onSendChat="handleChatSend" /> // 新規メソッド不要!
コンポーネント設計のベストプラクティス
Atomic Design
ガイドライン:
- pages/: ルーティングとメタ情報のみ(50行以内)
- organisms/: 複雑な機能の統合(100〜200行)
- molecules/: 複合コンポーネント(50〜100行)
- atoms/: 基本的なUI要素(30〜50行)
- composables/: ビジネスロジックと状態管理
ディレクトリ構造:
pedaru-vue/ ├── pages/ │ └── index.vue # 薄いルートコンポーネント(50行以内) ├── components/ │ ├── organisms/ │ │ └── PdfViewer.vue # PDFビューワー全体 │ ├── molecules/ │ │ ├── PdfToolbar.vue # ツールバー │ │ └── PdfPageNav.vue # ページナビゲーション │ └── atoms/ │ ├── BaseButton.vue # ボタン │ └── BaseIcon.vue # アイコン ├── composables/ │ ├── usePdfViewer.ts # 型定義もこのファイル内に配置 │ └── usePdfNavigation.ts └── stores/ └── pdf.ts # Pinia store(型定義も同じファイル内)
Props/Emitsの型安全な定義
ガイドライン:
- Propsは必ずTypeScriptのinterfaceで定義
- 必須とオプショナルを明示的に区別(
を使う)? - Emitsも型定義する(ペイロードの型を明確に)
- シンプルな通知はEmit、複雑な処理フローはコールバック関数
- コールバック関数を使う理由をコメントで明示
✅ Good: 型安全なProps/Emits定義
<script setup lang="ts"> // Props定義をinterfaceで明示 interface Props { isOnCamera: boolean; isOnAudio: boolean; nurseName: string; patientName?: string; // オプショナルは明示的に } const props = defineProps<Props>(); // Emits定義も型安全に interface Emits { (e: 'update:modelValue', value: boolean): void; (e: 'leave', reason: 'user-action' | 'timeout'): void; } const emit = defineEmits<Emits>(); </script>
コールバック vs Emit の使い分け:
<!-- パターン1: Emitを使う(シンプルな通知) --> <script setup lang="ts"> interface Emits { (e: 'close'): void; (e: 'submit', data: FormData): void; } const emit = defineEmits<Emits>(); </script> <!-- パターン2: コールバック関数を使う(複雑な処理フロー) --> <script setup lang="ts"> interface Props { isDisplayVideoWindow: boolean; leaveSession: () => Promise<void>; // 関数を直接渡す } const props = defineProps<Props>(); const onLeaveClick = async () => { // NOTE: 親コンポーネントで定義した処理を利用する必要がある // 理由:親のVideoStatusPanelで表示制御のフラグ更新とZoomのビデオ退出を行う await props.leaveSession(); }; </script>
コンポーネント肥大化の防止
ガイドライン:
- コンポーネントは100行以内を目標
- ロジックはcomposablesに分離
- UIはAtomic Designに基づいて分割
- テンプレートも100行を超えたら分割を検討
- 1ファイル200行を超えたら必ず分割
TypeScript型安全性のベストプラクティス
型定義の配置方針
ガイドライン:
- 型定義はロジックに近い場所に配置する(専用の
ディレクトリは作らない)types/ - composableで使う型はそのcomposableファイル内に定義
- storeで使う型はそのstoreファイル内に定義
- 複数ファイルで共有する型のみ、関連するcomposableからexport
❌ Bad: 別ディレクトリに型定義を分離
composables/ └── usePdfViewer.ts types/ └── pdf.ts # 型定義が離れている
✅ Good: ロジックと型定義を同じファイルに配置
// composables/usePdfViewer.ts // 型定義(このcomposableで使用する型) export interface PdfViewerState { currentPage: number; totalPages: number; scale: number; } export type PdfLoadStatus = 'idle' | 'loading' | 'loaded' | 'error'; // ロジック export function usePdfViewer() { const state = reactive<PdfViewerState>({ currentPage: 1, totalPages: 0, scale: 1.0, }); const status = ref<PdfLoadStatus>('idle'); // ... return { state, status }; }
メリット:
- 型とロジックが近いため、変更時の影響範囲が明確
- ファイルを開くだけで型の定義がわかる
- 不要な型が残りにくい(ロジック削除時に型も一緒に削除される)
as constとUnion型の活用
ガイドライン:
- 定数はas constで定義してUnion型を自動生成
- 文字列リテラル型を活用して型安全性を確保
- Template Literal Typeで命名規則を型で表現
- keyof typeofでオブジェクトからUnion型を生成
❌ Bad: 文字列リテラルを直接使用
const status = ref<string>('notYetStarted'); const updateStatus = (newStatus: string) => { status.value = newStatus; // 任意の文字列を許容してしまう }; updateStatus('typo-status'); // コンパイルエラーにならない
✅ Good: as constとUnion型の活用
// 定数オブジェクトをas constで定義 export const OnlineReservationVideoSessionStatus = { notYetStarted: 'notYetStarted', sessionCreating: 'sessionCreating', sessionCreated: 'sessionCreated', sessionStarted: 'sessionStarted', } as const; // Union型を自動生成 export type OnlineReservationVideoSessionStatusType = (typeof OnlineReservationVideoSessionStatus)[keyof typeof OnlineReservationVideoSessionStatus]; // 使用例 const status = ref<OnlineReservationVideoSessionStatusType>( OnlineReservationVideoSessionStatus.notYetStarted ); updateStatus(OnlineReservationVideoSessionStatus.sessionStarted); // ✅ OK updateStatus('typo-status'); // ❌ コンパイルエラー
Template Literal Typeの活用:
// 命名規則を型レベルで表現 type ZoomRoomNameType = `online_reservation_${number}`; const createRoomName = (id: number): ZoomRoomNameType => { return `online_reservation_${id}`; };
Type Guardと型の絞り込み
ガイドライン:
- unknownからの型変換には必ずType Guardを使用
- anyは絶対に使わない
- Type Guard関数は
演算子を使って定義is - 複数の型を扱う場合はそれぞれType Guardを定義
❌ Bad: unknownをanyにキャスト
const onErrorOccur = (e: unknown) => { const error = e as any; // anyにキャストして型チェックを回避 if (error.errorCode) { Sentry.captureMessage(`Error code: ${error.errorCode}`); } };
✅ Good: Type Guardで安全に型を絞り込む
// Type Guardの定義 interface ZoomErrorObject { type?: string; reason?: string; errorCode?: number; } export const isZoomErrorObject = (error: unknown): error is ZoomErrorObject => { return ( error !== null && typeof error === 'object' && ('type' in error || 'reason' in error || 'errorCode' in error) ); }; // 使用例 const onErrorOccur = (e: unknown) => { if (isZoomErrorObject(e)) { // この中ではeはZoomErrorObject型として扱える Sentry.captureMessage(`Zoom Error: ${e.errorCode}`); } else { Sentry.captureException(e); } };
テスト戦略とベストプラクティス
価値のあるテストのみ
ガイドライン:
- 振る舞いをテストし、実装の詳細はテストしない
- 正常系とエラー系の両方をカバー
- 単純なgetter/setterはテスト不要
- ビジネスロジックの正しさを検証
❌ Bad: 実装の詳細をテスト
describe('useVideoStatus', () => { it('videoStatusはrefである', () => { const { videoStatus } = useVideoStatus(); expect(isRef(videoStatus)).toBe(true); // 価値が低い }); it('isLoadingの初期値はfalseである', () => { const { isLoading } = useVideoStatus(); expect(isLoading.value).toBe(false); // 価値が低い }); });
✅ Good: 振る舞いをテスト
describe('useVideoStatus', () => { it('API から VideoStage 情報を取得して videoStatus に設定する', async () => { const mockResponse = { data: { video_stages: [{ id: 1, status: 'active' }] } }; mockVideoStageRepository.fetchStatus.mockResolvedValue(mockResponse); const { videoStatus, fetchVideoStatus } = useVideoStatus(); await fetchVideoStatus(123); expect(mockVideoStageRepository.fetchStatus).toHaveBeenCalledWith(123); expect(videoStatus.value).toMatchObject({ valid_status: true }); }); it('API エラー時に error メッセージを設定する', async () => { mockVideoStageRepository.fetchStatus.mockRejectedValue(new Error('Network error')); const { error, fetchVideoStatus } = useVideoStatus(); await fetchVideoStatus(123); expect(error.value).toBe('状態の取得に失敗しました'); }); });
タイマーとポーリングのテスト
ガイドライン:
でタイマーを制御可能にvi.useFakeTimers()
で時間を進めるvi.advanceTimersByTimeAsync()- Luxon使用時は
も設定Settings.now - afterEachで必ず
を呼ぶvi.useRealTimers() - ポーリングの開始・停止・間隔を検証
❌ Bad: 実際の時間を待つ
it('1秒後に経過時間が更新される', async () => { const { elapsedTime } = useSessionElapsedTime(sessionStartTime); await new Promise(resolve => setTimeout(resolve, 1000)); // テストが遅い expect(elapsedTime.value).toBe('00:00:01'); });
✅ Good: Fake Timersを使用
describe('useSessionElapsedTime', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); Settings.now = () => Date.now(); // Luxonの時刻もリセット }); it('HH:mm:ss形式で経過時間を返す', () => { const now = new Date('2025-01-07T10:30:00'); vi.setSystemTime(now); Settings.now = () => now.getTime(); const startTime = DateTime.fromISO('2025-01-07T09:00:00'); const sessionStartTime = ref(startTime.toISO()); const { elapsedTime } = useSessionElapsedTime(sessionStartTime); expect(elapsedTime.value).toBe('01:30:00'); }); }); describe('startPolling', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('10秒間隔でポーリングが実行される', async () => { mockVideoStageRepository.fetchStatus.mockResolvedValue({ data: {} }); const { startPolling } = useVideoStatus(); startPolling(123); await vi.advanceTimersByTimeAsync(0); expect(mockVideoStageRepository.fetchStatus).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(10000); // 10秒後 expect(mockVideoStageRepository.fetchStatus).toHaveBeenCalledTimes(2); }); });
実装チェックリスト
新しい機能を実装する際は、以下をチェックしてください:
設計段階
- Vue2とVue3のどちらで実装するか確認したか?(新規は必ずVue3)
- pages/配下のコンポーネントは薄く保てるか?(50行以内)
- 既存コンポーネントへの影響を最小限にできるか?(10行以内の変更を目標)
- ロジックをcomposablesに分離できるか?
- データ駆動設計を適用できるか?(マスターデータから自動生成)
- Atomic Designに基づいてコンポーネントを分割できるか?
- 1つのcomposableが複数の責任を持っていないか?
- 複数の関心事を持つcomposableを、独立した複数のcomposableに分離しているか?(Composeパターン)
- 技術層とビジネス層を分離できるか?
- 将来の拡張性を考慮した設計か?(データ追加で自動的にUIが更新される)
実装段階
-
とTypeScriptを使用しているか?<script setup> - 型定義はロジックに近い場所に配置しているか?(専用の
ディレクトリは作らない)types/ - Props/Emitsに型定義を付けているか?
- 既存メソッドを再利用できる場合はコールバック関数Propsを使っているか?
- マスターデータを
で定義しているか?as const - as constとUnion型を活用しているか?
- Type Guardで安全に型を絞り込んでいるか?
- anyを使っていないか?
- v-forでUIを自動生成しているか?(ハードコードを避ける)
- keyには一意なID(item.id、item.key)を使用しているか?(indexを使っていないか)
- タイマーやイベントリスナーのクリーンアップ処理を実装したか?
-
でコンポーネント外での使用を考慮したか?getCurrentInstance() - 複雑な処理フローではコールバック関数を使っているか?
- コメントで設計意図を明示しているか?
テスト段階
- 振る舞いをテストしているか?(実装の詳細ではなく)
- 正常系とエラー系の両方をカバーしているか?
- タイマーテストでFake Timersを使っているか?
- モックは外部依存のみに限定しているか?
- テストの独立性を保っているか?
まとめ
AIに実装を依頼する際は、以下を意識してください:
基本原則
- Vue3 Composition API + TypeScriptを使用: 常に
を使用<script setup> - 単一責任の原則を守る: 1つのcomposable/コンポーネントは1つの関心事のみ
- レイヤーを分離する: 技術層とビジネス層を明確に分ける
- Atomic Designを適用する: pages → organisms → molecules → atoms
- 型安全性を確保する: as const、Union型、Type Guardを活用
- クリーンアップ処理を実装: タイマー、イベントリスナーは必ず解放
- データ駆動設計を推進: マスターデータからUIを自動生成(DRY原則)
- 既存コンポーネントへの影響を最小化: 新機能は新規コンポーネントに隔離
実装のポイント
- Composables: 50〜100行、単一責任、技術層とビジネス層を分離、データ駆動設計、複数のcomposableを組み合わせる(Compose)
- コンポーネント: 100行以内、ロジックはcomposablesに委譲、v-forで動的生成
- Props/Emits: 必ずTypeScriptで型定義、既存メソッド再利用時はコールバック関数
- TypeScript: as const、Union型、Type Guard、Template Literal Type
- テスト: 振る舞いをテスト、Fake Timers、モック最小化
特に重要:pages/配下のコンポーネント肥大化防止
- pages/: 50行以内の薄いルートコンポーネント
- organisms/: 100〜200行の機能統合
- molecules/: 50〜100行の複合コンポーネント
- composables/: ビジネスロジックと状態管理
特に重要:データ駆動設計と拡張性
- マスターデータ定義:
で型推論、1箇所で管理as const - UIは自動生成: v-forでデータから動的に生成
- 新規追加は容易: データ定義のみで完結(UIコードの修正不要)
- 型安全性の確保: TypeScriptの型チェックで誤りを防止
- テストの独立性: データ変更でUIが正しく更新されるかを検証
特に重要:既存コンポーネントへの影響最小化
- 親コンポーネントへの変更は10行以内を目標
- 既存メソッドの再利用: コールバック関数Propsで疎結合
- 新機能は新規コンポーネントに隔離: リグレッションリスク最小化
- Props/Emitsはシンプルに: 2〜3個まで
- レビューしやすい差分: 変更箇所を最小限に
これらの原則に従うことで、保守性が高く、テストしやすく、拡張性のある、品質の高いVue/TypeScriptコードを実装できます。