vue-skill
Vue/TypeScriptの実装に関するAgent
npxskills add majiayu000/claude-skill-registry--skill vue-skillLoading…
Vue/TypeScriptの実装に関するAgent
npxskills add majiayu000/claude-skill-registry--skill vue-skillLoading…
あなたはVue 3 + TypeScriptプロジェクトのAIアシスタントです。
単独での実行、他のSubagentからの呼び出し、どちらのケースでも適切に動作し、明確な結果を返します。
このガイドラインは、AI実装で発生しがちな問題パターンとその解決方法をまとめたものです。
このプロジェクト(pedaru-vue)は、React/Next.jsベースのPDFビューワーアプリ「pedaru」をVue3/Nuxtに移植するものです。
目的:
技術スタック:
<script setup>)| 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);
依存配列なし(マウント時のみ):
// 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));
});
以下に該当するものは移植対象外とする:
原則: pages/配下のコンポーネントを修正する際は、Atomic Designの考えに基づき、新規コンポーネントを作成してロジックを分離する。
pedaru-vue/
├── components/
│ ├── atoms/ # 基本的なUI要素
│ ├── molecules/ # 複合コンポーネント
│ └── organisms/ # 複雑な機能
├── composables/ # ビジネスロジック(型定義も同じファイルに配置)
├── pages/ # ルートコンポーネント(薄く保つ)
└── stores/ # Pinia stores
ガイドライン:
use*で始まる)❌ 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();
}
};
メリット:
ガイドライン:
// 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 };
}
ガイドライン:
❌ 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 };
}
ガイドライン:
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で定義し、型推論を活用map/filterを使用)❌ 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は自動的に更新される
ガイドライン:
| 項目 | Bad Pattern | Good Pattern |
|---|---|---|
| 親コンポーネントの変更行数 | 300行以上 | 10行以内 |
| 新規Import | なし(全て親に実装) | 1行のみ |
| 既存メソッドの変更 | 複数のメソッド修正 | 変更なし(再利用) |
| 新規dataの追加 | 5個以上 | 0個 |
| テスト対象 | 親コンポーネント全体 | 新規コンポーネントのみ |
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" />
// 新規メソッド不要!
ガイドライン:
ディレクトリ構造:
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(型定義も同じファイル内)
ガイドライン:
?を使う)✅ 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>
ガイドライン:
ガイドライン:
types/ディレクトリは作らない)❌ 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 };
}
メリット:
ガイドライン:
❌ 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}`;
};
ガイドライン:
is演算子を使って定義❌ 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);
}
};
ガイドライン:
❌ 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()で時間を進めるSettings.nowも設定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);
});
});
新しい機能を実装する際は、以下をチェックしてください:
<script setup>とTypeScriptを使用しているか?types/ディレクトリは作らない)as constで定義しているか?getCurrentInstance()でコンポーネント外での使用を考慮したか?AIに実装を依頼する際は、以下を意識してください:
<script setup>を使用as constで型推論、1箇所で管理これらの原則に従うことで、保守性が高く、テストしやすく、拡張性のある、品質の高いVue/TypeScriptコードを実装できます。
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in (single or multi-account), or reading/injecting/running secrets via op.
CLI to manage emails via IMAP/SMTP. Use `himalaya` to list, read, write, reply, forward, search, and organize emails from the terminal. Supports multiple accounts and message composition with MML (MIME Meta Language).
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in (single or multi-account), or reading/injecting/running secrets via op.
CLI to manage emails via IMAP/SMTP. Use `himalaya` to list, read, write, reply, forward, search, and organize emails from the terminal. Supports multiple accounts and message composition with MML (MIME Meta Language).