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