上一篇我们把 Vue3 + TypeScript + Vite 项目跑起来了,Element Plus 和 Pinia 也装进了依赖,浏览器能正常打开页面。但你我都清楚,那个状态离“框架”还差得远:目录结构还是脚手架默认的三板斧,接口层没统一出口,状态管理全挤在一个 store 里,组件库全量引入后首次编译已经能感觉到卡顿。这篇文章就是来解决这些问题的——把项目从“能运行的 Demo”改造成“可以直接开新功能的中后台骨架”。内容完全围绕 Vue3、TypeScript、Vite、Element Plus、Pinia 这套组合展开,覆盖目录规划、工程配置、类型体系、按需加载、状态模块化,以及几个我已实测踩过的坑。准备接手新项目、想重构旧项目、或者刚学完语法但不知道怎么组织代码的人,这份记录能帮你省下不少弯路。
1. 先聊清楚这套组合里每个角色到底负责什么
在动手改代码之前,我建议先想明白一个问题:为什么是 Vue3 + TypeScript + Vite + Element Plus + Pinia,而不是换一套别的?不是因为这套最潮,而是这套组合在现代中后台项目里分工非常明确,每个成员都有自己的活儿,合在一起才构成一个能支撑团队协作的基础设施。
- Vite负责构建与开发体验。开发时依赖 esbuild 预打包、源码走原生 ESM,冷启动和热更新都快得离谱;生产环境交给 Rollup,配合拆包配置能对产物做精细控制。
- Vue3负责视图与逻辑组织。组合式 API 把过去 Options API 里散落的 data、methods、computed 收拢到一块,逻辑复用靠 composables,模板和逻辑终于能跟业务一一对应。
- TypeScript负责类型约束。它真正解决的问题不是“写代码”而是“协作”:接口字段、组件 props、store 的 state 全部有了编辑器层面的契约,别人写的代码你接得住,你写的接口别人改得放心。
- Element Plus负责基础 UI。中后台项目 80% 的页面离不开表格、表单、弹窗、消息提示,组件库直接决定了前端开发速度的上限。
- Pinia负责跨组件状态。它把 Vuex 里的 Mutation 流程砍掉了,store 定义得像写一个普通函数一样简单,而且对 TypeScript 的推导支持是原生级别的。
给一个外行朋友打比方:Vite 是施工升降机,Vue3 是主体框架,TypeScript 是设计图纸,Element Plus 是预制构件,Pinia 是工地台账。没有图纸,框架也能盖起来住人,但一旦多人同时进场,图纸就是所有人对齐的那个标准。项目框架也一样,技术选型不是选单个工具,而是选一套协作机制。
1.1 这套组合真正要解决的是协作成本
很多人把框架搭建理解成“把依赖装上、把菜单画出来”,其实那只是起步。框架存在的意义是让新增功能时不用每次都纠结“代码放哪、类型怎么定义、状态怎么管”。比如一个典型的用户列表页面,你希望团队里的任何一个人去做,都能顺手遵循同样的目录约定、调用同一个请求函数、处理同一套加载状态,而不是每个人写出一套自己的风格。
选型的时候还有一条隐性标准:生态成熟度。Vite 的插件生态、Element Plus 的组件覆盖度、Pinia 在 Vue3 社区里的普及率,都是经过大量中后台项目验证的。要是选一个还在快速迭代、周边配套不全的库,框架搭完可能就得推翻重来。这也是我为什么偏保守地选了这套组合。
2. 目录结构:决定框架能不能长大的骨架设计
脚手架默认的src目录只有components和views两个文件夹,小 Demo 没问题,但一旦开始接入路由、状态、接口,立刻会乱。目录结构本质上是在回答一个问题:一个文件应该放哪里,以及别人如何快速找到它。
2.1 基础分层的目录实践
我目前用的结构长这样,可以作为一套起点:
src/ ├── api/ # 接口请求定义 ├── assets/ # 静态资源(图片、字体等) ├── components/ # 全局通用组件 ├── composables/ # 组合式函数 ├── layout/ # 页面布局 ├── router/ # 路由配置 ├── stores/ # Pinia 状态仓库 ├── styles/ # 全局样式与主题变量 ├── types/ # 全局类型声明 ├── utils/ # 工具函数 ├── views/ # 页面组件 ├── main.ts # 应用入口 └── App.vue # 根组件这个结构有一个核心逻辑:先按技术职责分层,再按业务模块聚合。api、router、stores这些是按技术职责划分的全局层,它们跟具体业务无关,是框架的基础设施;views下面是按业务模块聚合的页面层,每个页面文件夹里可以再带自己的局部组件、局部 composables 和局部请求。这样一个人开发一个功能时,从接口到页面到状态都能在同一个文件夹里闭环,但全局的请求工具、路由守卫、store 又不至于散落各处。
我见过一种反向设计:直接在views下面建user/api.ts、user/store.ts、user/index.vue,业务聚合得很彻底。这种在小团队、单业务线里很舒服,但多业务线并行时,全局请求层、权限逻辑、消息通知这些横切关注点很难抽取,容易变成你复制我一段逻辑、我再复制你一段。折中方案是:基础能力归api/utils这些全局层,业务页面归views下面每个模块文件夹,模块内部可以自包含。我自己更推荐这个折中,既有约束又有弹性。
2.2 别忽视的新建页面流程约定
目录定好了,还得顺手约定“新增一个页面”的标准动作:在views下建目录,在router里配路由,在api下建请求文件,在stores下建需要的 store。四个文件缺哪个,页面上线的过程中就会在哪一步卡住。这些约定最好写进 README 或者团队文档里,不然三个月后新人会问你“接口到底该写在哪一层”。
3. Vite 配置:路径别名、代理与拆包的 3 个关键动作
脚手架生成的vite.config.ts只是最小配置,真正要支撑日常开发,至少要做三件事:配置路径别名、配好开发代理、规划构建拆包。这三件做完,开发体验和上线产物质量会有明显提升。
3.1 路径别名配置与 tsconfig 同步
没有路径别名之前,写import xxx from '../../../../utils/request'是家常便饭。路径一深,改个文件位置就得改一堆相对路径。配置@指向src是 Vite 项目的标准操作:
import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } } })这里用fileURLToPath而不是字符串拼接路径,是因为它能正确处理跨平台路径差异,Windows 和 Linux 下不会因为斜杠方向出问题。配完 Vite 之后有一个很容易漏的步骤:tsconfig.json里的paths也要同步,否则 TypeScript 编译器不认@,编辑器里会标红。
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }这一步漏掉的话,运行没问题,Vite 能正常把它转成真实路径,但 VSCode 的类型检查会一直报错。搭建框架时“配置要成对出现”这个意识要刻在脑子里:Vite 的 alias 负责运行时解析,tsconfig 的 paths 负责编译期解析,缺一个都是隐患。
3.2 开发代理与生产环境接口地址分离
中后台项目几乎没有不跨域的。后端环境和前端往往不在同一台机器,开发时用 Vite 的 server.proxy 把/api指向后端地址最省事,既不用后端配 CORS,也不用前端开浏览器跨域插件。
server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, // 是否重写路径取决于后端接口是否自带 /api 前缀 rewrite: (path) => path.replace(/^\/api/, '') } } }这里有两个细节值得注意。一是changeOrigin要开,后端做了域名白名单校验时,请求来源才能被正确识别为代理目标域名。二是rewrite不是必须的,如果后端接口本身就是http://localhost:8080/api/user/list,那就不应该去掉/api,我在实际项目中见过因为无脑 rewrite 导致请求 404 的,排查半天发现是前缀被削掉了。
生产环境不能依赖代理,接口地址一般通过环境变量注入。在.env.development和.env.production里分别定义:
VITE_API_BASE_URL=/apiVITE_API_BASE_URL=https://api.example.com这样代码里只用import.meta.env.VITE_API_BASE_URL,发布到哪个环境由构建时的 env 文件决定,前端代码不用跟着环境改。
3.3 构建拆包的取舍
全量引入 Element Plus 之后,首次打包产物体积会很难看。Vite 默认把依赖打进 vendor,但把所有依赖塞一个包也是灾难。我一般用manualChunks做基础拆分:
build: { chunkSizeWarningLimit: 1500, rollupOptions: { output: { manualChunks: { 'vue-vendor': ['vue', 'vue-router', 'pinia'], 'element-plus': ['element-plus'] } } } }把 Vue 生态相关依赖打包成一个vue-vendor,Element Plus 单独拆出来。好处是:这些大块头依赖更新频率低,浏览器缓存命中率高;业务代码频繁改动只会影响业务 chunk,用户二次访问时不用重新下载几十 MB 的依赖。
这里要提醒一句:manualChunks的配置不是越细越好,拆太碎会导致大量小文件的请求开销和缓存失效的交叉依赖。我见过把lodash、axios、dayjs全都拆成单独 chunk 的项目,HTTP 请求数激增,性能反而更差。依赖之间耦合度低、更新频率不同才值得拆,目前看 Element Plus 和 Vue 生态这几块够用了。
4. TypeScript:给框架装上类型大脑的关键配置
Vite 脚手架默认会生成tsconfig.json,但很多关键选项是关闭或宽松的。搭建框架这个阶段,最值得花时间的是把严格模式打开、把路径映射补齐、把环境变量声明好。这一步做完,后面的组件开发和 store 编写会非常顺畅。
4.1 tsconfig 里值得认真对待的几项
下面这份是我项目的基准配置,可以对照自己项目调整:
{ "compilerOptions": { "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "jsx": "preserve", "resolveJsonModule": true, "isolatedModules": true, "esModuleInterop": true, "lib": ["ESNext", "DOM", "DOM.Iterable"], "skipLibCheck": true, "noEmit": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"] }我重点解释三个最容易引发争议的选项:
strict: true是必须的。虽然它会让初期的类型报错变多,但那些报错多数是真实隐患——潜在的空值、隐式的 any、未处理的 undefined。中后台项目里最常见的线上 bug 就是“接口返回某个字段没判空”,严格模式下这类问题在编译期就能被按住。
moduleResolution: "bundler"是 TypeScript 5.0 之后的选项,专门针对 Vite 这类打包器。如果没有业务历史包袱,直接用它,它对 package.json 里的exports字段解析更准确,也不用像node模式那样处理很多 Node 生态的历史兼容。
skipLibCheck: true建议打开。它跳过对node_modules里声明文件的类型检查,能把编译速度提上来。代价是有可能漏掉依赖库自身的类型冲突,但实际开发中这个换算是值得的。
4.2 环境变量的类型声明
import.meta.env默认返回的是any加一堆基础字段,用起来完全没有提示。手写一份环境变量类型声明非常值得,成本极低、收益立竿见影。在src/types/env.d.ts里:
/// <reference types="vite/client" /> interface ImportMetaEnv { readonly VITE_API_BASE_URL: string readonly VITE_APP_TITLE: string } interface ImportMeta { readonly env: ImportMetaEnv }写完这个之后,在组件里敲import.meta.env.VITE_的时候,编辑器会直接把项目里定义的环境变量列出来。少一个字母、写错一个变量名,都会在编译阶段直接报错,而不是上线后才发现接口地址配错了。这份文件要记得纳入版本管理,它是团队约定的一部分。
4.3 vue-tsc 与 defineProps 泛型写法
脚手架默认的build脚本通常只是vite build,不会做类型检查。我建议改成:
"build": "vue-tsc --noEmit && vite build"对比之下能发现很多开发时被忽略的类型问题。对于<script setup>组件,defineProps和defineEmits用泛型写,是让组件类型清晰的关键:
<script setup lang="ts"> interface Props { title: string count?: number onConfirm?: () => void } defineProps<Props>() </script>这里使用defineProps<Props>()而不是defineProps({ title: String })的原因很简单:一个是严格的 TypeScript 类型契约,一个是运行时的软约束。前者能在组件使用方直接看到 props 的类型和是否必填,后者只能等运行时报错。和 TS 打交道时间越久,越明白“早一个环节发现错误”的价值。
5. Element Plus 按需导入与主题定制,别让包体拖后腿
Element Plus 全量导入很简单,一个app.use(ElementPlus)搞定,但产物里塞进了所有组件,包括那些永远用不到的。按需自动导入是框架搭建的常规操作,这里的核心是配置unplugin-auto-import和unplugin-vue-components。
5.1 自动按需导入的配置
安装插件之后,在vite.config.ts里追加:
import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()], dts: 'src/types/auto-imports.d.ts' }), Components({ resolvers: [ElementPlusResolver()], dts: 'src/types/components.d.ts' }) ] })这套配置的作用是:模板里写了<el-button>就自动按需加载该组件及样式,代码里用了ElMessage.show这类 API 也会自动注入对应模块和样式。两个dts字段负责生成类型声明文件,让自动导入的组件和方法在 TS 环境下有完整类型提示。
有个很容易踩的坑:修改配置后第一次启动必须让插件生成auto-imports.d.ts和components.d.ts这两个文件,而且开发服务器启动期间改配置后要重启才能让类型文件生效。我第一次用的时候没重启,编辑器一直报“找不到 ElMessage”,但页面跑起来又是好的,排查了半天。还有一次是components.d.ts被误加入.gitignore,导致团队成员每次拉完代码都要等本地重新生成才能消除报错,这个文件一定要入库。
5.2 函数式组件的样式补充
自动导入组件本身很顺,但函数式组件(ElMessage、ElNotification、ElMessageBox)经常出现“功能正常、样式丢失”的情况。原因是这些 API 在调用时是动态插入 DOM 的,插件对这类动态组件的样式处理不一定跟上。项目里如果遇到弹窗样式全裸,最快的手动兜底方案是在main.ts或者全局样式入口里补上:
import 'element-plus/es/components/message/style/css' import 'element-plus/es/components/notification/style/css' import 'element-plus/es/components/message-box/style/css'这几个样式文件体积不大,却能把最常用的人机交互组件样式稳住。我见过团队因为这个问题卡了一下午,最后发现是样式没引进来,这种问题非常隐蔽,因为报错信息不会直接提示缺样式。
5.3 主题定制与全局配置
Element Plus 自带一套蓝色主题,但项目一般都要改品牌色。最简单的做法是利用 CSS 变量覆盖:
:root { --el-color-primary: #2f6bff; --el-border-radius-base: 6px; }放在全局样式里即可生效,不用重新编译 SCSS,也适合运行时的动态换肤需求。如果项目需要的定制更深(比如修改组件的 padding、字体大小),推荐用 SCSS 变量覆盖方案:安装sass,然后通过 Vite 配置额外注入变量文件。
另外建议在App.vue或main.ts外层包一个ElConfigProvider,统一设置locale和组件默认属性:
<template> <el-config-provider :locale="zhCn"> <router-view /> </el-config-provider> </template> <script setup lang="ts"> import zhCn from 'element-plus/es/locale/lang/zh-cn' </script>locale这一项很值得做,Element Plus 默认是英文文案,日期选择器、分页器在中文项目里不改语言看起来很违和。
6. Pinia 模块化:状态管理别写成全局垃圾堆
Pinia 上手很容易,正因如此很多人随手就往 store 里塞东西,最后变成一个什么都装的大杂烩。状态管理的核心不是“能存”,而是“知道什么状态该放这里、怎么切分模块”。
6.1 options 与 setup 两种风格怎么选
Pinia 支持两种定义方式,一个偏传统,一个偏组合式:
options 风格:
export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: null as UserInfo | null }), getters: { isLoggedIn: (state) => !!state.token }, actions: { setToken(token: string) { this.token = token localStorage.setItem('token', token) } } })setup 风格:
import { ref, computed } from 'vue' export const useUserStore = defineStore('user', () => { const token = ref(localStorage.getItem('token') || '') const userInfo = ref<UserInfo | null>(null) const isLoggedIn = computed(() => !!token.value) function setToken(val: string) { token.value = val localStorage.setItem('token', val) } return { token, userInfo, isLoggedIn, setToken } })我实际项目里更偏 setup 风格,因为它能直接复用组合式函数,把一段带业务逻辑的状态干脆地封装进 store。比如把“拉取用户信息并填充 state”的逻辑写在 store 内部,跟 Vuex 时代把这类逻辑丢在组件里相比,组件代码明显干净。但如果团队刚接触 Pinia,options 风格的语法更直观,上手曲线更低。这个选择看团队背景,没有绝对优劣。
6.2 跨 store 引用与持久化的边界
组件树里最烦的状态场景是 A 模块需要 B 模块的某个字段。Pinia 里跨 store 调用非常直接:在某个 store 的 action 里直接useOtherStore()就行,因为 action 执行时 Pinia 已经处于激活状态。
export const useOrderStore = defineStore('order', () => { const userStore = useUserStore() function createOrder(data: OrderDto) { // 可以从 userStore 读取 token、用户信息 return request.post('/order', data, { headers: { Authorization: `Bearer ${userStore.token}` } }) } return { createOrder } })这里要注意,useUserStore()调用位置最好放在 action 内部,而不是 store 定义体顶部。如果在 setup store 的函数体顶层调用,只要它是惰性的、在函数被实际调用时才执行,问题不大;但如果在定义 store 时立即执行,就可能踩到“Pinia 实例尚未激活”的坑,下面会专门讲。
持久化方面,我坚持一个原则:只持久化必要字段。token、用户信息、主题偏好这类需要跨刷新保留的数据放进localStorage;接口缓存、页面临时筛选条件这类状态留在内存即可。项目里见过给整个 store 加了持久化,导致一个很大的对象被序列化到 localStorage,进而拖慢页面启动的案例,得不偿失。如果确实有全局持久化需求,可以单独引一个pinia-plugin-persistedstate,但要有节制地用。
6.3 组件外使用 store 的正确姿势
这可以说是 Pinia 最常见的坑:在router的守卫或者utils/request这些非组件文件里调用useUserStore(),直接抛错 “getActivePinia was called but there was no active Pinia”。原因是 Pinia 只有在app.use(pinia)之后才被激活,而路由守卫和请求工具往往在main.ts顶层导入的时候就被执行了。
解决办法有几种,我推荐最省心的一种:在main.ts里先创建 pinia 实例并把它暴露成一个可导入文件,然后在需要的文件中传入实例。
// src/main.ts 或单独一个文件 import { createPinia } from 'pinia' export const pinia = createPinia()// src/router/index.ts import { pinia } from '@/main' import { useUserStore } from '@/stores/user' router.beforeEach((to, from, next) => { const userStore = useUserStore(pinia) // 这里可以安全读取 userStore 的状态 next() })useUserStore(pinia)里的参数就是 Pinia 实例。这个写法本质上是告诉 Pinia:“你不用去找全局激活的那个实例,用我传进来的这个就行。”比在路由守卫里app.use(pinia)之后再 import store 要直观得多,而且不依赖模块加载顺序。
7. 框架初跑:踩坑记录与排查速查表
框架搭完之后,真实跑业务的第一周通常会集中爆发一批环境问题。这里整理一份我踩过的坑速查表,遇到类似症状可以直接查。
| 现象 | 根本原因 | 解决方式 |
|---|---|---|
@别名在编辑器中标红 | tsconfig 的paths未配置 | 在tsconfig.json中补baseUrl和paths |
import.meta.env没有任何提示 | 缺少环境变量类型声明 | 新建env.d.ts,声明ImportMetaEnv接口 |
| 改完 vite.config 后自动导入组件报错 | 插件生成的 d.ts 文件缺失或过期 | 重启 dev server,确认auto-imports.d.ts已生成并入库 |
ElMessage功能正常但无样式 | 函数式组件样式未被自动处理 | 手动import 'element-plus/es/components/message/style/css' |
路由守卫里useUserStore()报错 | Pinia 实例尚未激活 | 导出 pinia 实例,调用时传入useUserStore(pinia) |
| 页面首次加载后修改代码热更新很慢 | 依赖预构建缓存被 invalidate | 删除node_modules/.vite后重启 |
| 打包后 Element Plus 样式丢失 | 按需插件未配置importStyle | 确认 resolver 配置正确,必要时手动引入样式入口 |
7.1 路由守卫与请求拦截器里的 Pinia 问题
路由守卫这块值得单独说,因为它是每个中后台项目都会用到的地方。登录态校验、权限过滤、动态标题,全都写在beforeEach里。如果没有 pinia 实例传入,useUserStore()会直接抛错,导致整个路由系统瘫痪。
我的习惯是:在main.ts里把pinia实例 export 出来,作为一个独立模块。这样 router 和 utils 都能引它,不需要在两面都创建。要维持一个项目里只有一个 Pinia 实例,否则 store 的状态会分裂成两份,组件里读到的和守卫里读到的不是同一个值。
7.2 Vite 预构建缓存引发的玄学问题
Vite 开发期间会预构建依赖并缓存到node_modules/.vite。这个缓存的失效机制有时候不灵敏,尤其在你改了包管理器的 lock 文件、或者手动改过node_modules里的内容之后,跑出来的页面可能还是旧依赖。
遇到“我明明改了代码,浏览器里没变”这种灵异事件,第一反应别是刷新页面,先看一眼依赖缓存。删除node_modules/.vite再重启,绝大多数问题都能解决。第二步是确认浏览器没有缓存旧版本的服务 Worker,如果你项目里用了 PWA,这个嫌疑得排除。Vite 本身很可靠,但它把脏活藏起来了,出问题的时候这些隐藏的中间层往往是元凶。
框架初跑的这一周,本质上是把“脚手架默认配置”和“真实业务需求”之间的缝隙填平。每修一个问题,你对这套组合的理解就深一层。我个人在这件事上的体会是:搭建框架最难的从来不是某一个库的用法,而是把它们之间的边界约定好——目录往哪长、类型往哪放、状态归谁管、请求怎么发。这些约定写下来虽然只是几段配置、几个文件,但它们决定了团队后面三个月写代码的顺畅程度。
这套组合的下一步扩展,我通常会继续处理请求层统一封装、路由权限控制和动态菜单这几个方向。等到业务真的开始跑起来,再回头调整框架细节,才算把框架从“纸面设计”变成“经得起业务捶打的地基”。