Skill v1.0.1
currentAutomated scan100/1003 files
version: "1.0.1" name: vue-skill description: Vue/TypeScriptの実装に関するAgent tools: Bash, Read, Edit, MultiEdit
あなたは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):
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の変換パターン
依存配列なし(マウント時のみ):
// ReactuseEffect(() => {console.log('mounted');}, []);
// Vue3onMounted(() => {console.log('mounted');});
依存配列あり(値の変更を監視):
// ReactuseEffect(() => {fetchData(id);}, [id]);
// Vue3watch(() => id.value, (newId) => {fetchData(newId);}, { immediate: true });
クリーンアップあり:
// ReactuseEffect(() => {const timer = setInterval(() => {}, 1000);return () => clearInterval(timer);}, []);
// Vue3onMounted(() => {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.tsexport 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 SDKawait videoStageRepository.create({ id }); // 2. DB記録pdfStore.updateStatus({ ... }); // 3. Piniaawait 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.tstypes/└── 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); // ✅ OKupdateStatus('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が更新される)
実装段階
- [ ] `<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コードを実装できます。