Vue 3 + TypeScript最佳实践:2025年企业级项目架构指南

引言
上个月我们团队开了一次技术选型会,气氛一度挺尴尬。产品经理刚走,几个前端就吵起来了——一个说要用Vuex,一个非要Pinia,还有个老哥坚持用Redux(对,你没看错,他是从React转过来的)。目录结构怎么组织?TS类型要不要strict模式?这些问题讨论到最后也没个定论,会议室的白板写满了又擦掉。 说实话,我第一次配Vue 3 + TypeScript的时候,tsconfig.json改了不下20遍,每次以为搞定了,换台电脑又报错。那种抓狂的感觉,做过的朋友应该都懂。 这篇文章是我们团队踩了无数坑之后总结出来的一套方案,不敢说是最优解,但至少在三个中大型项目里跑下来没出过大问题。如果你正在启动新项目,或者想给老项目做个架构升级,希望这些经验能帮你少走点弯路。
2025年Vue 3技术栈选型指南
先聊聊技术栈选型这事。Vue 3发布都好几年了,2025年该用什么,其实现在已经挺明确了。 我们团队目前的标配是:Vite + Vue 3 + TypeScript + Pinia + Vue Router 4。这套组合可能很多人已经在用了,但我想说说为什么是这几个。 Vite就不多说了,开发体验比Webpack好太多,热更新基本秒级。Vue 3.6虽然还在alpha阶段,但Evan You在Vue.js Nation 2025演讲里透露的数据挺吓人的——Vapor Mode可以在100毫秒内挂载10万个组件。虽然目前生产环境还用不上,但方向是对的。 重点说说Pinia。说真的,看到Pinia的API设计时,我第一反应是:“这才是状态管理该有的样子啊。“对比一下:
// Vuex的写法(繁琐)
const store = createStore({
state: () => ({ count: 0 }),
mutations: {
increment(state) { state.count++ }
},
actions: {
incrementAsync({ commit }) {
setTimeout(() => commit('increment'), 1000)
}
}
})
// Pinia的写法(清爽)
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const increment = () => count.value++
const incrementAsync = () => setTimeout(increment, 1000)
return { count, increment, incrementAsync }
})Pinia体积才1.5kb左右,Vuex 5开发基本停滞了。除非你的项目已经深度绑定Vuex且没有迁移计划,否则新项目真没必要再用Vuex了。
项目目录结构设计
我见过太多项目把所有组件一股脑堆在components下面,三个月后连自己都找不到文件。 目录结构这东西,小项目怎么搞都行,但项目一大,没有规范就是灾难。我们团队试过好几种方案,最后稳定下来的是这种:
src/
├── api/ # API接口层
│ ├── modules/ # 按业务模块拆分
│ │ ├── user.ts
│ │ └── order.ts
│ └── index.ts
├── assets/ # 静态资源
│ ├── images/
│ └── styles/
├── components/ # 全局通用组件
│ ├── base/ # 基础组件(Button、Input等)
│ └── business/ # 业务通用组件
├── composables/ # 组合式函数
│ ├── useAuth.ts
│ └── useRequest.ts
├── layouts/ # 页面布局组件
├── router/ # 路由配置
│ ├── modules/ # 路由模块
│ └── index.ts
├── stores/ # Pinia状态管理
│ ├── modules/
│ └── index.ts
├── types/ # 全局类型定义
│ ├── api.d.ts
│ └── global.d.ts
├── utils/ # 工具函数
├── views/ # 页面组件
│ ├── user/
│ └── order/
├── App.vue
└── main.ts这里有几个关键点: composables和utils的区别:composables放的是带响应式逻辑的组合函数,比如useAuth、useRequest;utils放的是纯工具函数,比如formatDate、debounce。很多人把这俩混在一起,后期维护挺头疼的。 按功能域拆分而非文件类型:api、stores、views里面都按业务模块再分一层。这样找文件时逻辑清晰——用户相关的东西都在user文件夹下。 types单独抽离:全局类型定义放在types目录,组件内部的类型可以写在组件文件里。别把所有类型都堆到一个types文件夹,那样更乱。
TypeScript类型定义最佳实践
老实讲,TS的类型体操确实有点劝退,但掌握这几个场景就够应付大部分需求了。 先说tsconfig.json,这几个配置一定要开:
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}strict: true是必须的,虽然一开始会报一堆错,但长期来看绝对值得。moduleResolution: "bundler"是比较新的选项,搭配Vite用体验更好。 然后是Vue组件的类型定义。第一次看到defineProps<Props>()这种写法时,我愣了好几秒,不知道泛型是怎么传进去的。其实这是Vue编译器的魔法:
<script setup lang="ts">
// Props类型定义
interface Props {
title: string
count?: number
items: string[]
}
const props = defineProps<Props>()
// 带默认值的Props
const propsWithDefaults = withDefaults(defineProps<Props>(), {
count: 0,
items: () => []
})
// Emits类型定义
interface Emits {
(e: 'update', value: string): void
(e: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
// 或者用更简洁的写法(Vue 3.3+)
const emit2 = defineEmits<{
update: [value: string]
delete: [id: number]
}>()
</script>还有一个容易踩坑的地方——.vue文件的类型识别。如果你的IDE报错说找不到模块,需要在src目录下创建一个env.d.ts或者shims-vue.d.ts:
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}Pinia状态管理实战
用惯Vuex的mutations后,第一次在Pinia里直接修改state,总觉得哪里不对劲,像是做了什么违规操作。后来想通了,mutations那套规范其实是Flux架构遗留下来的包袱,Pinia干脆甩掉了这个负担。 我们团队推荐用Composition API风格定义Store:
// stores/modules/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getUserInfo, login } from '@/api/modules/user'
export const useUserStore = defineStore('user', () => {
// state
const token = ref<string>('')
const userInfo = ref<UserInfo | null>(null)
// getters
const isLoggedIn = computed(() => !!token.value)
const userName = computed(() => userInfo.value?.name ?? '游客')
// actions
const setToken = (newToken: string) => {
token.value = newToken
localStorage.setItem('token', newToken)
}
const fetchUserInfo = async () => {
try {
const res = await getUserInfo()
userInfo.value = res.data
} catch (error) {
console.error('获取用户信息失败', error)
}
}
const logout = () => {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
}
return {
token,
userInfo,
isLoggedIn,
userName,
setToken,
fetchUserInfo,
logout
}
})使用的时候注意一个坑——解构响应式会丢失。要用storeToRefs():
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// 错误写法,解构后失去响应式
const { userName, isLoggedIn } = userStore
// 正确写法
const { userName, isLoggedIn } = storeToRefs(userStore)
// actions可以直接解构,因为它们是普通函数
const { logout, fetchUserInfo } = userStore持久化存储推荐用pinia-plugin-persistedstate插件,配置挺简单:
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// 在store中启用
export const useUserStore = defineStore('user', () => {
// ...
}, {
persist: true // 或者配置具体选项
})Vue Router 4类型安全路由配置
路由守卫写多了会上瘾,总想在beforeEach里塞各种逻辑。我们之前有个项目,beforeEach里的代码快200行了,各种权限校验、埋点、标题设置全堆在里面,后来拆分成多个守卫才好维护一点。 先看路由配置的类型定义:
// router/index.ts
import type { RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
// 扩展路由meta类型
declare module 'vue-router' {
interface RouteMeta {
title?: string
requiresAuth?: boolean
roles?: string[]
}
}
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/home/index.vue'),
meta: {
title: '首页',
requiresAuth: false
}
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: {
title: '控制台',
requiresAuth: true,
roles: ['admin', 'editor']
}
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router权限校验守卫可以这样写:
// router/guards/auth.ts
import type { Router } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
export function setupAuthGuard(router: Router) {
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
// 不需要登录的页面直接放行
if (!to.meta.requiresAuth) {
next()
return
}
// 未登录跳转登录页
if (!userStore.isLoggedIn) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
// 权限校验
const { roles } = to.meta
if (roles && roles.length > 0) {
const hasRole = roles.some(role => userStore.userInfo?.roles?.includes(role))
if (!hasRole) {
next({ path: '/403' })
return
}
}
next()
})
}路由模块化这块,我建议按业务域拆分成多个文件,然后在index.ts里合并:
// router/modules/user.ts
export const userRoutes: RouteRecordRaw[] = [
{
path: '/user',
name: 'User',
component: () => import('@/layouts/BasicLayout.vue'),
children: [
{
path: 'profile',
name: 'UserProfile',
component: () => import('@/views/user/profile.vue')
}
]
}
]代码规范与工程化配置
ESLint 9改成flat config之后,我把旧配置迁移了整整一下午。以前的.eslintrc那套配置全废了,新的配置方式长这样:
// eslint.config.js
import js from '@eslint/js'
import vue from 'eslint-plugin-vue'
import typescript from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import vueParser from 'vue-eslint-parser'
export default [
js.configs.recommended,
...vue.configs['flat/recommended'],
{
files: ['**/*.{ts,tsx,vue}'],
languageOptions: {
parser: vueParser,
parserOptions: {
parser: tsParser,
sourceType: 'module'
}
},
plugins: {
'@typescript-eslint': typescript
},
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-explicit-any': 'warn'
}
}
]Prettier和ESLint打架的问题,用eslint-config-prettier解决,这个包会关掉ESLint中和Prettier冲突的规则。 unplugin-auto-import是个神器,可以自动导入Vue、Vue Router、Pinia的API,省去一堆import语句:
// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
export default defineConfig({
plugins: [
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
dts: 'src/auto-imports.d.ts',
eslintrc: {
enabled: true
}
})
]
})配好之后,代码里直接写ref()、computed()就行,不用手动import了。 Git提交规范用husky + lint-staged,在提交前自动跑lint检查:
// package.json
{
"scripts": {
"prepare": "husky install",
"lint": "eslint . --fix",
"lint-staged": "lint-staged"
},
"lint-staged": {
"*.{js,ts,vue}": ["eslint --fix", "prettier --write"]
}
}写在最后
写到这里,突然想起当年刚接触Vue 3时的手足无措。那时候Composition API刚出来,社区里争论要不要用还是坚守Options API,各种教程质量参差不齐,踩坑是家常便饭。 这套方案不一定适合所有项目。小项目完全可以简化,比如状态管理用provide/inject就够了,目录结构也不用分那么细。但如果你在做中大型企业级项目,团队有3个人以上协作,这套架构应该能帮你省不少事。 技术选型这东西没有银弹,最重要的是团队达成共识、形成规范、坚持执行。希望这篇文章能给你一些参考,少走一些弯路。 你们团队是怎么搭建Vue 3项目的?有什么好的实践经验?欢迎在评论区交流。
发布于: 2025年11月24日 · 修改于: 2025年12月4日


