Vue3 + TypeScript 企業級開發實戰指南

前端工程

為什麼 Vue3 + TypeScript 是 2026 年的企業標配

2026 年,Vue3 + TypeScript 已經成為中大型前端專案的絕對主流選擇。Vue3 的 Composition API 提供了更靈活的邏輯複用能力,TypeScript 則為大型專案帶來了型別安全和更好的重構體驗。兩者結合,讓團隊協作效率和程式碼品質都上了一個台階。

核心優勢一覽

  • 型別安全:編譯期捕獲 80% 以上的低級錯誤,減少線上 Bug
  • 更好的 IDE 支援:VSCode + Volar 提供精準的智慧提示和跳轉
  • 邏輯複用:Composition API + 自訂 Hook 替代 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>

自訂組合式函式(Composables)

這是 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">
// 方式一:泛型宣告(推薦)
interface Props {
  title: string
  count?: number
  items: string[]
  status: 'active' | 'inactive'
}

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

// 方式二:執行時宣告(不推薦,型別推導不完整)
// 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 轉 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 // 不響應!

// actions 直接呼叫即可
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>

元件設計模式

複合元件模式(Compound Components)

<!-- 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 但 TypeScript 不報錯

// ❌ 範本中不需要 .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']
    }
  }
})

測試 Composable

// 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#企业级#前端#教程