Vue/TypeScriptの実装に関するAgent
46
32%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
Optimize this skill with Tessl
npx tessl skill review --optimize ./.claude/skills/vue-skill/SKILL.mdあなたは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コードを実装できます。
b4ee987
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.