前言
在实际的前端项目开发中,很多团队在项目初期没有做好架构规划,导致代码耦合严重、维护成本急剧上升。本文将以 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 官方文档