Vue.js パターンとベストプラクティス は何をしますか?
Composition API (<script setup>) を使用した Vue.js 3 開発のための包括的なガイドです。コンポーネント設計、リアクティビティ、状態管理、ルーティング、テスト、SSR(サーバーサイドレンダリング)パターンをカバーします。Nuxt 固有のガイダンスは、通常の Vue と異なる点に重点を置いて含まれています。
有効化タイミング
以下の場合にこのスキルを有効化します。
- プロジェクトが Vue.js(任意のバージョン)、Nuxt、Vite + Vue、または Pinia を使用している場合。
- ユーザーが Vue コンポーネントのアーキテクチャ、コンポーザブル、リアクティビティ、または状態管理について質問している場合。
- Vue シングルファイルコンポーネント(
.vueファイル)をレビューしている場合。 - Vue Router、Pinia ストア、または Vite/Vitest の設定を行っている場合。
- Vue 固有のパフォーマンス、セキュリティ、または SSR パターンについて議論している場合。
1. プロジェクト構造
推奨レイアウト(機能優先)
src/
├── api/ # APIクライアントとエンドポイント定義
├── assets/ # 静的アセット(画像、フォント、アイコン)
├── components/ # 共有/再利用可能なコンポーネント
│ ├── base/ # 基本UIプリミティブ(Button, Input, Modal)
│ └── features/ # 機能固有の共有コンポーネント
├── composables/ # 再利用可能なComposition APIロジック
├── layouts/ # ページレイアウト(オプション)
├── pages/ # ルートレベルのページコンポーネント
├── router/ # Vue Router設定
├── stores/ # Piniaストア
├── types/ # TypeScript型定義
├── utils/ # 純粋なユーティリティ関数
└── App.vue # ルートコンポーネント
ファイル命名規則
| 命名規則 | 使用タイミング |
|---|---|
PascalCase.vue | すべてのコンポーネント(vue/multi-word-component-names で強制) |
useCamelCase.ts | コンポーザブル |
camelCase.ts | ユーティリティ、APIクライアント、型 |
kebab-case ディレクトリ | ルートセグメント、機能フォルダ |
2. コンポーネントアーキテクチャ
シングルファイルコンポーネントの順序
<script setup lang="ts">
// 1. インポート (vue → エコシステム → 絶対パス → 相対パス)
// 2. Props & Emits & Slots
// 3. コンポーザブル
// 4. ローカル状態 (ref/reactive)
// 5. 算出プロパティ
// 6. メソッド
// 7. ウォッチャー
// 8. ライフサイクルフック
</script>
<template>
<!-- テンプレート内容 -->
</template>
<style scoped>
/* スコープ付きスタイル */
</style>
プレゼンテーショナルコンポーネント vs コンテナコンポーネント
- コンテナコンポーネント: データフェッチ、状態、副作用を所有します。プレゼンテーショナルコンポーネントをレンダリングします。
- プレゼンテーショナルコンポーネント: プロップを受け取り、イベントを発行します。API呼び出しやストアアクセスは行いません。純粋なレンダリングを行います。
Propsのベストプラクティス
// デフォルト値を持つ型ベースのprops
interface Props {
label: string;
variant?: "primary" | "secondary";
disabled?: boolean;
items: Item[];
}
const props = withDefaults(defineProps<Props>(), {
variant: "primary",
disabled: false,
});
- 常に
type、および適切なrequired/defaultを指定します。 - 真偽値props:
isXxx、hasXxx、canXxx。 - propsを直接変更しないでください — 代わりにイベントを発行します。
- v-modelバインディングには、
defineModel()(Vue 3.4+)またはmodelValue+update:modelValueを使用します。
イベント
const emit = defineEmits<{
submit: [];
"update:modelValue": [value: string];
select: [id: string, index: number];
}>();
- テンプレートではケバブケースを使用します(
@update:model-value)。 - スクリプトではキャメルケースを使用します(
emit("update:modelValue", val))。
3. コンポーザブル(再利用可能なロジック)
構造
// composables/useDebounce.ts
export function useDebounce<T>(value: MaybeRef<T>, delay: number): Ref<T> {
const debounced = ref(toValue(value)) as Ref<T>;
let timer: ReturnType<typeof setTimeout>;
watch(
() => toValue(value),
(newVal) => {
clearTimeout(timer);
timer = setTimeout(() => { debounced.value = newVal; }, delay);
}
);
onUnmounted(() => clearTimeout(timer));
return readonly(debounced);
}
ルール
- 必ず
useプレフィックスで始める必要があります。 - リアクティブな値(
ref、computed、reactive)を返し、プレーンなプリミティブは返さないでください。 - リアクティブな入力は
MaybeRef/toRef()/toValue()を介して受け入れます。 - 副作用は
onUnmountedまたはウォッチャーのonCleanupでクリーンアップします。 - モジュールスコープの副作用は避けてください。
vs Mixins
コンポーザブルはVue 2のmixinを完全に置き換えます。
- Mixin: 不透明なデータフロー、競合、名前の衝突。
- コンポーザブル: 明示的なインポート、明確な戻り値、合成可能でツリーシェイク可能。
4. 状態管理
何をいつ使うか
| パターン | ユースケース |
|---|---|
ref() / reactive() | ローカルコンポーネント状態 |
| Props + Emits | 親子間通信 |
| Provide / Inject | テーマ、設定、プラグインAPI |
| Piniaストア | グローバル、共有、複雑な状態 |
| サーバー状態コンポーザブル | キャッシュ付きAPIデータ(fetch/TanStack Queryをラップ) |
Pinia Setup Store(推奨)
// stores/useCartStore.ts
export const useCartStore = defineStore("cart", () => {
const items = ref<CartItem[]>([]);
const isLoading = ref(false);
const totalPrice = computed(() =>
items.value.reduce((sum, i) => sum + i.price * i.quantity, 0)
);
const itemCount = computed(() =>
items.value.reduce((sum, i) => sum + i.quantity, 0)
);
async function addItem(productId: string) {
isLoading.value = true;
try {
const item = await fetchProduct(productId);
const existing = items.value.find(i => i.id === item.id);
if (existing) existing.quantity++;
else items.value.push({ ...item, quantity: 1 });
} finally {
isLoading.value = false;
}
}
return { items, isLoading, totalPrice, itemCount, addItem };
});
- Setup Store構文(Options Storeではない)を使用します。
- ビジネスレベルの変更にはアクションを優先し、グループ化された更新には
$patch()を使用します。 - すべての非同期アクション: ローディング + 成功 + エラーを処理します。
5. Vue Router
ルート定義
const routes = [
{
path: "/users/:id",
name: "user-detail",
component: () => import("@/pages/UserDetail.vue"), // 遅延ロード
props: true, // paramsをpropsとして渡す
meta: { requiresAuth: true },
},
];
ナビゲーションガード
router.beforeEach((to, from) => {
const { isLoggedIn } = useAuthStore();
if (to.meta.requiresAuth && !isLoggedIn) {
return { name: "login", query: { redirect: to.fullPath } };
}
});
リアクティブなルートパラメータ
コンポーネントがマウントされたままルートパラメータが変更される場合:
const route = useRoute();
const id = computed(() => route.params.id as string);
watch(id, (newId) => fetchItem(newId));
6. テンプレートパターン
テンプレート構文
<!-- v-if/v-else-if/v-else -->
<div v-if="isLoading">読み込み中...</div>
<div v-else-if="error">エラー: {{ error }}</div>
<div v-else>{{ content }}</div>
<!-- 頻繁な切り替えにはv-show -->
<div v-show="isOpen">切り替えコンテンツ</div>
<!-- 安定したキーを持つv-for -->
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
<!-- 算出フィルタリングリスト(同じ要素にv-if + v-forは使用しない) -->
<div v-for="item in activeItems" :key="item.id">{{ item.name }}</div>
<!-- イベント処理 -->
<form @submit.prevent="handleSubmit">
<button type="submit">保存</button>
</form>
<!-- v-model -->
<input v-model="name" />
<CustomInput v-model="value" v-model:title="title" />
7. パフォーマンス
| テクニック | 使用タイミング |
|---|---|
v-memo | ほとんど変更されないリスト項目 |
v-once | 一度だけレンダリングされ、その後静的なコンテンツ |
shallowRef() | 大規模なデータ構造を全体として置き換える場合 |
shallowReactive() | トップレベルのプロパティのみをリアクティブにする場合 |
v-if より v-show | 頻繁な表示/非表示の切り替え |
<KeepAlive :max="10"> | 切り替えビューのキャッシュ |
| 遅延ルート | () => import(...) を非クリティカルなルートに使用 |
Suspense | フォールバック付きの非同期コンポーネント読み込み |
8. テスト
スタック
- Vitest: ユニットテストとコンポーネントテスト
- Vue Test Utils: マウントとインタラクション
- @pinia/testing: ストアのモック
- Playwright: E2E
コンポーネントテストパターン
import { mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import UserCard from "./UserCard.vue";
beforeEach(() => { setActivePinia(createPinia()); });
it("レンダリングと発行", async () => {
const wrapper = mount(UserCard, {
props: { user: { id: "1", name: "Alice" } },
});
expect(wrapper.text()).toContain("Alice");
await wrapper.find("button").trigger("click");
expect(wrapper.emitted("select")![0]).toEqual(["1"]);
});
9. Nuxt固有のパターン
自動インポート
Nuxtはref、computed、watch、useFetch、useAsyncDataなどを自動インポートします。インポートせずに直接使用してください。Nuxt以外のプロジェクトでは、常に明示的にインポートしてください。
useAsyncData / useFetch
const { data: user, pending, error, refresh } = await useAsyncData(
"user", // キャッシュ用の一意のキー
() => $fetch(`/api/users/${id}`),
);
const { data: posts } = await useFetch("/api/posts", {
query: { page: 1 },
key: "posts-page-1", // リクエストの重複排除
});
サーバールート
// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
const { id } = await getValidatedRouterParams(event, z.object({
id: z.string().uuid(),
}).parse);
// ... 取得して返す
});
ランタイム設定
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// サーバーサイドのみ
apiSecret: "",
// 公開(クライアントに公開)
public: {
apiBase: "https://api.example.com",
},
},
});
10. Vue 3.5+ の新API
リアクティブなPropsの分割代入
Vue 3.5でリアクティブなpropsの分割代入が安定化されました — defineProps() から分割代入された変数は自動的にリアクティブになります。
// Vue 3.5+: 分割代入されたpropsはリアクティブ(toRefsは不要)
const { count = 0, msg = "hello" } = defineProps<{
count?: number;
msg?: string;
}>();
// 制限: 分割代入されたpropsを直接監視することはできません
watch(() => count, (newVal) => { ... }); // ゲッターラッパーが必要
useTemplateRef()
テンプレート参照には、名前が一致するプレーンなrefの代わりに useTemplateRef() を使用します。
import { useTemplateRef } from "vue";
const inputEl = useTemplateRef<HTMLInputElement>("input");
// "input" はテンプレート内の ref="input" 属性と一致します(変数名ではありません)
動的なref IDをサポート: useTemplateRef(dynamicRefId)。
onWatcherCleanup()
グローバルにインポート可能なウォッチャークリーンアップAPI(Vue 3.5+)。ウォッチャーのコールバック内で同期的に呼び出す必要があります。
import { watch, onWatcherCleanup } from "vue";
watch(userId, async (newId) => {
const controller = new AbortController();
onWatcherCleanup(() => controller.abort());
// ... シグナルでフェッチ
});
useId()
フォーム要素とアクセシビリティのためのSSRで安定した一意のID生成。
import { useId } from "vue";
const id = useId();
defer Teleport
<Teleport defer> を使用すると、同じサイクルでレンダリングされたターゲットにテレポートできます。
<Teleport defer to="#container">コンテンツ</Teleport>
<div id="container"></div>
遅延ハイドレーション(SSR)
defineAsyncComponent() が hydrate 戦略をサポートするようになりました:
import { defineAsyncComponent, hydrateOnVisible } from "vue";
const AsyncComp = defineAsyncComponent({
loader: () => import("./Comp.vue"),
hydrate: hydrateOnVisible(),
});
アンチパターン
| アンチパターン | 問題点 | 修正方法 |
|---|---|---|
defineProps() の分割代入(Vue < 3.5) | スナップショットをキャプチャし、リアクティビティを失う | props.xxx でアクセスするか toRefs() を使用する |
分割代入されたpropsの watch()(Vue 3.5+) | コンパイル時エラー — 分割代入されたpropsは直接監視できない | ゲッターラッパーを使用: watch(() => count, ...) |
同じ要素に v-if + v-for | あいまいな実行順序 | 算出フィルタリング配列を使用する |
v-for のキーにインデックス | 並び替えで状態が崩れる | 安定したデータベースIDを使用する |
| propsの変更 | 単方向データフローに違反する | イベントを発行するか v-model を使用する |
ユーザーコンテンツでの v-html | XSS脆弱性 | DOMPurifyでサニタイズする |
| Vue 3でのMixin | 不透明で衝突しやすい | コンポーザブルに置き換える |
| コンポーザブル内のモジュールスコープの副作用 | インスタンス間で共有される | onMounted + onUnmounted でスコープする |
置き換え可能な状態での reactive() | 置き換えでリアクティビティが壊れる | 代わりに ref() を使用する |
| クリーンアップのないウォッチャー | メモリリーク、競合状態 | onCleanup または onWatcherCleanup()(Vue 3.5+)を使用する |
| 新しいVue 3コードでのOptions API | エコシステムがComposition APIに移行中 | <script setup> を使用する |
| テンプレート参照にプレーンなref | 動的なref参照をサポートせず、名前マッチングが脆弱 | useTemplateRef()(Vue 3.5+)を使用する |
関連スキル
accessibility— ARIA、セマンティックHTML、フォーカス管理frontend-patterns— クロスフレームワークのフロントエンドアーキテクチャtypescript— Vueプロジェクトに適用されるTypeScriptのベストプラクティスcoding-standards— 一般的なコード品質基準