Vue.js パターンとベストプラクティス
Composition API(<script setup>)を使用したVue.js 3開発のための包括的ガイド。コンポーネント設計、リアクティビティ、状態管理、ルーティング、テスト、SSRパターンをカバーします。Nuxt固有のガイダンスは、通常のVueと異なる部分に記載しています。
有効化するタイミング
以下の場合にこのスキルを有効化してください:
- プロジェクトがVue.js(任意のバージョン)、Nuxt、Vite + Vue、またはPiniaを使用している場合。
- ユーザーがVueコンポーネントアーキテクチャ、Composable、リアクティビティ、または状態管理について質問した場合。
- Vue Single-File Components(
.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 | Composable |
camelCase.ts | ユーティリティ、APIクライアント、型定義 |
kebab-case ディレクトリ | ルートセグメント、機能フォルダ |
2. コンポーネントアーキテクチャ
Single-File Componentの順序
<script setup lang="ts">
// 1. インポート(vue → エコシステム → 絶対パス → 相対パス)
// 2. Props & Emits & Slots
// 3. Composable
// 4. ローカル状態 (ref/reactive)
// 5. 算出プロパティ
// 6. メソッド
// 7. ウォッチャー
// 8. ライフサイクルフック
</script>
<template>
<!-- テンプレート内容 -->
</template>
<style scoped>
/* スコープ付きスタイル */
</style>
プレゼンテーショナル vs コンテナ
- コンテナコンポーネント: データ取得、状態、副作用を所有。プレゼンテーショナルコンポーネントをレンダリング。
- プレゼンテーショナルコンポーネント: Propsを受け取り、イベントを発行。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. Composable(再利用可能なロジック)
構造
// 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で副作用をクリーンアップする。- モジュールスコープの副作用を持たせない。
Mixinsとの比較
ComposableはVue 2のmixinを完全に置き換えます:
- Mixin: 不透明なデータフロー、情報源の競合、名前の衝突。
- Composable: 明示的なインポート、明確な戻り値、合成可能でツリーシェイカブル。
4. 状態管理
いつ何を使うか
| パターン | ユースケース |
|---|---|
ref() / reactive() | ローカルコンポーネントの状態 |
| Props + Emits | 親子間通信 |
| Provide / Inject | テーマ、設定、プラグインAPI |
| Piniaストア | グローバル、共有、複雑な状態 |
| サーバー状態のComposable | 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することはできない
watch(() => count, (newVal) => { ... }); // PASS getterが必要
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());
// ... signalを使用してデータ取得
});
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は直接監視できない | getterラッパーを使用する: watch(() => count, ...) |
同じ要素での v-if + v-for | 実行順序が曖昧 | 算出プロパティでフィルタリングした配列を使用する |
v-for のキー = インデックス | 並び替え時に状態が壊れる | 安定したデータベースIDを使用する |
| Propsの変更 | 単方向データフローに違反 | イベントを発行するか、v-model を使用する |
ユーザーコンテンツでの v-html | XSS脆弱性 | DOMPurifyでサニタイズする |
| Vue 3でのMixin | 不透明、衝突しやすい | Composableに置き換える |
| Composable内のモジュールスコープの副作用 | インスタンス間で共有される | 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— 一般的なコード品質基準