一份优雅的Vue3中后台项目模板:目录结构、请求层与路由守卫拆解

发布时间:2026/10/19 5:25:22
一份优雅的Vue3中后台项目模板:目录结构、请求层与路由守卫拆解 简介一款面向 Vue3 开发者的优雅项目组织模板适合需要快速搭建工程化前端项目、规范代码结构的初中级开发者。模板基于 Vite 构建围绕 Composition API 拆分布局、路由、组件与状态管理并预置 ESLint/Prettier、通用工具函数等最佳实践可大幅减少从零配置的工作量。资源共 233 个文件压缩包约 9.9MB其中 159 个 Markdown 文档承载使用说明与学习笔记js/ts 文件负责路由、交互与配置vue 组件展示业务封装思路css 文件提供多套主题样式另有 html/json 等示例用于辅助理解。目前已有 786 人学习下载通过这份模板既能获得一套可直接改造的 Vue3 项目骨架又能借助附带的 rem 单位、水平居中布局、正则与 this 指向等页面示例巩固高频前端基础知识点配套 Markdown 笔记与多主题样式也为理解工程配置和样式组织提供了直观参考。1. 先说结论这份Vue3模板把项目组织变成了“复制粘贴”的事接手一个已有Vue3项目时我常见的情形是组件目录按文件类型平铺接口请求散落在十几个页面里状态管理全塞一个store每次加需求都像在扫雷。后来我拿到一份内部沉淀的Vue3项目模板解压即跑目录按业务模块切分请求、路由、状态各自有出口Vite单页热更新基本秒开。这份模板适合准备开始新中后台前端工程的人也适合想把自己的项目从Vue2切换到Vue3的团队。它不教语法只给一套可以直接落地的工程骨架这就是标题里“优雅”二字的全部含义。2. 项目定位与技术选型优雅是设计出来的不是堆特性堆出来的2.1 这套模板解决的三个真实痛点第一痛点是接口层与页面耦合。不少项目的axios实例直接写在某个组件里后端字段一改所有相关页面跟着改。模板把请求层统一收敛到utils/request.ts所有业务模块通过api目录导出函数调接口页面里看不到axios所以后端字段结构和请求编码调整都收缩到一个文件内。第二痛点是状态管理没有边界。Vue3很多人懒得用状态库在组件外定义ref全局共享组件卸载后变量还挂在内存里刷新页面状态就丢。模板用Pinia按模块拆store跨页面共用的数据放全局store单模块内部数据入模块store逻辑边界在目录里就画好了。第三痛点是路由权限永远在重复造轮子。模板把这部分放路由守卫里新模块自己声明router.ts主路由自动注册权限变更时只需要维护权限码和模块路由的对应关系。这三个问题单看不复杂但放在团队里每个都要讨论半天。模板的价值是把口径固化下来后来的人照着目录放文件就行不用每次重新争论一个约定。我自己也曾在某个个人项目里享受过“自由”目录想怎么放就怎么放最后连自己都找不到访问某接口的组件在哪这就是血泪教训。2.2 技术栈清单哪些是必选哪些是聪明的取舍下面这张表把模板用到的技术按用途列出来技术用途为什么选它Vue 3.2框架组合式API让逻辑复用成为常态Vite构建开发态几乎秒开HMR响应快TypeScript语言组件props、API响应都有类型约束Vue Router 4路由支持动态路由与约定式metaPinia状态管理天然配合setup语法比Vuex轻很多Axios请求拦截器稳定生态丰富unplugin-auto-import自动导入免去ref/computed没完没了的importunplugin-vue-components组件自动注册UI框架按需加载不全局引入ESLint Prettier代码规范提前锁住分号、引号之争Husky lint-staged提交钩子脏代码不进库Sass样式变量与嵌套方便定制主题这里有一个取舍值得提模板没有把UI框架完整包挂在main.ts而是靠unplugin-vue-components解析template里的el-button自动引入组件和样式。很多项目图省事全局安装UI库首屏体积直接多几百KB这套模板用自动解析方案把压力放回编译器开发时不用手动import打包时也不会全量引入。顺带一提模板刻意没放NProgress这类进度条插件——动态路由和首屏加载一闪而过时进度条反而会造成闪烁属于不必要的复杂度。2.3 适用场景与边界模板不等于银弹这套骨架最适合中后台管理端左侧菜单、顶栏、多级路由、表格表单增删改查这类工程占了前端工作的大头。layouts里已经备好DefaultLayout和BlankLayout登录页、外嵌页用空布局就行。如果你的目标是一个重交互的C端落地页或者像大屏这种每个像素都要定制的地方这套模块化结构会显得重它强制按业务模块切分而此类页面往往只有一两个模块反而多出一层心智负担。边界之外还有一个团队协作要点模板的规范是写进目录结构里的不是写在wiki里。新人不会读文档但一定会看目录当每个模块内部都有一套api.ts/router.ts/views新人照着系统模块抄一遍就能上手。这比任何代码评审都高效。所以如果你给多个团队用这份模板最好约定不许跨模块引用对方私有组件只允许引用共享components否则模块边界很快就会被写穿。说到取舍还得提一句模板没有选Vuex也没有选Nuxt。Vuex在Vue3下虽能用但Pinia已经成了官方推荐。不用Nuxt是因为这是一个单页中后台模板不需要服务端渲染和约定式路由引入Nuxt会把项目复杂性抬高一个档次。这份模板的目标是在一百个页面以内保持结构清晰超过这个规模再去考虑微前端拆分也不迟。模板体积小、依赖少就是这个原因。3. 目录结构拆解一份“优雅”的Vue3项目是怎么排版的3.1 顶层目录每一条路径都对应一个职责把zip解开之后你会看到这样一个顶层结构. ├── public/ # 不会被构建处理的静态资源 ├── src │ ├── api/ # API请求的统一出口 │ │ ├── auth.ts │ │ ├── dashboard.ts │ │ └── types.ts │ ├── assets/ # 会被构建的资源图片、字体、全局样式 │ ├── components/ # 跨模块复用的通用组件 │ │ ├── Table/ │ │ ├── Dialog/ │ │ └── Pagination/ │ ├── composables/ # 组合式函数 │ │ ├── useTable.ts │ │ └── useCountDown.ts │ ├── layouts/ # 布局组件 │ │ ├── DefaultLayout.vue │ │ └── BlankLayout.vue │ ├── modules/ # 业务模块按功能划分 │ │ ├── dashboard/ │ │ └── system/ │ ├── router/ # 路由总入口 │ │ ├── index.ts │ │ └── guard.ts │ ├── stores/ # 全局store │ │ ├── index.ts │ │ ├── user.ts │ │ └── app.ts │ ├── styles/ # 全局样式与scss变量 │ ├── utils/ # 纯工具函数含request.ts │ └── main.ts # 应用入口 ├── .env.development # 开发环境变量 ├── .env.production # 生产环境变量 ├── .env.uat # 预发环境变量可选 ├── vite.config.ts ├── tsconfig.json └── package.json逐层解释一下。public放favicon和静态logo它不会被Vite做hash处理所以名字别乱改。assets里放会被import或被css引用的图片会经过去重和指纹生成。components目录是我最强调的纪律只放跨模块复用的无业务组件组件内不允许出现api请求和store调用所有的数据通过props和emit进出。一旦某个组件里出现了useUserStore它就该上移到对应模块的components下。composables目录是这版模板比Vue2时代舒服的地方。useTable封装了loading、列表数据、分页参数、拉取方法业务页面里只需要一行const { list, loading, page, loadData } useTable(getListApi)不必在组件里重复维护这四组变量。如果你刚从Options API过来先把这层理解为一个“可以把响应式变量和函数一起拿出组件外”的抽屉。3.2 模块化目录业务边界比文件类型更重要很多项目的目录是按“Views/Components/Utils”这种文件类型划分的问题在于一次“用户管理”需求会同时改到views和components和store和api四个目录每个目录里的文件都在变多但没有一个目录能告诉你“用户管理”这一整块代码在哪。这套模板用modules目录按业务边界收拢。每个业务模块内部自己长这样src/modules/system/ ├── api.ts # 模块内接口声明只由本模块引用 ├── router.ts # 模块路由表主路由会自动注册 ├── store.ts # 模块内状态如果不需要可以不留 ├── components/ # 模块私有的组件 ├── views/ # 模块页面 │ ├── UserList.vue │ └── RoleList.vue └── index.ts # 统一导出供外部引用模块内的api.ts只导出函数不出现axios实例router.ts导出route配置由主路由动态addRoutestore.ts按需引入Pinia的defineStore。当你需要砍掉某个业务模块时直接删除这一个目录其余地方不会残留任何跟它相关的代码。模块之间如果需要通信我一般会定两条规则第一跨模块的数据走全局store第二跨模块的组件引用走共享components。同一模块内部页面可以任意引用自己的私有组件。这套规则看起来死板但确实避免了“两个模块互相import最后分不清谁在上谁在下”的纠缠。我见过一个一百多页的中后台项目就是因为模块间互相引用成环后来只能回退成单个大views目录重来一次。3.3 自动导入与路径别名少写一半的import模板里的vite.config.ts有一段自动导入配置这是打开项目后最值得优先看的部分import { defineConfig } from vite import vue from vitejs/plugin-vue import AutoImport from unplugin-auto-import/vite import Components from unplugin-vue-components/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers import path from node:path export default defineConfig({ plugins: [ vue(), AutoImport({ imports: [vue, vue-router, pinia], resolvers: [ElementPlusResolver()], dts: src/auto-imports.d.ts, }), Components({ resolvers: [ElementPlusResolver()], dts: src/components.d.ts, }), ], resolve: { alias: { : path.resolve(__dirname, src), }, }, })AutoImport里的imports数组声明了要把哪些API自动引入。配置了vue之后ref、computed、watch等函数可以在组件里直接用不用再写import { ref } from vue。vue-router会把useRoute、useRouter导进来pinia则把storeToRefs、defineStore放进来。resolvers是给第三方UI库用的ElementPlusResolver会在代码里检测到el-button时自动引入对应的组件和样式。Components插件做的是同样的事但针对的是组件标签。它扫描template里的自定义标签按resolver规则找到组件目录自动import并注册。两个插件的dts参数会在项目根目录生成一份类型声明文件TypeScript因此能识别全局的ref和ElMessage等变量编辑器里也能正常提示。路径别名/components取代了长串的相对路径。你注意一下tsconfig.json里也要配对应的paths否则编辑器会提示找不到模块。我见过有人只改了vite.config.ts忘了tsconfig结果构建正常但IDE一直飘红这种不一致的坑在第5章还会提到。4. 核心模块代码解析请求层、状态层与路由层的完整链路这一章是把模板当成“黑匣子”拆开看。一个用户从输入地址到看到数据会依次经过路由守卫、Pinia状态、Axios请求。把三层理解清楚改业务就有底了。4.1 Axios请求层拦截器、错误码与自动携带Token请求层是模板里最稳定的一段逻辑它负责把“后端返回格式不统一”这类脏活全部挡在业务之外。模板的src/utils/request.ts大概是这样的import axios from axios import { ElMessage } from element-plus import { useUserStore } from /stores/user const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, }) // 请求拦截器自动携带token service.interceptors.request.use( (config) { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }, (error) Promise.reject(error), ) // 响应拦截器统一处理业务码 service.interceptors.response.use( (response) { const { code, data, message } response.data // 约定 code 为 0 表示成功其余为业务错误 if (code ! 0) { ElMessage.error(message || 请求异常) return Promise.reject(new Error(message || Error)) } return data }, (error) { if (error.response?.status 401) { const userStore useUserStore() userStore.logout() window.location.href /login } return Promise.reject(error) }, ) export default service创建实例时的baseURL从import.meta.env.VITE_API_BASE_URL读取在.env.development里指向代理地址或后端地址线上构建时由.env.production接管。timeout设了15秒我一般把这个值放在配置中心或环境变量里方便不同环境调整。请求拦截器里每次发请求前都会判断store里有没有token有就加到Authorization头。这里有一个细节在拦截器里调用useUserStore必须确保pinia已经安装到Vue应用里否则会报“no active pinia”第5章会专门讲。响应拦截器默认后端返回的结构是{ code, data, message }。code为0时直接返回data所以业务页面里调用接口拿到的就是真正的数据不用再剥一层。code非0时统一弹出错误提示并reject一个Error。这一步把全项目的告警文案收敛到了一个文件里后端调整文案时只改这里。如果是HTTP层错误比如超时、断网、401就走第二个回调。401的处理统一执行logout并跳到登录页不需要每个页面自己判断登录态失效。如果你要接下载文件的接口注意这里拦截器会把blob也当成response.data去解析需要在请求配置里加responseType: blob并在拦截器里对blob类型做特判否则会误报“code不合法”。这块模板里保留了注释属于常见的翻车点。4.2 Pinia状态层setup store 让模块状态更容易维护模板里的store全部采用setup语法一个文件就是一个store。以登录后的用户信息为例import { defineStore } from pinia import { ref, computed } from vue import { loginApi, logoutApi } from /api/auth import type { LoginParams, UserInfo } from /api/types export const useUserStore defineStore(user, () { const token ref(localStorage.getItem(token) || ) const userInfo refUserInfo | null(null) const isLoggedIn computed(() !!token.value) async function login(payload: LoginParams) { const data await loginApi(payload) token.value data.token userInfo.value data.userInfo localStorage.setItem(token, data.token) } async function logout() { await logoutApi().catch(() {}) token.value userInfo.value null localStorage.removeItem(token) } return { token, userInfo, isLoggedIn, login, logout } })defineStore的第一个参数user是store的唯一id派生的useUserStore是真正在组件里调用的hook。第二个参数传一个setup函数函数内部用ref定义的变量就是state用computed算出的就是getter定义在内部的函数就是action。这种写法和Vue组件的setup保持了一致的思维模型你不需要再记state/getters/mutations/actions四件套。持久化这里选择手动localStorage。同步存入token能在刷新时恢复登录态避免每次刷新都闪一下登录页。如果你想更省事可以引入pinia-plugin-persist但为了模板的依赖精简这里用手写方式。要注意的是不要把整个userInfo都存进localStorage因为里面的头像、权限数组可能更新很频繁只存token并在刷新后用token重新拉取一次用户信息是更合理的方式。跨组件共享状态时组件A和组件B各自调useUserStore拿到的是同一个实例。在组件里消费时如果你想保持响应式又不丢失响应式可以用storeToRefs(userStore)这个API来自pinia会把store的state转成ref效果类似Vue3的toRefs。4.3 路由层动态注册、权限守卫和刷新不白屏路由层分两块静态路由和动态模块路由。静态路由在src/router/index.ts里定义login、404页面和根路径每个业务模块的router.ts导出它自己的路由配置。模块路由的注册发生在全局守卫里这样可以确保权限确认后才会挂载本来就没有权限的页面。核心代码在src/router/guard.tsimport { useUserStore } from /stores/user import { generateRoutes } from /router/generate router.beforeEach(async (to, from, next) { const userStore useUserStore() const isPublic to.meta.public true // 未登录只能访问公开页面 if (!userStore.isLoggedIn !isPublic) { next({ path: /login, query: { redirect: to.fullPath } }) return } // 已登录但动态路由还没注册先注册再重新进入目标 if (userStore.isLoggedIn !userStore.routesLoaded) { const routes generateRoutes(userStore.permissions) routes.forEach((route) router.addRoute(route)) userStore.setRoutesLoaded(true) next({ ...to, replace: true }) return } next() })第一段处理未登录跳转第二段是关键登录后首次跳转时动态路由尚未登记如果直接next()Vue Router会因为匹配不到而落到404。这里先生成routes并addRoute然后用next({...to, replace: true})重新导航一次此时路由表已经完整才能正常进入页面。替换成replace是为了不产生多余的history记录同时避免“redirect循环”的怪现象。routesLoaded这个标志位放在userStore里记录“本会话是否已经注册过动态路由”。没有这个标志刷新页面时会重复执行addRoute同名路由被二次注册会导致控制台警告甚至跳转异常。刷新后token还在routesLoaded会被重置为false于是守卫里重新注册一次这就是刷新不白屏的兜底逻辑。还有一个小细节模块路由的meta字段里尽量带上title和icon模板在layout里用v-for渲染侧边栏菜单时直接读这两个字段。菜单生成逻辑建议也放到守卫之后否则菜单项会随着路由表一起在刷新时闪一次。5. 避坑与常见问题这些“玄学”问题多半是配置层面没对齐5.1 现象自动导入生效了但ESLint把ref、computed全标成“未定义”这是刚用unplugin-auto-import最常遇见的报错。编辑器里一切正常一执行npm run lint就飘红原因在于ESLint并不认识模板生成的src/auto-imports.d.ts它不知道ref这些符号是全局的还当做普通未定义变量在检查。解决方法是让ESLint和TypeScript共用同一套全局声明。我在.eslintrc.js里加一段module.exports { globals: { ref: readonly, computed: readonly, ElMessage: readonly, // ...按auto-imports.d.ts里实际导出的变量名补全 }, }一般情况下使用typescript-eslint插件时更推荐直接引入unplugin-auto-import提供的types配置。还有一种方案是把auto-imports.d.ts放进tsconfig的include里并在ESLint中开启项目内类型感知。只要记住“自动导入让开发者少写import但检查工具不会自动放宽标准”这个坑就能少踩一半。5.2 现象打包后某些图片路径变成404开发时却正常开发环境下一切正常npm run build后部署到子目录图片全找不到了。这是Vite base配置的问题。模板默认base是绝对路径/如果你的站点部署在https://某公司域名/xxx/这样的子路径下静态资源就会去根路径找。解决方式是在vite.config.ts里设置base: /xxx/或者在打包时用命令行参数--base路径。更好的方式是直接交给CI的环境变量export default defineConfig({ base: process.env.VITE_BASE_PATH || /, })这样生产环境在.env.production里VITE_BASE_PATH/xxx/就能动态控制。还有一点容易忽略public目录下的文件引用必须写绝对路径/public/xxx如果你用了相对路径打包后一样会错位。5.3 现象动态路由刷新后白屏只在登录后第一次跳转时正常这个问题几乎每个做权限路由的人都会遇到。原因在上面4.3已经分析过刷新时addRoute的代码没有执行路由表里没有当前path的匹配项Vue Router找不到组件就渲染空RouterView。我经历过最隐蔽的一种情况是已经加了routesLoaded判断但刷新后白屏依旧。检查发现router.addRoute加的是路由对象数组而模块路由里存在多个父级为空的嵌套路由addRoute在注册时要求父路由先存在。我在模板里统一用addRoute(route)且路由对象里不写children浅层嵌套而是把每个模块路由都注册成一级路由菜单再按meta父子关系渲染。如果必须嵌套先从路由数组中提取父路由注册再注册子路由。从粗粒度到细粒度注册可以避免一大半的“路由表没挂上”问题。5.4 现象在request.ts或router.ts里调用useUserStore提示当前没有激活的Pinia这个报错文案是“getActivePinia() was called but there was no active Pinia”通常出现在请求拦截器或路由守卫的非组件代码里。原因很简单pinia的useStore这类hook依赖应用注入的pinia实例在组件setup里不需要手动传入但离开组件上下文后没有自动绑定实例。解决方式有两种。第一种是在main.ts里把createPinia()的返回值存下来在非组件文件里直接import这个实例传进去// main.ts export const pinia createPinia() app.use(pinia)// request.ts import { pinia } from /main const userStore useUserStore(pinia)第二种是调整调用时机在拦截器里不使用store而是直接从localStorage读token。这样虽然能跑但登录态变更后无法同步所以我不推荐。模板最终采用第一种方式既保持响应式又绕开时序问题。5.5 现象env文件里加了变量但页面拿到的是undefined类型声明也报错Vite只暴露以VITE_前缀开头的环境变量后端地址通常叫VITE_API_BASE_URL这是惯例。如果你写的是API_BASE_URL虽然变量存在但import.meta.env里没有它因为Vite会自动过滤掉非VITE_前缀的键。同时TypeScript不知道这个变量的类型编辑器会标红。我的做法是在src/vite-env.d.ts里补充类型声明/// reference typesvite/client / interface ImportMetaEnv { readonly VITE_API_BASE_URL: string readonly VITE_BASE_PATH: string readonly VITE_UPLOAD_URL: string } interface ImportMeta { readonly env: ImportMetaEnv }改动.env文件后记得重跑dev server因为环境变量在启动时读入运行中修改不会被热更新加载这也是一个很常见的“改了没生效”的坑。6. 二次开发把这套骨架改造成自己的工程6.1 启动与构建命令模板的启动命令没门槛先确认Node版本在16以上然后依次执行npm install、npm run dev、npm run build。package.json里的scripts还带了lint、type-check我习惯在接到新项目后先跑一遍确认环境本身干净。开发代理配置在vite.config.ts的server.proxy里改成你本地的后端地址就行。6.2 改造一复制一个模块作为起点不要直接改底层框架代码先复制src/modules/system整个目录改名ops把router.ts里的path改成/ops再改views里的标题。然后把新模块的router导入generateRoutes的合并列表重启dev server访问/ops看页面是否能打开。打通这一步模块就被主路由接受了之后往views里堆页面、往api.ts里加接口都不会破坏整体。6.3 改造二对接真实接口时只需改三个文件第一处是.env.development里VITE_API_BASE_URL指向后端联调地址或代理路径第二处是src/api/types.ts按后端返回定义好interface第三处是确认响应拦截器解构的code/data/message字段名和你后端一致不一致就在request.ts里映射一次。多环境部署同理复制.env.production一份为.env.uat把VITE_API_BASE_URL指到预发环境构建时用--mode uat传参。6.4 验证顺序与收尾改完业务后我按npm run lint、npm run type-check、npm run build这个顺序过一遍。开发环境看着正常的代码很可能在类型检查阶段暴露null的隐患build构建能帮你发现资源路径和chunk体积的问题。从那以后我每次开新工程都强制先走一遍“复制模板→空模块冒烟→再写业务”这个让项目中期不失控的秘密很简单组织的决定权从个人习惯挪到了模板约束里。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询