
上一篇文章把 Vite Vue3 TS 的骨架拉起来之后后台收到不少私信问得最多的是同一个问题项目结构搭好了接下来怎么让这套东西真正变成能上线、能多人协作的架子而不是一个只会跑 npm run dev 的空壳。这篇就来填这个坑。还是先交代一下背景。这个系列是基于 Vue3 TypeScript Vite Element Plus Pinia 这套技术栈来搭一个中后台管理系统框架。第一篇做完了“能跑起来”的雏形第二篇要把工程化能力补齐环境变量、代码规范、请求层封装、动态路由、权限控制、Pinia 模块化、Element Plus 深度接入。这些短板不补业务代码一多整个项目马上就会乱成一锅粥。这篇适合两类人一类是刚接触这套技术栈、想看看资深前端怎么组织项目的同学另一类是已经在用这套栈做项目但总觉得结构差点意思、想找优化思路的开发者。内容偏实战能直接抄作业的地方我都写成了代码和配置但更重要的还是把每一步的取舍讲清楚——知道为什么这么写后面遇到自己的项目才不会照搬出问题。1. 这一篇要把框架打磨成什么样1.1 从骨架到可用的工程第一篇搭出来的是标准的三层结构入口、App、一个页面。这种结构演示 demo 没问题一旦开始接业务文件会迅速膨胀到那时再重构成本就高了。所以第二步的核心工作是把目录结构从“能跑”升级到“能长”。我见过太多项目初期只有 views 和 components 两个目录三个月后 views 下面堆了四十多个文件接口请求散落在各个页面里公共方法到处复制粘贴。这些问题跟技术栈没关系纯粹是早期没有把目录边界划好。所以这个阶段我做的事情其实很朴素先把 src 下的目录在项目里固定下来然后靠 ESLint 规则约束加上 code review 时坚持原则慢慢让团队按这个边界写代码。框架的“框”字靠的就是这里。目录定好了后面不管加多少人代码都朝着同一个方向长。1.2 目录结构的一次彻底重构直接把我当前在用的目录结构贴出来src/ ├─ api/ # 接口层按业务域拆文件 │ ├─ auth.ts │ └─ user.ts ├─ assets/ # 静态资源 ├─ components/ # 通用业务组件按组件建目录 │ ├─ ProTable/ │ └─ SearchForm/ ├─ hooks/ # 组合式函数 │ ├─ useTable.ts │ └─ useDebounceRef.ts ├─ layouts/ # 框架布局组件 ├─ router/ # 路由表、动态路由逻辑 ├─ stores/ # Pinia 状态模块 │ ├─ modules/ │ │ ├─ user.ts │ │ └─ app.ts │ └─ index.ts ├─ styles/ # 全局样式、主题变量 ├─ types/ # 全局类型声明 ├─ utils/ # 工具函数 │ ├─ request.ts # axios 实例封装 │ └─ token.ts ├─ views/ # 页面级组件 ├─ App.vue └─ main.ts这个结构有几个关键取舍。第一api 和 views 严格分开。views 里只负责渲染和交互接口请求统一走 api 目录。这样接口地址变更时不用去几十个页面里找请求在哪发的。第二utils 和 hooks 分开。utils 放纯函数像 uuid 生成、格式化时间、token 的存取hooks 放跟 Vue 响应式生命周期相关的逻辑比如 useTable、useDebounceRef。两者混在一起后面的人很难从文件名判断代码行为。第三layouts 单独建目录。中后台的侧边栏、顶栏、面包屑这些布局是全局性的放进 views 会跟懒加载路由混在一起独立出来之后维护非常干净。2. 工程化配置把团队规范写进代码里2.1 多环境变量管理业务开发绕不开多环境本地开发、测试环境、生产环境接口地址和某些开关配置都不一样。Vite 对这块的支持很直接项目根目录创建三个文件.env.development .env.production .env.test内容按需写比如# .env.development VITE_APP_BASE_API/api VITE_APP_ENVdevelopment生产环境对应的值是正式接口域名。这里有个容易忽略的细节Vite 的环境变量只有 VITE_ 前缀的才会暴露给前端代码。你在 .env 里写个 BASE_API 不带前缀代码里 import.meta.env.BASE_API 拿到的永远是 undefined。所以约定俗成业务相关变量统一以 VITE_APP_ 开头。在代码里通过 import.meta.env 访问时为了拿到类型提示需要额外声明。在 src 下建一个 env.d.ts/// reference typesvite/client / interface ImportMetaEnv { readonly VITE_APP_BASE_API: string readonly VITE_APP_ENV: string } interface ImportMeta { readonly env: ImportMetaEnv }不写这个声明也能跑但 Vite 推了一套完整的类型体系不用白不用。每次请求接口时 import.meta.env.VITE_APP_BASE_API 都有类型约束比写错字符串才发现要好得多。2.2 ESLint Prettier Husky 组合拳很多项目拉下来之后第一件事是让人“别乱动别人的代码”但靠口头约定永远挡不住格式化问题。我现在的做法是三条线同时上ESLint 检查代码质量Prettier 统一代码格式Husky 在提交前拦住不符合规范的代码。ESLint 现在主流配置是 flat config也就是 eslint.config.ts。Vite 创建 VueTS 项目时如果选择了 ESLint脚手架已经生成了基础配置但要注意几个点。一是 Vue 文件的 parser 必须区 distributionts 文件用 typescript-eslint/parservue 文件用 vue-eslint-parser然后让 parser 属性指向 TS parser。很多报错说 “Parsing error: Unexpected token” 就是因为没有这个嵌套配置。Prettier 配置最简单成为一套固定选项比如单引号、不加分号。关键是要跟 ESLint 配合好不要让两者互相打架。ESLint 里的 stylistic 规则如果和 Prettier 冲突要么关掉要么用 eslint-config-prettier 把格式相关的规则全部禁用。我的经验是格式问题交给 Prettier 兜底逻辑问题交给 ESLint 去管这个分工一旦定下来整个团队写出来的代码风格会非常统一。Husky 的接入也比较套路化。安装 husky 和 lint-staged 之后在 package.json 里加一个 prepare 脚本然后在 .husky 目录创建 pre-commit 钩子{ scripts: { prepare: husky, lint:fix: eslint . --fix, format: prettier --write src/**/*.{ts,vue} }, lint-staged: { *.{ts,vue,tsx}: [ eslint --fix, prettier --write ], *.{css,scss}: prettier --write } }pre-commit 里面就一行npx lint-staged这样每次 git commit 只对暂存区文件做检查和格式化速度很快。要注意的是第一次跑可能会把大量累积的格式问题一次性刷出来建议在接入之前先把全项目格式化一遍否则会污染提交记录。我接这个配置的当天就踩了一次commit 日志里密密麻麻全是 format看着就头疼。2.3 路径别名与 TS 同步Vite 里配路径别名非常常见但有个高频坑vite.config.ts 里配了 aliastsconfig.json 没同步结果代码能跑IDE 疯狂报红线提示找不到模块。Vite 配置import { defineConfig } from vite import vue from vitejs/plugin-vue import path from node:path export default defineConfig({ resolve: { alias: { : path.resolve(__dirname, src) } } })tsconfig.json 需要补充 paths{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }两个地方同步改完import 就有类型提示和跳转能力了。不少同学配置完这里有一步容易漏掉就是使用 setup 模式后tsconfig 需要 include 掉 vite.config.ts 本身否则编辑 vite.config.ts 的时候 TS 类型会不完整。顺手加上可靠性高很多。3. 请求层封装线上问题的第一道防线3.1 实例化 Axios 与拦截器请求层不封装最直接的问题是每个页面都在 new Axios超时时间不一致token 拼接逻辑满天飞后端一改响应格式就是一个大工程。封装的核心做法是创建单一实例把 header 注入、token 携带、错误提示、状态码处理全部收拢到拦截器里。我平时写的 request.ts 逻辑分三层。第一层创建实例import axios from axios import type { AxiosRequestConfig } from axios const service axios.create({ baseURL: import.meta.env.VITE_APP_BASE_API, timeout: 10000 })第二层是请求拦截器把 token 加进请求头service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config })第三层是响应拦截器统一处理业务码service.interceptors.response.use( response { const res response.data if (res.code ! 0) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { const status error.response?.status if (status 401) { // token 失效清理本地状态并跳回登录页 localStorage.removeItem(token) window.location.href /login } else { ElMessage.error(error.message || 网络异常) } return Promise.reject(error) } )这里有一个关键细节假设后端约定的成功 code 是 0拦截器统一判断后所有请求拿到的返回值就不再需要重复判断 code 了。页面里调用接口直接就是干净的 data。这种约定后端和前端提前对齐开发效率会提升一大截。3.2 带泛型的统一请求函数拦截器只是底层真正落地还要有一个统一的请求方法不然每个模块都 import axios 去调拦截器就形同虚设。配合 TS 做泛型约束后接口调用的类型体验会很好。import type { AxiosResponse } from axios export interface ApiResponseT unknown { code: number data: T message: string } export function requestT unknown(config: AxiosRequestConfig): PromiseApiResponseT { return service.requestApiResponseT(config) }使用的时候定义好接口返回的数据类型// api/user.ts import { request } from /utils/request interface UserInfo { id: string name: string email: string } export function fetchUserInfo() { return requestUserInfo({ url: /user/info, method: get }) }页面上调用const res await fetchUserInfo() // res.data 的类型被推断为 UserInfo这里要注意的是axios 的泛型传参容易懵。axios 内部的 requestT 里的 T 表示整个响应体的类型而 response interceptor 内部我们把 response.data 直接返回了所以业务侧拿到的其实是 data 部分。如果不熟悉 axios 类型实现很容易把泛型套成 AxiosResponse。实际上我这边在 request 函数的泛型给的是 ApiResponse然后页面拿 res.data 就是业务数据类型刚好对得上。3.3 取消请求与重复提交处理请求层真正成熟的标志是处理取消。经典场景有两个一个是页面卸载时把未完成请求取消掉避免 setState 警告和网络资源浪费另一个是用户连续点击“保存”按钮防止重复提交。Vue3 里推荐的做法是用 AbortController 而不是旧的 CancelToken因为 CancelToken 在 axios 新版本里已标记废弃。封装思路是暴露一个带 signal 的请求方法export function requestWithAbortT unknown(config: AxiosRequestConfig): PromiseApiResponseT { const controller new AbortController() const promise service.requestApiResponseT({ ...config, signal: controller.signal }) return promise }页面里结合 onScopeDispose 使用组件卸载自动取消所有挂载的 controller。重复提交的问题除了按钮 loading 之外也可以用单例请求的思路同一个 url 的请求如果还没结束新来的直接忽略或复用上一个 controller 取消掉。这个业界没有统一标准根据业务形态选择就好。4. 路由权限动态注册与守卫联动4.1 静态路由与动态路由拆分中后台项目权限路由几乎是标配。我把路由分成两块不需要登录就能访问的静态路由和登录后根据用户权限动态生成的路由。静态路由只留三种登录页、404、首页重定向。其他业务页面全部走动态注册// router/index.ts const staticRoutes [ { path: /login, name: Login, component: () import(/views/login/index.vue), meta: { hidden: true } }, { path: /, redirect: /dashboard }, { path: /404, component: () import(/views/error/404.vue), meta: { hidden: true } } ]动态路由的数据来源通常是后端权限接口返回的是一组菜单树每项带着 path、component、meta 这些信息。前端拿到之后把 component 字段做一层映射转换成一个标准 RouteRecordRaw 数组再逐个 addRoute。很多人会问动态路由的 component 怎么映射后端不可能返回一个组件对象通常返回的是字符串比如 “system/user”。前端维护一个视图映射表const modules import.meta.glob(/views/**/*.vue) function menuToRoute(menu: MenuItem): RouteRecordRaw { const component modules[/src/views/${menu.component}.vue] return { path: menu.path, name: menu.name, component, children: menu.children?.map(menuToRoute) } }用 import.meta.glob 的好处是 Vite 会按需分包而不是一次性把所有页面加载进来。这个映射表对目录结构有强约定views 下面的目录一旦改名后端配置也要同步更新。项目初期把这种映射关系写成文档比临时写一堆 if else 要稳定得多。4.2 完整的权限路由生成流程动态路由生成常在全局前置守卫里完成。整体流程是这样的用户访问任意页面进入 beforeEach如果没有 token直接跳转登录页有 token 但还没加载过用户信息调用用户信息接口拿到权限列表后生成动态路由用 addRoute 注册全部注册完再跳转目标页面核心代码骨架router.beforeEach(async to { const userStore useUserStore() const token userStore.token if (!token) { if (to.path /login) return true return { path: /login, query: { redirect: to.fullPath } } } if (to.path /login) return { path: / } if (!userStore.routesLoaded) { await userStore.fetchUserInfo() const routes userStore.generateRoutes() routes.forEach(route router.addRoute(route)) userStore.routesLoaded true return { ...to, replace: true } } return true })这里有个老坑动态路由里最后要补一个通配 404但不能用静态路由注册。因为 defineRoutes 的时候还不存在权限路由静态 404 一旦注册动态路由注册后会被 404 拦截。正确做法是在动态路由数组末尾追加{ path: /:pathMatch(.*)*, redirect: /404, meta: { hidden: true } }这样只有登录用户、权限路由加载完之后404 才会作为兜底工作。还有一个细节afterEach 里可以清理路由标题把菜单中文名设置到 document.title这个中后台体验会是加分项。4.3 按钮级权限的自定义指令路由权限只解决“能不能进页面”的问题页面里的按钮权限同样不能少。我一般用自定义指令实现 v-permission控制按钮显隐。// src/directives/permission.ts import type { Directive, DirectiveBinding } from vue const checkPermission (code: string) { const userStore useUserStore() return userStore.permissions.includes(code) } export const permission: Directive { mounted(el: HTMLElement, binding: DirectiveBindingstring) { if (!checkPermission(binding.value)) { el.parentNode?.removeChild(el) } } }在 main.ts 里全局注册app.directive(permission, permission)页面上直接写el-button v-permissionsystem:user:add新增/el-button这种做法的核心是 permission 列表来自用户信息接口而用户信息 store 在拿到数据之后再渲染按钮组件顺序没问题。如果担心按钮是通过 v-if 动态渲染、指令触发时机在 store 数据之前也可以在指令内用 watch 监听权限列表变化再决定移除或保留。但现实业务里用户信息加载完成之前页面一般由路由守卫拦住了所以 mounted 判断基本够用。5. Pinia 存储模块化业务状态不打架5.1 组合式写法定义 storePinia 相比 Vuex 体验好很多去掉了 mutation 这个中间层TS 支持也更好。我在这个项目里全部采用组合式写法跟 setup 语法天然亲和更像是在写一个组合式函数。user store 是典型例子// stores/modules/user.ts import { defineStore } from pinia export const useUserStore defineStore(user, () { const token ref(localStorage.getItem(token) || ) const userInfo refUserInfo | null(null) const permissions refstring[]([]) const routesLoaded ref(false) async function login(params: LoginParams) { const res await loginApi(params) token.value res.data.token localStorage.setItem(token, res.data.token) } async function fetchUserInfo() { const res await fetchUserInfoApi() userInfo.value res.data.info permissions.value res.data.permissions } function logout() { token.value userInfo.value null permissions.value [] routesLoaded.value false localStorage.removeItem(token) } return { token, userInfo, permissions, routesLoaded, login, fetchUserInfo, logout } })组合式写法的好处是逻辑内聚和页面代码的表达一致。等第一个 store 写顺后后面 app store侧边栏折叠、主题设置、tabs store多标签页如果有需要都沿用同样写法。5.2 组件外使用 store 的注意事项Pinia 一个常见的坑是在 ts 工具函数里直接 useUserStore()会报 “getActivePinia was called with no active Pinia”。原因是 useStore 必须在 setup 环境、或者 pinia 实例已经 install 之后才能正常解析当前上下文。但在 request 拦截器或路由守卫这种组件外调用通常是没问题的因为 main.ts 里已经 app.use(pinia)实例已经激活。真正容易踩坑的是在模块顶层调用// 例子在 store 模块内部互相引用 import { useAppStore } from ./app const appStore useAppStore() // 模块加载时执行pinia 未初始化这种代码放模块顶层被某个组件间接 import 后就会报错。解决方式是延迟到函数内部再调用export function getAppStore() { return useAppStore() }或者直接在 util 函数里每次调用时动态获取 store。凡是涉及 store 之间互相引用的地方都应该放在 action 或函数体内部而不是模块顶层。这个规则我踩过两次之后已经把它当成铁律写在团队规范里。5.3 持久化方案选型Pinia 本身不持久化刷新页面数据就没了。业务里 token 反正放在 localStorageuserInfo 一般可以重新拉取真正需要持久化的反而少。我的取舍是数据能通过接口重新获取的就不做持久化不能重新获取的才考虑持久化。这个原则避免了很多因为缓存产生的陈旧数据问题。如果确实需要持久化比如用户偏好设置、主题色我不会引第三方库直接用 watch 手动同步到 localStorageconst themeColor ref(localStorage.getItem(themeColor) || #409eff) watch(themeColor, val { localStorage.setItem(themeColor, val) })项目中只有一个 store 需要做复杂持久化或者团队想统一方案时再考虑引入 pinia-plugin-persistedstate。它支持按模块配置 storeKey还能自定义序列化。轻量项目没必要两个依赖包都能少引则少引。6. Element Plus 与工程协同的细节6.1 按需自动导入Element Plus 全量引入省事但打包体积感人。既然用了 Vite按需导入是必须的。最佳体验是用两个插件配合模板解析可以做到不用手写 import组件和 API 自动注入// 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()] }), Components({ resolvers: [ElementPlusResolver()] }) ] })AutoImport 负责处理 ElMessage 这种函数式 API 的自动导入Components 负责组件标签的按需加载。两者配好之后组件库的样式也会自动引入不需要再手写 element-plus/dist/index.css。要注意的是ElementPlusResolver 默认是按需加载组件和样式但如果项目的依赖版本不一致可能出现样式不生效的问题这种情况排查优先级最高的就是插件版本是否匹配。6.2 全局配置与主题定制Element Plus 的全局配置用 app.use(ElementPlus, { locale }) 只能配置少量项更灵活的是用 ElConfigProvider 包裹根组件统一设置尺寸和语言。我更习惯在 App.vue 里这样处理el-config-provider :localezhCn :sizesize router-view / /el-config-providersize 从 app store 里读取切换全局尺寸时就改一个变量所有组件跟着变。主题定制方面Element Plus 2.x 全面支持 CSS 变量覆盖主色直接写:root { --el-color-primary: #1890ff; --el-color-primary-light-3: #4da6ff; --el-color-primary-light-5: #85c2ff; }有自定义主题需求就维护一套 CSS 变量文件。比老版本去改 scss 源码省力很多。注意 primary 的 light 系列变量如果不覆盖按钮 hover 颜会显示默认蓝色跟主色不匹配。6.3 二次封装思路组件库用久了会发现表格和表单的重复代码最多我建议二次封装两个组件ProTable 和 SearchForm。这两个封装的共同思路是把数据驱动做到极致。ProTable 接收一个 columns 配置数组每列定义好 label、prop、宽度、是否格式化组件内部封装 loading、分页、空数据状态。这样业务页面只需要传配置和接口函数。SearchForm 则用类似的 schema 驱动方式把查询条件渲染、重置、查询按钮集成。封装的要点不是把所有能力都做进去而是定义一个足够好用的规矩表格和表单的交互模式统一后面加需求才不需要到处贴组件。二次封装最怕越写越笨重所以保持配置项精简很重要。我一般给自己定一个原则当 ProTable 的 props 超过十个开始审视是否过度设计。7. 实操中高频踩坑与速查最后把这个系列从零到框架搭建过程中最常遇到的坑整理成几张表都是真实踩过的直接保存一份在团队文档里。7.1 配置问题的定位思路现象可能原因解决办法import.meta.env 任何变量都是 undefined环境变量没加 VITE_ 前缀检查 .env 文件变量名路径别名在 IDE 报错但运行正常tsconfig paths 未配置tsconfig.json 的 paths 与 vite alias 同步Vue 模板中组件样式不生效自动导入插件版本不兼容检查 element-plus 与 unplugin 插件版本ESLint 报 Parsing errorvue 文件缺少 vue-eslint-parserparserOptions 里配置 parser 嵌套7.2 权限和状态相关现象可能原因解决办法getActivePinia was called模块顶层调用 useStore改为函数内部调用刷新后动态路由 404动态路由注册只执行一次路由守卫里判断 routesLoaded 并重新生成登录页面跳转死循环守卫逻辑对 /login 没有明确放行检查 token 存在时访问 /login 的处理按钮权限偶尔不生效指令触发时 permissions 还没加载用路由加载顺序保证用户信息先返回7.3 构建与性能优化打包体积是另一个重点。Element Plus 按需导入后还有一个容易被忽略的点Vite 默认分包会把所有动态路由的组件打包到一个大 chunk 里。而用 vite-plugin-style-import 等同类方案处理样式时如果不结合按需组件也可能出现样式重复打包。构建后看一眼 dist 目录的 chunk 大小超过三四百 KB 的优先分析一下哪些是组件库内容再决定是否需要手动分包。常见的手动分包配置是在 build.rollupOptions.output 里把 element-plus 相关依赖单独打成 vendor chunk。这个配置虽然简单但能显著减少业务代码更新导致的缓存失效范围。build: { rollupOptions: { output: { manualChunks: { element-plus: [element-plus], vue-vendor: [vue, vue-router, pinia] } } } }框架初期就加上这个分包配置后面前端上线时缓存命中率会差很多。写在最后说实话每次搭框架都会遇到一些新的细节问题即使是同一个技术栈、同一个项目模板不同团队接手后还是会跑出不同的情况。我尽量把通用部分都写进这篇里了剩下那些跟具体业务强相关的比如菜单权限树的具体格式、后端返回字段的命名规范基本得靠团队自己对齐。我个人一直的观点是框架搭建没有“一步到位”这回事最重要的是保持“小步快跑 及时重构”的节奏先围绕一个真实页面把请求、路由、状态这些链路走通然后再逐步收紧规范、引入工具。很多人搭框架失败不是因为技术选型不对而是第一天就想把生产级的约束全部铺满结果开发体验很差热情很快就被消磨掉了。如果你是按这个系列一步步搭下来的现在这个框架跑一个登录、拉用户信息、渲染动态菜单、做一次列表查询应该是没有问题的。接下来有两条路可以自己探索一是给组件库补一套内部的可视化文档页面二是把构建流程接到 CI/CD 里做自动部署和预览。后面有时间我也会把这两块内容单独写成新的系列到时候接着聊。