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 must be used inside 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 must be used inside 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#企业级#前端#教程