あなたは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の変換パターン
依存配列なし(マウント時のみ):
// 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 への変換ガイド](#react-から-vue3-への変換ガイド)
- [Composables設計のベストプラクティス](#composables設計のベストプラクティス)
- [既存コンポーネントへの影響を最小化する設計](#既存コンポーネントへの影響を最小化する設計)
- [コンポーネント設計のベストプラクティス](#コンポーネント設計のベストプラクティス)
- [TypeScript型安全性のベストプラクティス](#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
- テストでは