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 }
})
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 には useAuth や useRequest のようにリアクティブなロジックを持つ関数を置きます。utils には formatDate や debounce のような純粋関数を置きます。混ぜると後からメンテがつらくなります。
ファイル種別ではなく機能ドメインで分割:api、stores、views の中をさらに業務モジュールで分けます。ユーザー関連は 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
Step 1: プロジェクト初期化とスタック選定
Vite プロジェクトの作成: -
2
Step 2: プロジェクト・ディレクトリ構造の設計
機能ドメインで分割: -
3
Step 3: TypeScript 型定義の設定
tsconfig.json の要点: -
4
Step 4: Pinia 状態管理の設定
Composition API スタイルで Store を定義: -
5
Step 5: const userInfo = ref<UserInfo
null>(null); -
6
Step 6: Vue Router 4 の型安全ルーティング
meta 型の拡張: -
7
Step 7: ESLint 9 とコード規約の設定
ESLint 9 は flat config:
FAQ
Pinia と Vuex の違いは?なぜ Pinia を推奨するのか?
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 はどう設定する?要点は?
• 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 の使い方と注意点は?
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.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 で型安全なルートと権限ガードをどう設定する?
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日
フロントエンドフレームワーク比較
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
React 19 のフォーム、まだ 30 行?Actions で一気に短縮、パフォーマンス 40% 向上
React 19 の Actions、use() Hook、コンパイラーなど 6 つの主要機能を深掘り解説。実践コード比較でフォーム処理の簡素化、パフォーマンス最適化、Server Components の活用法を紹介。1 週間の実践経験を共有し、すぐに試せる内容です。
第 4 / 6 記事
次の記事
フロントエンド性能最適化の実践:Core Web Vitals満点攻略
Core Web Vitalsの3大指標(LCP/INP/CLS)の改善方法を体系的に解説。画像最適化、コード分割、遅延読み込みなど10以上の実戦テクニックを紹介。2週間でLighthouseスコアを60から90以上に引き上げる完全チェックリストと落とし穴回避ガイド付き。
第 6 / 6 記事
関連記事
2025年版ブログフレームワーク選定ガイド:Hugo、Astro、Hexo どれを選ぶ?
2025年版ブログフレームワーク選定ガイド:Hugo、Astro、Hexo どれを選ぶ?
Astro 5 でブログを再構築し、Lighthouse スコアを 68 から 100 に
Astro 5 でブログを再構築し、Lighthouse スコアを 68 から 100 に
React に疲れた?Svelte 5 でコード半減・性能 2 倍(完全チュートリアル付き)
コメント
GitHubアカウントでログインしてコメントできます