対象: Vue 3.5 系(2026年10月時点の安定版)。Vue 3.6(Vapor Mode)はまだ RC 段階のため、末尾で補足します。 公式スタイルガイド(https://vuejs.org/style-guide/)を土台に、実務でよく使う指針をまとめています。
目次
- プロジェクト構成とツール
- コンポーネント設計
- Composition API と
<script setup> - リアクティビティ
- Props / Emits / v-model
- テンプレート
- Composables(ロジックの再利用)
- 状態管理(Pinia)
- ルーティング(Vue Router)
- TypeScript
- パフォーマンス
- スタイリング
- テスト
- セキュリティ
- アクセシビリティ
- 命名規則まとめ
- Vue 3.6 / Vapor Mode について
- チェックリスト
1. プロジェクト構成とツール
推奨ツールチェーン
| 用途 | 推奨 |
|---|---|
| プロジェクト作成 | npm create vue@latest(Vite ベース) |
| ビルド | Vite |
| 型チェック | TypeScript + vue-tsc |
| エディタ | VS Code + Vue (Official) 拡張 |
| Lint | ESLint + eslint-plugin-vue(flat config) |
| フォーマット | Prettier |
| 状態管理 | Pinia |
| ルーティング | Vue Router |
| テスト | Vitest + Vue Test Utils / Playwright |
| SSR・フルスタック | Nuxt |
Vue CLI(webpack ベース)はメンテナンスモードです。新規プロジェクトでは使わないでください。Vuex も新規では Pinia を選びます。
ディレクトリ構成例
src/
├── assets/ # 画像・フォントなど
├── components/ # 汎用 UI コンポーネント
│ ├── base/ # BaseButton, BaseInput など
│ └── ...
├── composables/ # useXxx 関数
├── features/ # 機能単位のまとまり(大規模向け)
│ └── user/
│ ├── components/
│ ├── composables/
│ └── api.ts
├── layouts/
├── pages/ (views/) # ルートに対応するコンポーネント
├── router/
├── stores/ # Pinia ストア
├── services/ (api/) # API クライアント
├── types/
├── utils/
├── App.vue
└── main.ts
- 小〜中規模は「種類別」(components / composables / stores)、大規模は「機能別」(features/xxx)に寄せると見通しが良くなります。
2. コンポーネント設計
単一責任
- 1コンポーネント = 1つの役割。テンプレートが長くなったら(目安 150〜200 行超)分割を検討します。
- 表示(Presentational) と ロジック(Container) を分けると再利用・テストがしやすくなります。
単一ファイルコンポーネント(SFC)
- 1ファイル1コンポーネント。
- ブロック順は統一します(推奨:
<script setup>→<template>→<style>)。
<script setup lang="ts">
// ロジック
</script>
<template>
<!-- マークアップ -->
</template>
<style scoped>
/* スタイル */
</style>コンポーネント名
- 複数語にする(HTML 要素と衝突しないため):
TodoItem○ /Item× - ファイル名は PascalCase:
UserProfileCard.vue - 基底コンポーネントは接頭辞で揃える:
BaseButton,BaseIcon - 1インスタンスしか存在しないものは
The接頭辞:TheHeader,TheSidebar - 親と密結合な子は親名を接頭辞に:
TodoList→TodoListItem
Slots を活用する
- 「props で見た目を細かく切り替える」より「slot で中身を差し込ませる」方が柔軟です。
- 名前付き slot / スコープ付き slot で、ロジックは持ちつつ描画を親に委ねられます。
<!-- DataList.vue -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot name="item" :item="item">{{ item.label }}</slot>
</li>
</ul>
</template>3. Composition API と <script setup>
新規コードは <script setup> + Composition API
- 型推論が効き、記述量が少なく、ロジックを composable に切り出しやすい。
- Options API も引き続きサポートされていますが、プロジェクト内でどちらかに統一するのが重要です。
コードの並び順(推奨)
関心ごとに「まとまり」を作り、以下のように並べると読みやすくなります。
<script setup lang="ts">
// 1. import
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
// 2. props / emits / model
const props = defineProps<{ userId: string }>()
const emit = defineEmits<{ saved: [id: string] }>()
// 3. store / composables
const userStore = useUserStore()
// 4. 状態
const isEditing = ref(false)
// 5. computed
const displayName = computed(() => userStore.user?.name ?? '名無し')
// 6. 関数
function save() { /* ... */ emit('saved', props.userId) }
// 7. watch / ライフサイクル
onMounted(() => userStore.fetch(props.userId))
</script>機能が増えてきたら「状態+computed+関数」を機能ごとにまとめ、composable へ切り出します。
4. リアクティビティ
ref を基本にする
- 公式も
ref()を主に使うことを推奨しています。reactive()はプリミティブを扱えず、分割代入や再代入でリアクティビティが失われる落とし穴があります。
// ✅
const count = ref(0)
const user = ref<User | null>(null)
// ⚠️ reactive の落とし穴
const state = reactive({ count: 0 })
let { count } = state // リアクティブでなくなる
state = reactive({ count: 1 }) // 参照が切れるcomputed は純粋に
- 副作用(API 呼び出し、DOM 操作、他の状態の変更)を書かない。
- 計算結果を直接書き換えない(必要なら writable computed を明示的に定義)。
watch / watchEffect
- 「値から値を作る」なら
computed。watchは 副作用(API 呼び出し、ログ、localStorage 保存など)専用。 - 監視対象を明示したいなら
watch、依存を自動追跡したいならwatchEffect。 - 非同期処理の競合は
onWatcherCleanup(3.5+)またはコールバック第3引数のonCleanupで処理します。
watch(keyword, async (q) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
results.value = await search(q, { signal: controller.signal })
})props の分割代入(3.5+)
- Vue 3.5 からは
definePropsの分割代入がリアクティブに保たれ、デフォルト値も書けます。
const { size = 'md', disabled = false } = defineProps<{
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
}>()
// watch する場合はゲッターで包む
watch(() => size, (v) => { /* ... */ })大きなデータには shallowRef / markRaw
- 巨大な配列や外部ライブラリのインスタンス(地図、チャートなど)を深くリアクティブにしない。
const rows = shallowRef<Row[]>([])
rows.value = [...rows.value, newRow] // 置き換えで更新を通知
const chart = markRaw(new Chart(el, config))テンプレート参照(3.5+)
useTemplateRef()を使うと、変数名とref属性名を分けられ、型推論も効きます。
const inputEl = useTemplateRef<HTMLInputElement>('input')
onMounted(() => inputEl.value?.focus())5. Props / Emits / v-model
Props
- 型と必須/任意を必ず定義する(配列形式
defineProps(['foo'])は避ける)。 - props を直接書き換えない(単方向データフロー)。変更したいときは emit で親に伝えるか、ローカルにコピーする。
- オブジェクト/配列のデフォルト値はファクトリ関数で(分割代入デフォルトなら不要)。
- 宣言は camelCase、テンプレートでは kebab-case でも可(DOM テンプレートでは kebab-case 必須)。
// ✅
const props = defineProps<{
title: string
items?: Item[]
}>()
// ❌ props.title = 'x'Emits
defineEmitsで 型付きで宣言 する。イベント名は動詞・過去形またはドメイン用語で(update:modelValue,submit,item-selected)。
const emit = defineEmits<{
submit: [payload: FormData]
cancel: []
}>()v-model は defineModel(3.4+)
- 双方向バインディングのボイラープレートが不要になります。
<script setup lang="ts">
const model = defineModel<string>({ required: true })
const checked = defineModel<boolean>('checked', { default: false })
</script>
<template>
<input v-model="model" />
<input type="checkbox" v-model="checked" />
</template>Provide / Inject
- 深い階層へのデータ受け渡し(props のバケツリレー回避)に使う。
InjectionKeyで型付け し、キーは別ファイルに Symbol で定義する。- 書き換えは provide した側の関数経由に限定し、値は
readonly()で渡すと安全です。
// keys.ts
export const ThemeKey: InjectionKey<Readonly<Ref<'light' | 'dark'>>> = Symbol('theme')6. テンプレート
v-for には必ず一意な :key
- インデックスを key にしない(並び替え・削除でバグの原因に)。
<li v-for="todo in todos" :key="todo.id">{{ todo.title }}</li>v-if と v-for を同じ要素に書かない
- Vue 3 では
v-ifが先に評価されるため、ループ変数を参照できません。 - フィルタは
computedで、表示切替はラッパー(<template>)で。
<!-- ✅ -->
<li v-for="user in activeUsers" :key="user.id">...</li>
<!-- ✅ -->
<template v-if="showList">
<li v-for="user in users" :key="user.id">...</li>
</template>テンプレートに複雑な式を書かない
- 2つ以上の操作や条件が絡む式は
computedか関数に移す。
<!-- ❌ -->
{{ items.filter(i => i.done).map(i => i.price).reduce((a, b) => a + b, 0) }}
<!-- ✅ -->
{{ totalDonePrice }}v-if と v-show の使い分け
- 頻繁に切り替える →
v-show(CSS で表示切替) - めったに切り替わらない/初期非表示が多い →
v-if(DOM ごと生成・破棄)
その他
- ディレクティブ省略記法(
:@#)はプロジェクト内で統一。 - 属性が多いときは1行1属性で改行。
- 自己終了タグ: SFC 内では中身のないコンポーネントは
<MyComp />と自己終了させる。
7. Composables(ロジックの再利用)
基本ルール
- 名前は
useで始める:useFetch,useMouse,useLocalStorage <script setup>/setup()の同期的なトップレベルで呼ぶ(awaitの後や条件分岐の中で呼ばない)。- 戻り値は
reactiveではなく ref を含むプレーンオブジェクト にする(分割代入してもリアクティブを保てる)。 - 引数は
MaybeRefOrGetterで受け、内部でtoValue()を使うと ref・ゲッター・生値のどれでも受けられます。 - 副作用(イベントリスナー、タイマー)は
onUnmounted/onScopeDisposeで必ず片付ける。
// composables/useFetch.ts
import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue'
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
watchEffect(async (onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
loading.value = true
error.value = null
try {
const res = await fetch(toValue(url), { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (e) {
if ((e as Error).name !== 'AbortError') error.value = e as Error
} finally {
loading.value = false
}
})
return { data, error, loading }
}- 汎用的なものは自作する前に VueUse を確認しましょう。
- Mixins は使わない(出所不明のプロパティ・名前衝突の原因)。composable に置き換えます。
8. 状態管理(Pinia)
何をストアに置くか
- グローバルに共有する状態だけをストアへ(認証ユーザー、カート、設定など)。
- 1コンポーネント内で完結する状態はローカル
refで十分。 - サーバーデータのキャッシュが中心なら TanStack Query (Vue Query) や Pinia Colada も検討。
Setup Store 形式を推奨
Composition API と同じ書き方ができ、composable も使えます。
// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() =>
items.value.reduce((sum, i) => sum + i.price * i.qty, 0)
)
function add(item: CartItem) {
items.value.push(item)
}
return { items, total, add }
})注意点
- ストアから分割代入するときは
storeToRefs()を使う(アクションはそのまま分割代入してOK)。
const cart = useCartStore()
const { items, total } = storeToRefs(cart)
const { add } = cart- ストアはドメイン単位で分割(
useUserStore,useCartStore)。巨大な1ストアは避ける。 - 状態の変更はアクション経由に寄せると追跡しやすい。
- Setup Store では、外部から見せたい状態はすべて
returnする(返さないと DevTools や SSR で問題が出る)。
9. ルーティング(Vue Router)
- ページコンポーネントは遅延読み込み する。
const routes = [
{ path: '/', component: () => import('@/pages/HomePage.vue') },
{
path: '/users/:id',
name: 'user-detail',
component: () => import('@/pages/UserDetailPage.vue'),
props: true, // route params を props として受け取る
},
]props: trueでルートパラメータを props として受け取ると、コンポーネントがuseRoute()に依存せずテストしやすくなります。- 遷移は名前付きルート(
{ name: 'user-detail', params: { id } })を使うと、パス変更に強くなります。 - 認証チェックなどはナビゲーションガード(
router.beforeEach)に集約し、metaで制御。 - 同じコンポーネントでパラメータだけ変わる場合、
onMountedは再実行されないのでwatchで params を監視する。 - 型安全なルーティングが必要なら
unplugin-vue-router(ファイルベース)も選択肢。
10. TypeScript
<script setup lang="ts">を標準に。defineProps/defineEmits/defineModelは型ベース宣言で。- 型チェックは
vue-tsc --noEmitを CI に組み込む(Vite はビルド時に型チェックしない)。 anyを避け、API レスポンスには型(必要なら Zod などでランタイム検証)を付ける。- ジェネリックコンポーネントは
generic属性で。
<script setup lang="ts" generic="T extends { id: string | number }">
defineProps<{ items: T[] }>()
defineEmits<{ select: [item: T] }>()
</script>tsconfigではstrict: trueを有効にする。
11. パフォーマンス
バンドルサイズ
- ルート単位・重いコンポーネント単位で遅延読み込み(
defineAsyncComponent, 動的import())。 - ライブラリは tree-shaking 可能なもの(ESM)を選ぶ。
lodashよりlodash-esやネイティブ関数。 vite build後にrollup-plugin-visualizerなどでバンドルを確認する。
レンダリング
- 子コンポーネントに渡す props は安定させる(不要に変化する値を渡さない)。
<!-- ❌ activeId が変わると全 item が再描画 -->
<ListItem v-for="item in list" :key="item.id" :id="item.id" :active-id="activeId" />
<!-- ✅ 変化するのは 2 つの item だけ -->
<ListItem v-for="item in list" :key="item.id" :id="item.id" :active="item.id === activeId" />- 静的なコンテンツは
v-once、条件付きでのみ更新したいリスト項目はv-memo。 - 数千件を超えるリストは 仮想スクロール(
vue-virtual-scroller,@tanstack/vue-virtualなど)。 - 巨大データは
shallowRef/shallowReactive/markRaw(4. リアクティビティ参照)。 computedは依存が変わらない限りキャッシュされる。テンプレート内のメソッド呼び出しよりcomputedを優先。- 画面外のコンポーネントの状態保持には
<KeepAlive>(maxで上限を設定)。
非同期
<Suspense>はまだ実験的機能扱いです。本番で使う場合は挙動を理解した上で。
SSR / SEO
- SEO や初期表示速度が重要なら Nuxt(SSR / SSG)を検討。
- Vue 3.5 の Lazy Hydration(
hydrateOnVisibleなど)で、画面外のコンポーネントの hydration を遅延できます。
12. スタイリング
- コンポーネントのスタイルは
<style scoped>か CSS Modules(<style module>)でスコープを限定する。 - グローバル CSS はリセット・変数・タイポグラフィなど最小限に。
- 子コンポーネント内部の上書きが必要なら
:deep()を使うが、多用しない(props や CSS 変数で調整できる設計にする)。 - テーマや動的な値は CSS カスタムプロパティか
v-bind()in CSS で。
<script setup lang="ts">
const { color = 'tomato' } = defineProps<{ color?: string }>()
</script>
<style scoped>
.title { color: v-bind(color); }
</style>- Tailwind CSS を使う場合も、繰り返すパターンは
BaseButtonなどのコンポーネントにまとめる。
13. テスト
| 種類 | ツール | 対象 |
|---|---|---|
| ユニット | Vitest | composables、utils、ストア |
| コンポーネント | Vitest + Vue Test Utils(または Testing Library) | props / emits / 描画結果 |
| E2E | Playwright(または Cypress) | 主要なユーザーフロー |
指針
- 実装ではなく振る舞いをテストする(内部の変数ではなく、表示・emit されたイベントを検証)。
- 要素の取得は
data-testidやロール・ラベルで(CSS クラスに依存しない)。 - composable は単体でテスト可能に設計する(ライフサイクルを使うものはテスト用ホストコンポーネントで包む)。
- Pinia は
@pinia/testingのcreateTestingPinia()でモック。 - API は MSW などでモックする。
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
test('クリックでカウントが増え、change が emit される', async () => {
const wrapper = mount(Counter, { props: { initial: 1 } })
await wrapper.get('button').trigger('click')
expect(wrapper.text()).toContain('2')
expect(wrapper.emitted('change')?.[0]).toEqual([2])
})14. セキュリティ
v-htmlにユーザー入力を渡さない。必要なら DOMPurify などでサニタイズする。:hrefにユーザー入力の URL を渡す場合、javascript:スキームを弾く(サーバー側でも検証)。- ユーザー入力からテンプレートを動的に生成しない(ランタイムコンパイルは XSS の温床)。
:styleにユーザー入力をそのまま渡さない(クリックジャッキング等の恐れ)。- API キーなどの秘密情報をフロントエンドに置かない(
VITE_で始まる環境変数はバンドルに含まれ公開される)。 - 依存パッケージは定期的に
npm auditや Renovate / Dependabot で更新。
15. アクセシビリティ
- セマンティックな HTML を使う(クリック可能なものは
<div @click>ではなく<button>)。 - フォーム要素には
<label>を関連付ける。 - ルート遷移時にフォーカスやページタイトル(
document.title)を更新し、スクリーンリーダーに変化を伝える。 - モーダルはフォーカストラップと
Escで閉じる操作を実装(<Teleport>で body 直下に描画)。 aria-*属性は必要な場面で正しく(ネイティブ要素で表現できるならそちら優先)。- ESLint の
eslint-plugin-vuejs-accessibilityで機械的に検出できる問題を潰す。
16. 命名規則まとめ
| 対象 | 規則 | 例 |
|---|---|---|
| コンポーネントファイル | PascalCase・複数語 | UserCard.vue |
| テンプレート内のコンポーネント(SFC) | PascalCase | <UserCard /> |
| 基底コンポーネント | Base 接頭辞 | BaseButton |
| シングルトン | The 接頭辞 | TheHeader |
| props 宣言 | camelCase | userName |
| props(テンプレート) | kebab-case 推奨 | user-name="..." |
| イベント | camelCase 宣言 / kebab-case 使用 | emit('itemSelected') / @item-selected |
| composable | use + camelCase | useAuth |
| Pinia ストア | use + 名詞 + Store | useCartStore |
| boolean | is / has / can 接頭辞 | isLoading, hasError |
17. Vue 3.6 / Vapor Mode について
- Vapor Mode は、テンプレートを仮想 DOM を経由せず直接 DOM 操作するコードへコンパイルする新しいモードです。バンドルサイズとメモリ・更新コストを削減します。
- 2026年10月時点で Vue 3.6 は RC(プレリリース) で、安定版の最新は 3.5 系です。
- コンポーネント単位のオプトイン(
<script setup vapor>など)で、Options API は非対応。 - 3.6 ではリアクティビティシステムも alien-signals ベースに刷新され、性能とメモリ効率が向上しています。
- 現時点の推奨: 本番の既存コンポーネントを一斉移行しない。性能が重要な一部ページで試す、または小規模な新規アプリで試す程度に留める。
- 将来に備える意味でも、
<script setup>+ Composition API で書いておく ことが最も移行しやすい選択です。
18. チェックリスト
コードレビューやセルフチェックに使えます。
-
<script setup lang="ts">+ Composition API で統一されている - コンポーネント名は複数語・PascalCase
- props は型付きで宣言され、直接書き換えていない
- emits が型付きで宣言されている
- v-model は
defineModelを使っている -
v-forに一意な:key(インデックス以外)がある -
v-ifとv-forを同じ要素に書いていない - テンプレートの複雑な式は
computedに移している -
computedに副作用がない -
watchは副作用のためだけに使い、非同期処理はクリーンアップしている - イベントリスナー・タイマーはアンマウント時に解除している
- 共有ロジックは composable、共有状態は Pinia に分離されている
- Pinia から分割代入する際
storeToRefsを使っている - ページコンポーネントは遅延読み込みされている
- 巨大データに
shallowRef/markRawを検討した - スタイルは
scopedまたは CSS Modules -
v-htmlにサニタイズされていない入力を渡していない - 秘密情報をフロントエンドに含めていない
- ESLint(
eslint-plugin-vue)とvue-tscが CI で通っている - 主要な振る舞いにテストがある
参考リンク
- Vue 公式ドキュメント: https://vuejs.org/
- Vue スタイルガイド: https://vuejs.org/style-guide/
- パフォーマンスガイド: https://vuejs.org/guide/best-practices/performance.html
- セキュリティガイド: https://vuejs.org/guide/best-practices/security.html
- Pinia: https://pinia.vuejs.org/
- Vue Router: https://router.vuejs.org/
- VueUse: https://vueuse.org/
- eslint-plugin-vue: https://eslint.vuejs.org/