简介:一款面向 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 | 路由 | 支持动态路由与约定式meta |
| Pinia | 状态管理 | 天然配合setup语法,比Vuex轻很多 |
| Axios | 请求 | 拦截器稳定,生态丰富 |
| unplugin-auto-import | 自动导入 | 免去ref/computed没完没了的import |
| unplugin-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配置,由主路由动态addRoute;store.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 = ref<UserInfo | 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.ts:
import { 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 types="vite/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体积的问题。从那以后,我每次开新工程都强制先走一遍“复制模板→空模块冒烟→再写业务”,这个让项目中期不失控的秘密很简单:组织的决定权从个人习惯挪到了模板约束里。希望帮到你。
本文还有配套的精品资源,点击获取