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项目的?有什么好的实践经验?欢迎在评论区交流。
搭建Vue 3 + TypeScript企业级项目架构完整流程
从项目初始化到代码规范的完整配置步骤,包含Vite、Pinia、Vue Router、ESLint等最佳实践
⏱️ 预计耗时: 4 小时
- 1
步骤1: 项目初始化和技术栈选型
创建Vite项目:
• 运行:npm create vite@latest my-project -- --template vue-ts
• 进入项目:cd my-project
• 安装依赖:npm install
技术栈选型:
• Vite + Vue 3 + TypeScript + Pinia + Vue Router 4
• 安装核心依赖:npm install pinia vue-router@4
优势:
• Vite开发体验比Webpack好太多,热更新基本秒级
• Vue 3.6的Vapor Mode可以在100毫秒内挂载10万个组件(虽然目前生产环境还用不上,但方向是对的) - 2
步骤2: 设计项目目录结构
目录结构按功能域拆分:
• src/api/modules/(按业务模块拆分API接口,如user.ts、order.ts)
• src/components/base/和business/(基础组件和业务通用组件)
• src/composables/(带响应式逻辑的组合函数,如useAuth、useRequest)
• src/utils/(纯工具函数,如formatDate、debounce)
• src/stores/modules/(Pinia状态管理按业务模块拆分)
• src/views/(页面组件按业务模块拆分,如user/、order/)
• src/types/(全局类型定义)
关键点:
• composables和utils要区分开(composables放响应式逻辑,utils放纯工具函数)
• api、stores、views里面都按业务模块再分一层,这样找文件时逻辑清晰 - 3
步骤3: 配置TypeScript类型定义
tsconfig.json关键配置:
• 开启strict: true(必须的,虽然一开始会报一堆错但长期来看绝对值得)
• moduleResolution: 'bundler'(搭配Vite用体验更好)
• paths配置@/*别名
Vue组件类型定义:
• 使用defineProps<Props>()泛型语法
• 带默认值用withDefaults(defineProps<Props>(), { count: 0, items: () => [] })
• Emits类型定义用defineEmits<Emits>()
.vue文件类型识别:
• 在src目录下创建env.d.ts或shims-vue.d.ts
• declare module '*.vue' { import type { DefineComponent } from 'vue'; ... } - 4
步骤4: 配置Pinia状态管理
推荐用Composition API风格定义Store:
使用defineStore('user', () => {
const token = ref<string>('');
const userInfo = ref<UserInfo | null>(null);
const isLoggedIn = computed(() => !!token.value);
const fetchUserInfo = async () => { ... };
return { token, userInfo, isLoggedIn, fetchUserInfo }
})
使用注意:
• 解构响应式要用storeToRefs()(const { userName, isLoggedIn } = storeToRefs(userStore))
• actions可以直接解构(const { logout, fetchUserInfo } = userStore)
持久化存储:
• 安装pinia-plugin-persistedstate
• 在main.ts中pinia.use(piniaPluginPersistedstate)
• 在store中启用persist: true - 5
步骤5: 配置Vue Router 4类型安全路由
扩展路由meta类型:
declare module 'vue-router' {
interface RouteMeta {
title?: string;
requiresAuth?: boolean;
roles?: string[];
}
}
路由配置:
• 使用RouteRecordRaw[]类型
• meta中定义title、requiresAuth、roles等
权限校验守卫:
• 在router/guards/auth.ts中创建setupAuthGuard函数
• 使用router.beforeEach检查to.meta.requiresAuth和roles
• 未登录跳转登录页,权限不足跳转403
路由模块化:
• 按业务域拆分成多个文件(router/modules/user.ts)
• 在index.ts里合并 - 6
步骤6: 配置ESLint 9和代码规范
ESLint 9改用flat config:
• 创建eslint.config.js
• 使用js.configs.recommended和vue.configs['flat/recommended']
• 配置parser为vue-eslint-parser
• parserOptions.parser为@typescript-eslint/parser
Prettier集成:
• 用eslint-config-prettier解决ESLint和Prettier冲突
自动导入:
• 安装unplugin-auto-import
• 在vite.config.ts中配置AutoImport({ imports: ['vue', 'vue-router', 'pinia'] })
• 配好之后代码里直接写ref()、computed()不用手动import
Git提交规范:
• 配置husky + lint-staged
• 在package.json中配置lint-staged
• 在提交前自动跑lint检查
常见问题
Pinia和Vuex有什么区别?为什么推荐用Pinia?
API设计对比:
Vuex需要state、mutations、actions三层结构:
const store = createStore({
state: () => ({ count: 0 }),
mutations: { increment(state) { state.count++ } },
actions: { incrementAsync({ commit }) { setTimeout(() => commit('increment'), 1000) } }
})
Pinia用Composition API风格更简洁:
export const useCounterStore = defineStore('counter', () => {
const count = ref(0);
const increment = () => count.value++;
const incrementAsync = () => setTimeout(increment, 1000);
return { count, increment, incrementAsync };
})
Pinia的优势:
• 不需要mutations(直接修改state)
• TypeScript支持更好
• 体积更小
• API更直观
除非你的项目已经深度绑定Vuex且没有迁移计划,否则新项目真没必要再用Vuex了。
Vue 3项目目录结构应该如何设计?composables和utils有什么区别?
• src/api/modules/(按业务模块拆分如user.ts、order.ts)
• src/components/base/和business/(基础组件和业务通用组件)
• src/composables/(带响应式逻辑的组合函数如useAuth、useRequest)
• src/utils/(纯工具函数如formatDate、debounce)
• src/stores/modules/(Pinia状态管理按业务模块拆分)
• src/views/(页面组件按业务模块拆分如user/、order/)
• src/types/(全局类型定义)
composables和utils的区别:
• composables放的是带响应式逻辑的组合函数(比如useAuth、useRequest)
• utils放的是纯工具函数(比如formatDate、debounce)
• 很多人把这俩混在一起,后期维护挺头疼的
按功能域拆分的好处:
• api、stores、views里面都按业务模块再分一层
• 这样找文件时逻辑清晰——用户相关的东西都在user文件夹下
TypeScript在Vue 3中如何配置?有哪些关键点?
• 开启strict: true(必须的,虽然一开始会报一堆错但长期来看绝对值得)
• moduleResolution: 'bundler'(搭配Vite用体验更好,是比较新的选项)
• paths配置@/*别名
Vue组件类型定义:
• 使用defineProps<Props>()泛型语法:
interface Props { title: string; count?: number; items: string[] }
const props = defineProps<Props>()
• 带默认值用withDefaults(defineProps<Props>(), { count: 0, items: () => [] })
• Emits类型定义用defineEmits<Emits>()或更简洁的写法:
const emit2 = defineEmits<{ update: [value: string]; delete: [id: number] }>()
.vue文件类型识别:
在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;
}
如果IDE报错说找不到模块,通常是这个文件没配置好。
Pinia状态管理如何使用?有什么注意事项?
使用defineStore('user', () => {
const token = ref<string>('');
const userInfo = ref<UserInfo | null>(null);
const isLoggedIn = computed(() => !!token.value);
const fetchUserInfo = async () => {
try {
const res = await getUserInfo();
userInfo.value = res.data;
} catch (error) {
console.error('获取用户信息失败', error);
}
};
return { token, userInfo, isLoggedIn, fetchUserInfo };
})
使用注意:
• 解构响应式要用storeToRefs():const { userName, isLoggedIn } = storeToRefs(userStore)
• actions可以直接解构因为它们是普通函数:const { logout, fetchUserInfo } = userStore
持久化存储:
• 安装pinia-plugin-persistedstate
• 在main.ts中pinia.use(piniaPluginPersistedstate)
• 在store中启用persist: true或配置具体选项
用惯Vuex的mutations后,第一次在Pinia里直接修改state,总觉得哪里不对劲,但其实mutations那套规范是Flux架构遗留下来的包袱,Pinia干脆甩掉了这个负担。
ESLint 9的flat config如何配置?和旧版本有什么区别?
新配置方式:
• 创建eslint.config.js
• 使用js.configs.recommended和vue.configs['flat/recommended']
• 配置files: ['**/*.{ts,tsx,vue}']
• languageOptions.parser为vue-eslint-parser
• parserOptions.parser为@typescript-eslint/parser
• plugins配置@typescript-eslint
• rules中配置具体规则('vue/multi-word-component-names': 'off','@typescript-eslint/no-unused-vars': 'warn')
Prettier集成:
• 用eslint-config-prettier解决ESLint和Prettier冲突
• 这个包会关掉ESLint中和Prettier冲突的规则
自动导入:
• 安装unplugin-auto-import
• 在vite.config.ts中配置AutoImport({ imports: ['vue', 'vue-router', 'pinia'], dts: 'src/auto-imports.d.ts' })
• 配好之后代码里直接写ref()、computed()不用手动import
Git提交规范:
• 配置husky + lint-staged
• 在package.json中配置lint-staged("*.{js,ts,vue}": ["eslint --fix", "prettier --write"])
• 在提交前自动跑lint检查
Vue Router 4如何配置类型安全的路由和权限守卫?
declare module 'vue-router' {
interface RouteMeta {
title?: string;
requiresAuth?: boolean;
roles?: string[];
}
}
路由配置:
• 使用RouteRecordRaw[]类型
• 在meta中定义title、requiresAuth、roles等
权限校验守卫:
• 在router/guards/auth.ts中创建setupAuthGuard函数
• 使用router.beforeEach检查to.meta.requiresAuth(不需要登录的页面直接放行)
• 未登录跳转登录页:next({ path: '/login', query: { redirect: to.fullPath } })
• 权限校验检查roles:const hasRole = roles.some(role => userStore.userInfo?.roles?.includes(role))
• 权限不足跳转403
路由模块化:
• 按业务域拆分成多个文件(router/modules/user.ts)
• 在index.ts里合并
路由守卫写多了会上瘾,总想在beforeEach里塞各种逻辑,我们之前有个项目beforeEach里的代码快200行了,各种权限校验、埋点、标题设置全堆在里面,后来拆分成多个守卫才好维护一点。
7 分钟阅读 · 发布于: 2025年11月24日 · 修改于: 2026年1月22日
相关文章
Next.js 电商实战:购物车与 Stripe 支付完整实现指南
Next.js 电商实战:购物车与 Stripe 支付完整实现指南
Next.js 文件上传完整指南:S3/七牛云预签名URL直传实战
Next.js 文件上传完整指南:S3/七牛云预签名URL直传实战
Next.js 单元测试实战:Jest + React Testing Library 完整配置指南
评论
使用 GitHub 账号登录后即可评论