Vue3 + TypeScript エンタープライズ開発実践ガイド

前端工程

なぜ Vue3 + TypeScript が 2026 年のエンタープライズ標準なのか

2026 年、Vue3 + TypeScript は中規模から大規模のフロントエンドプロジェクトにおける圧倒的な主流選択となっています。Vue3 の Composition API はより柔軟なロジック再利用を提供し、TypeScript は大規模プロジェクトに型安全性と優れたリファクタリング体験をもたらします。この2つの組み合わせにより、チームコラボレーションの効率とコード品質が大幅に向上しています。

コアアドバンテージ

  • 型安全性:コンパイル時に80%以上の初歩的なエラーを検出し、本番バグを削減
  • 優れた IDE サポート:VSCode + Volar による正確なインテリセンスとナビゲーション
  • ロジック再利用:Composition API + カスタムフックが Mixins に代わり、名前衝突を解消
  • パフォーマンス向上:Vue3 の Proxy リアクティビティ + Tree-shaking でバンドルサイズを40%以上削減
  • 成熟したエコシステム:Pinia、Vue Router 4、Vite がすべて TypeScript をファーストクラスでサポート

Vue2 + Options API との比較

// Vue2 Options API - ロジックが data/methods/computed に分散
export default {
  data() {
    return { searchQuery: '', results: [] }
  },
  methods: {
    async fetchResults() {
      this.results = await api.search(this.searchQuery)
    }
  },
  computed: {
    filteredResults() {
      return this.results.filter(r => r.active)
    }
  }
}

// Vue3 Composition API - ロジックが凝集し、再利用可能
function useSearch() {
  const searchQuery = ref('')
  const results = ref<SearchResult[]>([])

  async function fetchResults() {
    results.value = await api.search(searchQuery.value)
  }

  const filteredResults = computed(() =>
    results.value.filter(r => r.active)
  )

  return { searchQuery, results, fetchResults, filteredResults }
}

Vite によるプロジェクトスキャフォールディング

Vite は 2026 年の Vue3 プロジェクトの標準ビルドツールであり、Webpack より10〜20倍高速なコールドスタートを実現します。

プロジェクトの初期化

# create-vue を使用(公式推奨)
npm create vue@latest my-enterprise-app

# 機能選択時に以下をチェック:
# ✅ TypeScript
# ✅ Vue Router
# ✅ Pinia
# ✅ Vitest
# ✅ ESLint + Prettier

cd my-enterprise-app
npm install

エンタープライズディレクトリ構造

src/
├── assets/              # 静的アセット
├── components/          # 共通コンポーネント
│   ├── base/            # 基本コンポーネント(Button, Input, Modal)
│   └── business/        # ビジネスコンポーネント
├── composables/         # コンポーザブル関数(useXxx)
├── layouts/             # レイアウトコンポーネント
├── pages/               # ページコンポーネント(ルート別に整理)
├── router/              # ルーター設定
├── stores/              # Pinia ステート管理
├── types/               # グローバル型定義
│   ├── api.d.ts         # API レスポンス型
│   ├── env.d.ts         # 環境変数型
│   └── global.d.ts      # グローバル型拡張
├── utils/               # ユーティリティ関数
├── api/                 # API リクエスト層
├── App.vue
└── main.ts

主要設定ファイル

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  },
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true
      }
    }
  },
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          'ui-vendor': ['element-plus']
        }
      }
    }
  }
})

💡 JSON フォーマッター を使用して、設定ファイルの構文を確認できます。


Composition API コアパターン

setup シンタックスシュガー

<script setup> は 2026 年の標準的な書き方です。コンパイラが自動的に処理するため、手動での return は不要です。

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'

const count = ref(0)
const doubled = computed(() => count.value * 2)

function increment() {
  count.value++
}

onMounted(() => {
  console.log('コンポーネントがマウントされました、初期値:', count.value)
})
</script>

<template>
  <div class="flex items-center gap-4">
    <span class="text-2xl font-bold">{{ count }}</span>
    <span class="text-gray-500">×2 = {{ doubled }}</span>
    <button
      class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      @click="increment"
    >
      +1
    </button>
  </div>
</template>

カスタムコンポーザブル

Vue3 のロジック再利用のコアパターンであり、Vue2 の Mixins に代わるものです。

// composables/usePagination.ts
import { ref, computed, type Ref } from 'vue'

interface UsePaginationOptions {
  total: Ref<number>
  pageSize?: number
}

export function usePagination(options: UsePaginationOptions) {
  const { total, pageSize = 10 } = options
  const currentPage = ref(1)

  const totalPages = computed(() =>
    Math.ceil(total.value / pageSize)
  )

  const paginatedRange = computed(() => ({
    start: (currentPage.value - 1) * pageSize,
    end: currentPage.value * pageSize
  }))

  function goToPage(page: number) {
    if (page >= 1 && page <= totalPages.value) {
      currentPage.value = page
    }
  }

  function nextPage() {
    goToPage(currentPage.value + 1)
  }

  function prevPage() {
    goToPage(currentPage.value - 1)
  }

  return {
    currentPage,
    totalPages,
    paginatedRange,
    goToPage,
    nextPage,
    prevPage
  }
}

リアクティビティシステムの内部動作

ref vs reactive vs computed

import { ref, reactive, computed } from 'vue'

// ref - プリミティブ型に推奨、.value でアクセス
const username = ref('田中')
console.log(username.value) // '田中'

// reactive - オブジェクト型、.value 不要、ただしオブジェクト全体の置き換えは不可
const userForm = reactive({
  name: '田中',
  age: 25,
  address: {
    city: '東京'
  }
})
userForm.name = '佐藤' // 直接代入

// ❌ エラー:reactive オブジェクト全体の置き換えは不可
// userForm = { name: '鈴木', age: 30 }

// computed - 派生状態、自動キャッシュ
const fullName = computed(() =>
  `${userForm.name} - ${userForm.address.city}`
)

選択のガイドライン

シナリオ 推奨 理由
プリミティブ値 ref reactive はプリミティブをサポートしない
フォームオブジェクト reactive .value 不要、より簡潔な構文
関数から返す場合 ref 値全体の置き換え可能、分割代入でリアクティビティを維持
派生計算 computed 依存追跡による自動キャッシュ
コレクション型(Map/Set) reactive ネイティブサポート

リアクティビティ喪失のよくある落とし穴

// ❌ reactive の分割代入はリアクティビティを失う
const state = reactive({ count: 0, name: 'test' })
const { count } = state // count はもうリアクティブではない!

// ✅ toRefs を使ってリアクティビティを維持
import { toRefs } from 'vue'
const { count, name } = toRefs(state) // リアクティブに維持

// ❌ 関数で reactive のプロパティを返す
function getUser() {
  const state = reactive({ user: null as User | null })
  return state.user // リアクティビティを失う
}

// ✅ ref を返す
function getUser() {
  const user = ref<User | null>(null)
  return { user } // リアクティビティを維持
}

TypeScript 統合ベストプラクティス

defineProps と defineEmits の型推論

<script setup lang="ts">
// 方法1:ジェネリック宣言(推奨)
interface Props {
  title: string
  count?: number
  items: string[]
  status: 'active' | 'inactive'
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  status: 'active'
})

// 方法2:ランタイム宣言(非推奨、型推論が不完全)
// const props = defineProps({
//   title: { type: String, required: true },
//   count: { type: Number, default: 0 }
// })

// defineEmits 型宣言
interface Emits {
  (e: 'update', value: string): void
  (e: 'delete', id: number): void
  (e: 'change', payload: { field: string; value: unknown }): void
}

const emit = defineEmits<Emits>()

emit('update', '新しい値')
emit('delete', 42)
emit('change', { field: 'name', value: '田中' })
</script>

ジェネリックコンポーネント

<script setup lang="ts" generic="T extends { id: number }">
interface Props {
  items: T[]
  selectedId?: number
}

const props = defineProps<Props>()
const emit = defineEmits<{
  (e: 'select', item: T): void
}>()
</script>

<template>
  <div
    v-for="item in items"
    :key="item.id"
    :class="[
      'p-2 cursor-pointer rounded',
      item.id === selectedId
        ? 'bg-blue-100 border-blue-500'
        : 'hover:bg-gray-100'
    ]"
    @click="emit('select', item)"
  >
    <slot :item="item" />
  </div>
</template>

💡 JSON to TypeScript ツール を使用して、API レスポンスから型定義を素早く生成できます。


Pinia ステート管理

Store の定義

// stores/useUserStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo, LoginParams } from '@/types/api'

export const useUserStore = defineStore('user', () => {
  // state
  const userInfo = ref<UserInfo | null>(null)
  const token = ref<string>('')

  // getters
  const isLoggedIn = computed(() => !!token.value)
  const displayName = computed(() =>
    userInfo.value?.nickname ?? userInfo.value?.username ?? '未ログイン'
  )

  // actions
  async function login(params: LoginParams) {
    const res = await api.login(params)
    token.value = res.token
    userInfo.value = res.user
    localStorage.setItem('token', res.token)
  }

  function logout() {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
  }

  async function fetchProfile() {
    if (!token.value) return
    userInfo.value = await api.getProfile()
  }

  return {
    userInfo,
    token,
    isLoggedIn,
    displayName,
    login,
    logout,
    fetchProfile
  }
})

コンポーネントでの使用

<script setup lang="ts">
import { useUserStore } from '@/stores/useUserStore'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()

// ✅ storeToRefs を使ってリアクティビティを維持
const { userInfo, isLoggedIn, displayName } = storeToRefs(userStore)

// ❌ 直接分割代入はリアクティビティを失う
// const { userInfo } = userStore // リアクティブではない!

// アクションは直接呼び出し可能
async function handleLogin() {
  await userStore.login({ username: 'admin', password: '123456' })
}
</script>

Store の組み合わせ

// stores/useCartStore.ts
import { defineStore } from 'pinia'
import { useUserStore } from './useUserStore'

export const useCartStore = defineStore('cart', () => {
  const userStore = useUserStore()
  const items = ref<CartItem[]>([])

  const totalPrice = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  async function addToCart(productId: number) {
    if (!userStore.isLoggedIn) {
      throw new Error('先にログインしてください')
    }
    const product = await api.getProduct(productId)
    items.value.push({ ...product, quantity: 1 })
  }

  return { items, totalPrice, addToCart }
})

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

型付きルート

// router/routes.ts
import type { RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    component: () => import('@/layouts/DefaultLayout.vue'),
    children: [
      {
        path: '',
        name: 'Home',
        component: () => import('@/pages/HomePage.vue'),
        meta: { title: 'ホーム', requiresAuth: false }
      },
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/pages/DashboardPage.vue'),
        meta: { title: 'ダッシュボード', requiresAuth: true }
      },
      {
        path: 'user/:id',
        name: 'UserProfile',
        component: () => import('@/pages/UserProfilePage.vue'),
        props: true,
        meta: { title: 'ユーザープロフィール', requiresAuth: true }
      }
    ]
  }
]

export default routes

ルート型の拡張

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { Router, RouteMeta } from 'vue-router'
import routes from './routes'

// RouteMeta 型を拡張
declare module 'vue-router' {
  interface RouteMeta {
    title?: string
    requiresAuth?: boolean
    permissions?: string[]
  }
}

const router: Router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

// グローバルナビゲーションガード
router.beforeEach((to, _from, next) => {
  document.title = to.meta.title ?? 'ToolsKu'

  if (to.meta.requiresAuth) {
    const userStore = useUserStore()
    if (!userStore.isLoggedIn) {
      next({ name: 'Home', query: { redirect: to.fullPath } })
      return
    }
  }

  next()
})

export default router

コンポーネントでの型安全ルーティング

<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

// route.params.id は string | string[]
const userId = computed(() => {
  const id = route.params.id
  return Array.isArray(id) ? id[0] : id
})

function navigateToDashboard() {
  router.push({ name: 'Dashboard' })
}
</script>

コンポーネント設計パターン

コンパウンドコンポーネントパターン

<!-- components/base/Tabs.vue -->
<script setup lang="ts">
import { ref, provide, type InjectionKey, type Ref } from 'vue'

interface TabsContext {
  activeTab: Ref<string>
  setActiveTab: (id: string) => void
}

export const TabsInjectionKey: InjectionKey<TabsContext> = Symbol('tabs')

const activeTab = defineModel<string>({ required: true })

provide(TabsInjectionKey, {
  activeTab,
  setActiveTab: (id: string) => { activeTab.value = id }
})
</script>

<template>
  <div class="tabs">
    <slot />
  </div>
</template>
<!-- components/base/TabItem.vue -->
<script setup lang="ts">
import { inject, computed } from 'vue'
import { TabsInjectionKey } from './Tabs.vue'

const props = defineProps<{ id: string; label: string }>()

const tabsContext = inject(TabsInjectionKey)
if (!tabsContext) {
  throw new Error('TabItem は Tabs 内で使用する必要があります')
}

const isActive = computed(() => tabsContext.activeTab.value === props.id)
</script>

<template>
  <button
    :class="[
      'px-4 py-2 border-b-2 transition-colors',
      isActive
        ? 'border-blue-500 text-blue-600'
        : 'border-transparent text-gray-500 hover:text-gray-700'
    ]"
    @click="tabsContext!.setActiveTab(id)"
  >
    {{ label }}
  </button>
</template>

provide / inject パターン

// composables/useTheme.ts
import { ref, provide, inject, type InjectionKey, type Ref } from 'vue'

interface ThemeContext {
  isDark: Ref<boolean>
  toggleTheme: () => void
}

export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')

export function provideTheme() {
  const isDark = ref(false)

  function toggleTheme() {
    isDark.value = !isDark.value
    document.documentElement.classList.toggle('dark', isDark.value)
  }

  provide(ThemeKey, { isDark, toggleTheme })
  return { isDark, toggleTheme }
}

export function useTheme() {
  const ctx = inject(ThemeKey)
  if (!ctx) throw new Error('useTheme は ThemeProvider 内で使用する必要があります')
  return ctx
}

パフォーマンス最適化

ルート遅延読み込み

// すべてのルートコンポーネントで動的インポートを使用
const routes: RouteRecordRaw[] = [
  {
    path: '/dashboard',
    component: () => import('@/pages/DashboardPage.vue') // 遅延読み込み
  },
  {
    path: '/heavy-page',
    component: () => import(
      /* webpackChunkName: "heavy" */
      '@/pages/HeavyPage.vue'
    )
  }
]

仮想スクロール

<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualList } from '@vueuse/core'

const allItems = ref(Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `アイテム ${i + 1}`
})))

const { list, containerProps, wrapperProps } = useVirtualList(
  allItems,
  { itemHeight: 48, overscan: 10 }
)
</script>

<template>
  <div v-bind="containerProps" class="h-[600px] overflow-auto">
    <div v-bind="wrapperProps">
      <div
        v-for="{ data, index } in list"
        :key="data.id"
        class="h-12 flex items-center px-4 border-b"
      >
        {{ index }}: {{ data.name }}
      </div>
    </div>
  </div>
</template>

KeepAlive キャッシュ

<script setup lang="ts">
import { ref } from 'vue'

const includeList = ref(['DashboardPage', 'UserProfilePage'])
</script>

<template>
  <router-view v-slot="{ Component }">
    <keep-alive :include="includeList">
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

よくある TypeScript エラーと修正

エラー1:テンプレートでの ref .value

// ❌ テンプレートでは .value は不要だが、型チェックが誤解を招くことがある
const count = ref(0)
// テンプレートでは {{ count }}、{{ count.value }} ではない

// ✅ <script> 内では .value を使用する必要がある
function increment() {
  count.value++ // 正しい
}

エラー2:コンポーネントイベントの型不一致

// ❌ emit パラメータの型不一致
const emit = defineEmits<{
  (e: 'change', value: string): void
}>()
emit('change', 123) // TS エラー:number を string に割り当てられない

// ✅ 正しい型を渡す
emit('change', 'hello')

エラー3:非同期コンポーネントの型推論失敗

// ❌ 非同期インポートされたコンポーネントが型を失う
const AsyncComp = defineAsyncComponent(() =>
  import('./MyComponent.vue')
) // 型が any

// ✅ DefineComponent で明示的に宣言
import type { DefineComponent } from 'vue'
const AsyncComp = defineAsyncComponent<DefineComponent<...>>(
  () => import('./MyComponent.vue')
)

エラー4:props デフォルト値の型不一致

// ❌ withDefaults デフォルト値の型エラー
interface Props {
  items: string[]
  count: number
}
const props = withDefaults(defineProps<Props>(), {
  items: '', // TS エラー:string を string[] に割り当てられない
  count: '0' // TS エラー:string を number に割り当てられない
})

// ✅ 正しいデフォルト値
const props = withDefaults(defineProps<Props>(), {
  items: () => [], // 参照型はファクトリ関数を使用
  count: 0
})

ユニットテスト:Vitest

Vitest の設定

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: { '@': resolve(__dirname, 'src') }
  },
  test: {
    globals: true,
    environment: 'jsdom',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html']
    }
  }
})

コンポーザブルのテスト

// composables/__tests__/usePagination.test.ts
import { describe, it, expect } from 'vitest'
import { ref } from 'vue'
import { usePagination } from '../usePagination'

describe('usePagination', () => {
  it('総ページ数を正しく計算する', () => {
    const total = ref(100)
    const { totalPages } = usePagination({ total, pageSize: 10 })
    expect(totalPages.value).toBe(10)
  })

  it('ページナビゲーションが正しく動作する', () => {
    const total = ref(50)
    const { currentPage, nextPage, prevPage, goToPage } = usePagination({
      total,
      pageSize: 10
    })

    expect(currentPage.value).toBe(1)
    nextPage()
    expect(currentPage.value).toBe(2)
    goToPage(5)
    expect(currentPage.value).toBe(5)
    prevPage()
    expect(currentPage.value).toBe(4)
  })

  it('ページ範囲を超えない', () => {
    const total = ref(30)
    const { currentPage, goToPage } = usePagination({ total, pageSize: 10 })

    goToPage(0)
    expect(currentPage.value).toBe(1)
    goToPage(100)
    expect(currentPage.value).toBe(1)
  })
})

コンポーネントのテスト

// components/__tests__/Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '../Counter.vue'

describe('Counter', () => {
  it('初期値を正しくレンダリングする', () => {
    const wrapper = mount(Counter, {
      props: { initialValue: 10 }
    })
    expect(wrapper.text()).toContain('10')
  })

  it('ボタンクリックでカウントが増加する', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('button').trigger('click')
    expect(wrapper.text()).toContain('1')
  })
})

よくある質問 FAQ

Q1: ref と reactive のどちらを使うべきか?

シンプルな原則:プリミティブには ref、オブジェクトには reactive。関数から返す場合や分割代入する場合は、常に ref + toRefs を使用。2026年の実践では、より一貫した動作から ref に統一するチームが増えています。

Q2: Vue3 プロジェクトで Vuex はまだ必要?

いいえ。Pinia が Vue3 の公式推奨ステート管理ソリューションです。完全に TypeScript で書かれ、よりシンプルな API、mutations なし、Composition API スタイルをサポートしています。

Q3: 型安全な API リクエストの処理方法は?

統一された API レスポンス型を定義し、axios インターセプターと組み合わせます:

interface ApiResponse<T> {
  code: number
  data: T
  message: string
}

async function getUser(id: number): Promise<ApiResponse<UserInfo>> {
  return http.get(`/api/users/${id}`)
}

Q4: コンポーネント間のクロスレベル通信をエレガントに処理するには?

  • 親子:props + emits
  • クロスレベル:provide / inject + InjectionKey
  • グローバルステート:Pinia
  • 一時ステート:defineModel(Vue 3.4+)

Q5: 初期画面の読み込み速度を最適化するには?

  1. ルート遅延読み込み(必須)
  2. 非同期コンポーネントインポート(defineAsyncComponent
  3. 画像の遅延読み込み(loading="lazy"
  4. コード分割(Vite の manualChunks
  5. gzip/brotli 圧縮の有効化
  6. 大規模サードパーティライブラリの CDN 利用

💡 Base64 エンコードツール を使用して小さな画像をインライン化し、HTTP リクエストを削減できます。

ブラウザローカルツールを無料で試す →

#Vue3#TypeScript#企业级#前端#教程