言語を切り替える
テーマを切り替える

Vue 3 + TypeScript ベストプラクティス:2025年版エンタープライズ構成ガイド

先月、チームで技術選定の会議をしました。状態管理、ディレクトリ構成、TypeScript 設定について議論が白熱し、なかなか結論が出ませんでした。私が初めて Vue 3 + TypeScript を組み立てたときは、tsconfig.json を 20 回以上書き直し、別の PC に持っていくとまたエラーになる——そんな経験もあります。

この記事は、私たちが数え切れないほどつまずいたあとにまとめた構成案です。最適解だとまでは言いませんが、少なくとも 3 つの中大型プロジェクトで大きなトラブルなく回っています。新規プロジェクトの立ち上げや、既存プロダクトの構成見直しの参考になれば幸いです。


2025年の Vue 3 技術スタック選定

まずスタック選定から。Vue 3 はリリースから数年経ち、2025 年に何を使うかはかなり見えています。

私たちの標準構成は Vite + Vue 3 + TypeScript + Pinia + Vue Router 4 です。すでに使っている方も多いでしょうが、なぜこの組み合わせかを整理します。

Vite は言うまでもなく、Webpack より開発体験がよく、HMR はほぼ秒級です。Vue 3.6 はまだ alpha ですが、Evan You が Vue.js Nation 2025 で示した数字は印象的でした——Vapor Mode なら 100ms 以内に 10 万コンポーネントをマウントできる、とのこと。本番で使える段階ではありませんが、方向性は明確です。

Pinia について触れます。API を見たときの第一印象は「これが状態管理のあるべき姿だ」でした。比較は次のとおりです。

// Vuex(冗長)
const store = createStore({
  state: () => ({ count: 0 }),
  mutations: {
    increment(state) { state.count++ }
  },
  actions: {
    incrementAsync({ commit }) {
      setTimeout(() => commit('increment'), 1000)
    }
  }
})
// Pinia(すっきり)
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const increment = () => count.value++
  const incrementAsync = () => setTimeout(increment, 1000)
  return { count, increment, incrementAsync }
})
1.5KB
Pinia のサイズ
Vuex より軽量。Vuex 5 の開発はほぼ停滞しており、新規プロジェクトは Pinia 推奨

Pinia はおおよそ 1.5KB 程度。Vuex 5 の開発はほぼ止まっています。既存プロジェクトが Vuex に深く依存していて移行予定がない場合を除き、新規で Vuex を選ぶ理由はあまりありません。


プロジェクト・ディレクトリ構造

components に全部突っ込むプロジェクトを何度も見ました。3 か月後には自分でもファイルが見つかりません。

小規模なら雑でも回りますが、規模が大きくなると規約なしは災害です。私たちが試行錯誤の末に落ち着いたのが次の構成です。

src/
├── api/                  # API 層
│   ├── modules/          # 業務モジュール単位
│   │   ├── user.ts
│   │   └── order.ts
│   └── index.ts
├── assets/               # 静的アセット
│   ├── images/
│   └── styles/
├── components/           # グローバル共通コンポーネント
│   ├── base/             # Button、Input など基礎部品
│   └── business/         # 業務共通コンポーネント
├── composables/          # コンポーザブル
│   ├── useAuth.ts
│   └── useRequest.ts
├── layouts/              # レイアウト
├── router/               # ルーティング
│   ├── modules/          # ルートモジュール
│   └── index.ts
├── stores/               # Pinia
│   ├── modules/
│   └── index.ts
├── types/                # グローバル型
│   ├── api.d.ts
│   └── global.d.ts
├── utils/                # ユーティリティ
├── views/                # ページ
│   ├── user/
│   └── order/
├── App.vue
└── main.ts

ポイントは次のとおりです。

composables と utils の違い:composables には useAuthuseRequest のようにリアクティブなロジックを持つ関数を置きます。utils には formatDatedebounce のような純粋関数を置きます。混ぜると後からメンテがつらくなります。

ファイル種別ではなく機能ドメインで分割apistoresviews の中をさらに業務モジュールで分けます。ユーザー関連は user 配下に揃うので、探しやすくなります。

types は独立:グローバル型は types に置き、コンポーネント固有の型はそのファイル内で十分です。型を全部 types に寄せると、かえって散らかります。


TypeScript 型定義のベストプラクティス

型の体操は確かにハードルがありますが、次の場面を押さえれば大半は足ります。

まず tsconfig.json。次は必須に近い設定です。

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "lib": ["ESNext", "DOM"],
    "skipLibCheck": true,
    "noEmit": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

strict: true は必須です。最初はエラーが大量に出ますが、長期的には得です。moduleResolution: "bundler" は比較的新しいオプションで、Vite と相性がよいです。

Vue コンポーネントの型は defineProps<Props>() に慣れるまで一瞬戸惑いますが、コンパイラがマクロで処理してくれます。

<script setup lang="ts">
// Props 型
interface Props {
  title: string
  count?: number
  items: string[]
}
const props = defineProps<Props>()
// デフォルト付き Props
const propsWithDefaults = withDefaults(defineProps<Props>(), {
  count: 0,
  items: () => []
})
// Emits 型
interface Emits {
  (e: 'update', value: string): void
  (e: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
// より簡潔な書き方(Vue 3.3+)
const emit2 = defineEmits<{
  update: [value: string]
  delete: [id: number]
}>()
</script>

.vue の型解決で IDE がモジュールを見つけられない場合は、src 配下に env.d.ts または shims-vue.d.ts を置きます。

/// <reference types="vite/client" />
declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

Pinia 状態管理の実践

Vuex の mutations に慣れたあと、Pinia で state を直接書き換えると「ルール違反?」と感じることがあります。mutations は Flux 由来の制約で、Pinia はそれを外した設計です。

Store は Composition API スタイルを推奨します。

// stores/modules/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getUserInfo, login } from '@/api/modules/user'
export const useUserStore = defineStore('user', () => {
  // state
  const token = ref<string>('')
  const userInfo = ref<UserInfo | null>(null)
  // getters
  const isLoggedIn = computed(() => !!token.value)
  const userName = computed(() => userInfo.value?.name ?? 'ゲスト')
  // actions
  const setToken = (newToken: string) => {
    token.value = newToken
    localStorage.setItem('token', newToken)
  }
  const fetchUserInfo = async () => {
    try {
      const res = await getUserInfo()
      userInfo.value = res.data
    } catch (error) {
      console.error('ユーザー情報の取得に失敗', error)
    }
  }
  const logout = () => {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
  }
  return {
    token,
    userInfo,
    isLoggedIn,
    userName,
    setToken,
    fetchUserInfo,
    logout
  }
})

使うときの落とし穴——分割代入でリアクティブが失われます。storeToRefs() を使います。

import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// ❌ リアクティブが失われる
const { userName, isLoggedIn } = userStore
// ✅ 正しい
const { userName, isLoggedIn } = storeToRefs(userStore)
// actions は普通の関数なのでそのまま分割代入可
const { logout, fetchUserInfo } = userStore

永続化は pinia-plugin-persistedstate が手軽です。

// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// store 側で有効化
export const useUserStore = defineStore('user', () => {
  // ...
}, {
  persist: true  // または詳細オプション
})

Vue Router 4:型安全なルーティング

beforeEach にロジックを詰め込みすぎると手放せなくなります。以前のプロジェクトでは beforeEach が 200 行近くまで膨らみ、権限・計測・タイトル設定が全部入っていました。ガードを分割してようやく保守できました。

ルート設定の型定義から。

// router/index.ts
import type { RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
// meta 型の拡張
declare module 'vue-router' {
  interface RouteMeta {
    title?: string
    requiresAuth?: boolean
    roles?: string[]
  }
}
const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/home/index.vue'),
    meta: {
      title: 'ホーム',
      requiresAuth: false
    }
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('@/views/dashboard/index.vue'),
    meta: {
      title: 'ダッシュボード',
      requiresAuth: true,
      roles: ['admin', 'editor']
    }
  }
]
const router = createRouter({
  history: createWebHistory(),
  routes
})
export default router

認可ガードの例です。

// router/guards/auth.ts
import type { Router } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
export function setupAuthGuard(router: Router) {
  router.beforeEach((to, from, next) => {
    const userStore = useUserStore()
    // ログイン不要なら通過
    if (!to.meta.requiresAuth) {
      next()
      return
    }
    // 未ログインはログインへ
    if (!userStore.isLoggedIn) {
      next({ path: '/login', query: { redirect: to.fullPath } })
      return
    }
    // ロールチェック
    const { roles } = to.meta
    if (roles && roles.length > 0) {
      const hasRole = roles.some(role => userStore.userInfo?.roles?.includes(role))
      if (!hasRole) {
        next({ path: '/403' })
        return
      }
    }
    next()
  })
}

ルートは業務ドメインごとにファイル分割し、index.ts でマージするのがおすすめです。

// router/modules/user.ts
export const userRoutes: RouteRecordRaw[] = [
  {
    path: '/user',
    name: 'User',
    component: () => import('@/layouts/BasicLayout.vue'),
    children: [
      {
        path: 'profile',
        name: 'UserProfile',
        component: () => import('@/views/user/profile.vue')
      }
    ]
  }
]

コード規約とエンジニアリング設定

ESLint 9 の flat config へ移行したとき、旧 .eslintrc は丸ごと使えなくなり、半日かかりました。新しい設定は次のような形です。

// eslint.config.js
import js from '@eslint/js'
import vue from 'eslint-plugin-vue'
import typescript from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import vueParser from 'vue-eslint-parser'
export default [
  js.configs.recommended,
  ...vue.configs['flat/recommended'],
  {
    files: ['**/*.{ts,tsx,vue}'],
    languageOptions: {
      parser: vueParser,
      parserOptions: {
        parser: tsParser,
        sourceType: 'module'
      }
    },
    plugins: {
      '@typescript-eslint': typescript
    },
    rules: {
      'vue/multi-word-component-names': 'off',
      '@typescript-eslint/no-unused-vars': 'warn',
      '@typescript-eslint/no-explicit-any': 'warn'
    }
  }
]

Prettier との衝突は eslint-config-prettier で ESLint 側の競合ルールをオフにします。

unplugin-auto-import は Vue / Vue Router / Pinia の API を自動 import してくれます。

// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
export default defineConfig({
  plugins: [
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/auto-imports.d.ts',
      eslintrc: {
        enabled: true
      }
    })
  ]
})

設定後は ref()computed() を毎回 import しなくてよくなります。

Git フックは husky + lint-staged で、コミット前に lint を回します。

// package.json
{
  "scripts": {
    "prepare": "husky install",
    "lint": "eslint . --fix",
    "lint-staged": "lint-staged"
  },
  "lint-staged": {
    "*.{js,ts,vue}": ["eslint --fix", "prettier --write"]
  }
}

おわりに

Vue 3 登場直後は、Composition API を使うか Options API に留まるかで議論が続き、チュートリアルの質もばらついていました。当時はつまずきが日常でした。

この構成がすべてのプロジェクトに合うわけではありません。小規模なら provide / inject で足りることも多く、ディレクトリも細かく分けなくてよい場合があります。中〜大規模で 3 人以上が協業するなら、かなりの手戻りを減らせるはずです。

銀の弾はありません。大事なのはチームで合意し、規約を守り続けることです。あなたのチームの Vue 3 構成も、ぜひコメントで教えてください。

Vue 3 + TypeScript エンタープライズ構成の構築手順

プロジェクト初期化からコード規約まで、Vite・Pinia・Vue Router・ESLint などのベストプラクティスを一通り設定する手順

Estimated time: PT4H

  1. 1

    Step 1: プロジェクト初期化とスタック選定

    Vite プロジェクトの作成:
  2. 2

    Step 2: プロジェクト・ディレクトリ構造の設計

    機能ドメインで分割:
  3. 3

    Step 3: TypeScript 型定義の設定

    tsconfig.json の要点:
  4. 4

    Step 4: Pinia 状態管理の設定

    Composition API スタイルで Store を定義:
  5. 5

    Step 5: const userInfo = ref<UserInfo

    null>(null);
  6. 6

    Step 6: Vue Router 4 の型安全ルーティング

    meta 型の拡張:
  7. 7

    Step 7: ESLint 9 とコード規約の設定

    ESLint 9 は flat config:

FAQ

Pinia と Vuex の違いは?なぜ Pinia を推奨するのか?
Pinia は約 1.5KB と軽量で、Vuex 5 の開発はほぼ停滞しています。

API の比較:

Vuex は state、mutations、actions の 3 層が必要:
const store = createStore({
state: () => ({ count: 0 }),
mutations: { increment(state) { state.count++ } },
actions: { incrementAsync({ commit }) { setTimeout(() => commit('increment'), 1000) } }
})

Pinia は Composition API スタイルで簡潔:
export const useCounterStore = defineStore('counter', () => {
const count = ref(0);
const increment = () => count.value++;
const incrementAsync = () => setTimeout(increment, 1000);
return { count, increment, incrementAsync };
})

Pinia の利点:
• mutations が不要(state を直接更新できる)
• TypeScript サポートが強い
• バンドルサイズが小さい
• API が直感的

既存プロジェクトが Vuex に深く依存していて移行予定がない場合を除き、新規で Vuex を選ぶ理由はあまりありません。
Vue 3 のディレクトリ構造はどう設計する?composables と utils の違いは?
機能ドメインで分割し、ファイル種別だけで分けない:

• src/api/modules/(user.ts、order.ts など)
• src/components/base/ と business/
• src/composables/(useAuth、useRequest などリアクティブロジック)
• src/utils/(formatDate、debounce など純粋関数)
• src/stores/modules/
• src/views/(user/、order/ など)
• src/types/(グローバル型)

composables と utils の違い:
• composables:リアクティブなロジックを持つ再利用関数
• utils:入出力だけの純粋関数
• 混ぜると後からメンテがつらい

機能ドメイン分割の利点:
• api、stores、views を業務モジュールでさらに分ける
• ユーザー関連は user 配下に揃い、探しやすい
Vue 3 で TypeScript はどう設定する?要点は?
tsconfig.json の要点:
• strict: true(最初はエラーが多いが長期的に必須)
• moduleResolution: 'bundler'(Vite と相性がよい比較的新しいオプション)
• paths で @/* エイリアス

Vue コンポーネントの型:
• defineProps<Props>():
interface Props { title: string; count?: number; items: string[] }
const props = defineProps<Props>()
• デフォルトは withDefaults(defineProps<Props>(), { count: 0, items: () => [] })
• Emits は defineEmits<Emits>() または
const emit2 = defineEmits<{ update: [value: string]; delete: [id: number] }>()

.vue の型認識:
src に env.d.ts または shims-vue.d.ts:
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}

IDE がモジュールを見つけられない場合、多くはこのファイル未設定が原因です。
Pinia の使い方と注意点は?
Composition API スタイルでの Store 定義を推奨:
defineStore('user', () => {
const token = ref<string>('');
const userInfo = ref<UserInfo | null>(null);
const isLoggedIn = computed(() => !!token.value);
const fetchUserInfo = async () => {
try {
const res = await getUserInfo();
userInfo.value = res.data;
} catch (error) {
console.error('ユーザー情報の取得に失敗', error);
}
};
return { token, userInfo, isLoggedIn, fetchUserInfo };
})

注意点:
• リアクティブな値は storeToRefs() で分割代入:const { userName, isLoggedIn } = storeToRefs(userStore)
• actions は普通の関数なのでそのまま分割代入可:const { logout, fetchUserInfo } = userStore

永続化:
• pinia-plugin-persistedstate をインストール
• main.ts で pinia.use(piniaPluginPersistedstate)
• store で persist: true または詳細オプション

Vuex の mutations に慣れると違和感がありますが、mutations は Flux 由来の制約で、Pinia はそれを外した設計です。
ESLint 9 の flat config はどう書く?旧版との違いは?
ESLint 9 では .eslintrc 系は使えず、flat config に移行します。

新しい設定:
• eslint.config.js を作成
• js.configs.recommended と vue.configs['flat/recommended']
• files: ['**/*.{ts,tsx,vue}']
• languageOptions.parser は vue-eslint-parser
• parserOptions.parser は @typescript-eslint/parser
• plugins に @typescript-eslint
• rules で個別ルール('vue/multi-word-component-names': 'off' など)

Prettier 連携:
• eslint-config-prettier で競合ルールをオフ

自動 import:
• unplugin-auto-import をインストール
• vite.config.ts で AutoImport({ imports: ['vue', 'vue-router', 'pinia'], dts: 'src/auto-imports.d.ts' })
• ref()、computed() の手動 import が不要に

Git 規約:
• husky + lint-staged
• package.json の lint-staged("*.{js,ts,vue}": ["eslint --fix", "prettier --write"])
• コミット前に lint を自動実行
Vue Router 4 で型安全なルートと権限ガードをどう設定する?
meta 型の拡張:
declare module 'vue-router' {
interface RouteMeta {
title?: string;
requiresAuth?: boolean;
roles?: string[];
}
}

ルート設定:
• RouteRecordRaw[] を使用
• meta に title、requiresAuth、roles を定義

認可ガード:
• router/guards/auth.ts に setupAuthGuard
• router.beforeEach で to.meta.requiresAuth を確認(不要なら通過)
• 未ログイン:next({ path: '/login', query: { redirect: to.fullPath } })
• roles チェック:const hasRole = roles.some(role => userStore.userInfo?.roles?.includes(role))
• 権限不足は 403 へ

ルートのモジュール化:
• 業務ドメインごとにファイル分割(router/modules/user.ts)
• index.ts でマージ

beforeEach に全部詰め込むと 200 行近くまで膨らむことがあります。権限・計測・タイトルはガードを分割した方が保守しやすいです。

4分で読めます · 公開日: 2025年11月24日 · 更新日: 2026年6月8日

関連記事

コメント

GitHubアカウントでログインしてコメントできます