Contents

Vue 3 + TypeScript 企业级项目架构实战:从脚手架到生产环境的最佳实践

前言

在实际的前端项目开发中,很多团队在项目初期没有做好架构规划,导致代码耦合严重、维护成本急剧上升。本文将以 Vue 3 + TypeScript + Vite 技术栈为例,分享一套经过生产验证的企业级项目架构方案,涵盖目录规范、组合式API最佳实践、状态管理、自定义Hooks以及全局错误处理。

本文假设读者已掌握 Vue 3 基础语法,重点放在架构设计层面


一、项目初始化与目录结构

1.1 使用 Vite 快速初始化

1
2
3
4
5
npm create vite@latest my-admin -- --template vue-ts
cd my-admin
npm install
npm install pinia vue-router@4 @vueuse/core
npm install -D @typescript-eslint/parser @typescript-eslint/eslint-plugin

1.2 企业级目录结构

一个清晰的目录结构是项目可维护性的基石:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
src/
├── api/                  # API 请求层
│   ├── modules/          # 按业务模块拆分
│   │   ├── user.ts
│   │   ├── order.ts
│   │   └── dashboard.ts
│   ├── request.ts        # Axios 实例封装
│   └── types.ts          # API 响应类型定义
├── assets/               # 静态资源
│   ├── icons/
│   └── styles/
├── components/           # 全局通用组件
│   ├── DataTable/
│   │   ├── index.vue
│   │   ├── columns.ts
│   │   └── types.ts
│   └── SearchBar/
├── composables/          # 组合式函数 (Hooks)
│   ├── useTable.ts
│   ├── usePagination.ts
│   ├── useFetch.ts
│   └── usePermission.ts
├── layouts/              # 布局组件
│   ├── DefaultLayout.vue
│   └── BlankLayout.vue
├── pages/                # 页面组件 (路由级)
│   ├── Dashboard.vue
│   ├── Login.vue
│   └── user/
│       ├── UserList.vue
│       └── UserDetail.vue
├── router/               # 路由配置
│   ├── index.ts
│   ├── routes.ts
│   └── guards.ts
├── stores/               # Pinia 状态管理
│   ├── modules/
│   │   ├── user.ts
│   │   └── app.ts
│   └── index.ts
├── utils/                # 工具函数
│   ├── storage.ts
│   ├── format.ts
│   └── validator.ts
├── types/                # 全局类型定义
│   ├── global.d.ts
│   └── env.d.ts
├── App.vue
└── main.ts

核心原则:按职责分层,而非按组件类型分类。 每个模块的代码都能独立修改,不会影响其他模块。


二、请求层封装:Axios 统一管理

项目中所有 HTTP 请求都应该经过统一的封装,便于处理认证、错误、重试等横切关注点。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// src/api/request.ts
import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { useUserStore } from '@/stores/modules/user'
import { ElMessage } from 'element-plus'

// 定义统一的 API 响应结构
interface ApiResponse<T = any> {
  code: number
  data: T
  message: string
}

const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 15000,
  headers: { 'Content-Type': 'application/json' },
})

// 请求拦截器:注入 Token
request.interceptors.request.use(
  (config) => {
    const userStore = useUserStore()
    if (userStore.token) {
      config.headers.Authorization = `Bearer ${userStore.token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

// 响应拦截器:统一错误处理
request.interceptors.response.use(
  (response: AxiosResponse<ApiResponse>) => {
    const { code, data, message } = response.data

    if (code === 200) {
      return data as any
    }

    // Token 过期,跳转登录
    if (code === 401) {
      const userStore = useUserStore()
      userStore.logout()
      window.location.href = '/login'
      return Promise.reject(new Error('登录已过期'))
    }

    ElMessage.error(message || '请求失败')
    return Promise.reject(new Error(message))
  },
  (error) => {
    const msg = error.response?.status === 500
      ? '服务器内部错误'
      : '网络异常,请检查连接'
    ElMessage.error(msg)
    return Promise.reject(error)
  }
)

// 泛型封装,自动推导类型
export function get<T>(url: string, params?: any, config?: AxiosRequestConfig) {
  return request.get<ApiResponse<T>>(url, { params, ...config }) as Promise<T>
}

export function post<T>(url: string, data?: any, config?: AxiosRequestConfig) {
  return request.post<ApiResponse<T>>(url, data, config) as Promise<T>
}

export default request

三、组合式 API 模式:可复用的业务逻辑

Vue 3 的 <script setup> + Composition API 是架构的核心能力。通过提取可复用的 composables,我们可以将表格分页、数据请求、权限校验等通用逻辑解耦。

3.1 通用数据请求 Hook

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// src/composables/useFetch.ts
import { ref, type Ref } from 'vue'

interface UseFetchOptions<T> {
  /** 是否立即执行 */
  immediate?: boolean
  /** 请求成功后的回调 */
  onSuccess?: (data: T) => void
  /** 请求失败后的回调 */
  onError?: (error: Error) => void
}

export function useFetch<T>(
  fetcher: () => Promise<T>,
  options: UseFetchOptions<T> = {}
) {
  const { immediate = true, onSuccess, onError } = options

  const data: Ref<T | null> = ref(null)
  const loading = ref(false)
  const error: Ref<Error | null> = ref(null)

  async function execute() {
    loading.value = true
    error.value = null

    try {
      data.value = await fetcher()
      onSuccess?.(data.value!)
    } catch (err) {
      error.value = err as Error
      onError?.(err as Error)
    } finally {
      loading.value = false
    }
  }

  if (immediate) {
    execute()
  }

  return { data, loading, error, execute }
}

3.2 表格分页 Hook

在后台管理系统中,带分页的表格是最常见的场景。下面是一个通用的分页表格 Hook:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// src/composables/useTable.ts
import { ref, reactive, onMounted, type Ref } from 'vue'

interface PaginationState {
  page: number
  pageSize: number
  total: number
}

interface UseTableOptions<T> {
  /** 列表接口 */
  fetcher: (params: { page: number; pageSize: number; [key: string]: any }) => Promise<{
    list: T[]
    total: number
  }>
  /** 每页条数 */
  defaultPageSize?: number
  /** 查询参数 */
  queryParams?: Record<string, any>
}

export function useTable<T>(options: UseTableOptions<T>) {
  const { fetcher, defaultPageSize = 20, queryParams = {} } = options

  const list: Ref<T[]> = ref([])
  const loading = ref(false)
  const pagination = reactive<PaginationState>({
    page: 1,
    pageSize: defaultPageSize,
    total: 0,
  })

  async function loadData() {
    loading.value = true
    try {
      const result = await fetcher({
        page: pagination.page,
        pageSize: pagination.pageSize,
        ...queryParams,
      })
      list.value = result.list
      pagination.total = result.total
    } catch (err) {
      console.error('加载数据失败:', err)
      list.value = []
    } finally {
      loading.value = false
    }
  }

  function handlePageChange(page: number) {
    pagination.page = page
    loadData()
  }

  function handleSizeChange(size: number) {
    pagination.pageSize = size
    pagination.page = 1
    loadData()
  }

  function refresh() {
    loadData()
  }

  onMounted(() => {
    loadData()
  })

  return {
    list,
    loading,
    pagination,
    handlePageChange,
    handleSizeChange,
    refresh,
  }
}

在页面中使用时,代码非常简洁:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<!-- src/pages/user/UserList.vue -->
<script setup lang="ts">
import { useTable } from '@/composables/useTable'
import { getUserList, type UserInfo } from '@/api/modules/user'

const {
  list: userList,
  loading,
  pagination,
  handlePageChange,
  handleSizeChange,
} = useTable<UserInfo>({
  fetcher: (params) => getUserList(params),
  defaultPageSize: 15,
})
</script>

<template>
  <el-table v-loading="loading" :data="userList" border stripe>
    <el-table-column prop="id" label="ID" width="80" />
    <el-table-column prop="name" label="姓名" />
    <el-table-column prop="email" label="邮箱" />
    <el-table-column prop="role" label="角色" />
  </el-table>

  <el-pagination
    class="mt-4 justify-end"
    :current-page="pagination.page"
    :page-size="pagination.pageSize"
    :total="pagination.total"
    layout="total, prev, pager, next, sizes"
    @current-change="handlePageChange"
    @size-change="handleSizeChange"
  />
</template>

四、状态管理:Pinia 模块化设计

Pinia 是 Vue 3 官方推荐的状态管理库,相比 Vuex 更简洁、类型推导更好。

4.1 Store 定义

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// src/stores/modules/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login, getUserInfo, logout as logoutApi } from '@/api/modules/user'
import type { UserInfo } from '@/api/types'

export const useUserStore = defineStore('user', () => {
  // State
  const token = ref(localStorage.getItem('token') || '')
  const userInfo = ref<UserInfo | null>(null)

  // Getters
  const isLoggedIn = computed(() => !!token.value)
  const userName = computed(() => userInfo.value?.name || '未登录')
  const permissions = computed(() => userInfo.value?.permissions || [])

  // Actions
  async function handleLogin(username: string, password: string) {
    const data = await login({ username, password })
    token.value = data.token
    localStorage.setItem('token', data.token)
    await fetchUserInfo()
  }

  async function fetchUserInfo() {
    if (!token.value) return
    const data = await getUserInfo()
    userInfo.value = data
  }

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

  function hasPermission(perm: string): boolean {
    return permissions.value.includes(perm)
  }

  return {
    token,
    userInfo,
    isLoggedIn,
    userName,
    permissions,
    handleLogin,
    fetchUserInfo,
    logout,
    hasPermission,
  }
})

五、路由与权限控制

5.1 路由配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// src/router/routes.ts
import type { RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/login',
    component: () => import('@/pages/Login.vue'),
    meta: { layout: 'blank' },
  },
  {
    path: '/',
    component: () => import('@/layouts/DefaultLayout.vue'),
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/pages/Dashboard.vue'),
        meta: { title: '仪表盘', icon: 'dashboard' },
      },
      {
        path: 'user',
        name: 'User',
        redirect: '/user/list',
        meta: { title: '用户管理', icon: 'user' },
        children: [
          {
            path: 'list',
            name: 'UserList',
            component: () => import('@/pages/user/UserList.vue'),
            meta: { title: '用户列表', permission: 'user:list' },
          },
          {
            path: 'detail/:id',
            name: 'UserDetail',
            component: () => import('@/pages/user/UserDetail.vue'),
            meta: { title: '用户详情', hidden: true },
          },
        ],
      },
    ],
  },
]

export default routes

5.2 路由守卫:Token 校验 + 权限拦截

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// src/router/guards.ts
import type { Router } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'

export function setupRouterGuards(router: Router) {
  router.beforeEach(async (to, _from, next) => {
    const userStore = useUserStore()

    // 白名单页面直接放行
    const whitelist = ['/login', '/404']
    if (whitelist.includes(to.path)) {
      next()
      return
    }

    // 未登录跳转登录页
    if (!userStore.isLoggedIn) {
      next({ path: '/login', query: { redirect: to.fullPath } })
      return
    }

    // 已登录但未获取用户信息,先拉取
    if (!userStore.userInfo) {
      try {
        await userStore.fetchUserInfo()
      } catch {
        userStore.logout()
        next({ path: '/login' })
        return
      }
    }

    // 权限校验
    const requiredPermission = to.meta.permission as string | undefined
    if (requiredPermission && !userStore.hasPermission(requiredPermission)) {
      next('/403')
      return
    }

    next()
  })
}

六、全局错误边界处理

生产环境中,未捕获的异常会导致页面白屏。通过全局错误处理器可以优雅地兜底:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/utils/errorHandler.ts
import { type App, type ErrorPayload, handleError } from 'vue'
import { ElMessage } from 'element-plus'

export function setupErrorHandler(app: App) {
  // Vue 运行时错误
  app.config.errorHandler = (err, instance, info) => {
    console.error('[Vue Error]', err)
    console.error('[Component]', instance?.$options?.name)
    console.error('[Info]', info)

    ElMessage.error('页面出现异常,请刷新重试')
  }

  // 未捕获的 Promise 错误
  window.addEventListener('unhandledrejection', (event) => {
    console.error('[Unhandled Rejection]', event.reason)
    event.preventDefault() // 阻止控制台报错
  })

  // 全局 JS 错误
  window.addEventListener('error', (event) => {
    console.error('[Global Error]', event.error)
  })
}

main.ts 中挂载:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import { setupErrorHandler } from './utils/errorHandler'

const app = createApp(App)

app.use(createPinia())
app.use(router)
setupErrorHandler(app)

router.isReady().then(() => {
  app.mount('#app')
})

七、环境变量与类型安全

Vite 原生支持 .env 文件,但团队协作时容易因变量名拼写错误导致线上事故。通过类型声明可以提前规避:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// src/types/env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  /** API 基础地址 */
  readonly VITE_API_BASE_URL: string
  /** 应用标题 */
  readonly VITE_APP_TITLE: string
  /** 是否开启 Mock */
  readonly VITE_USE_MOCK: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

使用时就可以获得完整的类型提示和编译期校验:

1
2
const baseUrl = import.meta.env.VITE_API_BASE_URL  // ✅ 类型安全
const wrong = import.meta.env.VITE_BASE_URL          // ❌ 编译报错

八、性能优化建议

8.1 路由懒加载 + 组件按需引入

1
2
// 路由懒加载 — 只有访问时才加载对应页面
const UserList = () => import('@/pages/user/UserList.vue')

8.2 列表虚拟滚动

当数据量超过 500 条时,使用 virtual-scroll 替代传统分页:

1
<el-table-v2 :data="bigList" :columns="columns" :width="800" :height="600" />

8.3 体积分析

1
2
npm run build -- --mode analyze
npx vite-bundle-visualizer

通过可视化图表定位体积瓶颈,针对性地优化第三方库的引入方式。


总结

本文从实际项目出发,分享了 Vue 3 + TypeScript 企业级项目的核心架构实践:

层次 职责 关键文件
API 层 请求封装、拦截器 api/request.ts
Composables 可复用业务逻辑 composables/useFetch.ts
Stores 全局状态管理 stores/modules/*.ts
Router 路由配置 + 权限守卫 router/guards.ts
Pages 页面级组件 pages/**/*.vue

核心原则:单一职责、类型安全、逻辑复用。好的架构不是一次设计出来的,而是在持续迭代中逐步演进的。希望本文的方案能为你的团队提供参考。


💡 延伸阅读:Vue 3 官方文档 — Composition API、Pinia 指南、Vue Router 官方文档