切换语言
切换主题

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 }
})
1.5KB
Pinia体积

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

    步骤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

    步骤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

    步骤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

    步骤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

    步骤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

    步骤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?
Pinia体积仅1.5KB,Vuex 5开发基本停滞了。

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中如何配置?有哪些关键点?
tsconfig.json关键配置:
• 开启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状态管理如何使用?有什么注意事项?
推荐用Composition API风格定义Store:
使用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 9改成flat config之后,以前的.eslintrc那套配置全废了。

新配置方式:
• 创建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如何配置类型安全的路由和权限守卫?
扩展路由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(不需要登录的页面直接放行)
• 未登录跳转登录页: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日

评论

使用 GitHub 账号登录后即可评论

相关文章