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: 如何優化首屏載入速度?
- 路由懶載入(必須)
- 元件非同步匯入(
defineAsyncComponent) - 圖片懶載入(
loading="lazy") - 程式碼分割(Vite 的
manualChunks) - 開啟 gzip/brotli 壓縮
- 使用 CDN 載入大型第三方函式庫
💡 使用 Base64 編碼工具 處理內嵌小圖片,減少 HTTP 請求。
本站提供瀏覽器本地工具,免註冊即可試用 →
#Vue3#TypeScript#企业级#前端#教程