BetterLink Logo 比邻
切换语言
切换主题

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

Vue 3 TypeScript最佳实践

引言

上个月我们团队开了一次技术选型会,气氛一度挺尴尬。产品经理刚走,几个前端就吵起来了——一个说要用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日

相关文章