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: 如何优化首屏加载速度?

  1. 路由懒加载(必须)
  2. 组件异步导入(defineAsyncComponent
  3. 图片懒加载(loading="lazy"
  4. 代码分割(Vite 的 manualChunks
  5. 开启 gzip/brotli 压缩
  6. 使用 CDN 加载大型第三方库

💡 使用 Base64 编码工具 处理内联小图片,减少 HTTP 请求。

本站提供浏览器本地工具,免注册即可试用 →

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