简介:面向中高级前端开发者的 Vue3 项目模板,以 Vite 为构建基础,将 Composition API、Vue Router、Vuex 状态管理和组件分层整合进清晰的目录结构,适合用作新项目起始脚手架或团队内部基线,解决从零搭建时配置繁琐与规范缺失的问题。压缩包共 233 个文件,体积约 9.9MB;其中 Markdown 说明文档多达 159 个,另有 Vue 组件、JS/TS 逻辑、CSS 样式、HTML 示例、JSON 配置以及 ESLint/Prettier 等工程化文件,基本覆盖开发、构建、规范检查与文档记录多个环节。资源中还包含水平居中布局、正则、rem 单位等前端常见知识点笔记,以及少量 SQL/Excel 辅助文件,可一边阅读解析一边对照模板代码,理解 Vue3 核心特性与 Vite 工作流。目前已有 784 人学习下载,适合希望快速搭建规范工程、并系统梳理 Vue3 与前端工程化实践的开发者。
1. 项目整体设计与组织思路
1.1 为什么要重新思考Vue3项目组织
这几年帮团队搭建过不少Vue3项目,也接手过各种"祖传代码",说实话,大部分工程的问题根本不在功能写不出来,而是项目一大了,代码放哪、怎么放、谁来放,完全靠自觉。今天这套模板的核心结论是:用一套约定俗成的目录结构、统一的数据流和逻辑复用方式,把项目组织从"个人风格"变成"团队共识"。
Vue3相比Vue2最大的变化不是性能、不是Diff算法,而是Composition API给了我们真正意义上的逻辑复用单元。Vue2时代我们复用逻辑靠mixin,mixin的缺陷是命名冲突、来源不明、依赖隐式,项目一旦复杂起来就是灾难。Vue3的组合式函数(Composables)解决了这些问题,但如果目录组织不配套,这个优势照样发挥不出来。我见过太多项目,Composition API全写在组件里,一个SFC一千多行,跟Vue2时代的Options API写法没什么本质区别。
注意:我强调"组织"而不只是"目录",是因为一套好的项目结构应该让新成员看一眼就知道什么东西放哪里、数据从哪来、组件怎么通信。约定大于配置,但前提是有约定。
1.2 这套模板解决了哪些痛点
结合我这些年做中后台管理系统、商城前端、数据可视化大屏的实际经验,常见痛点主要有四类:
- 组件目录只有
components一个文件夹,几百个组件堆在一起,找东西全靠编辑器搜索 - 接口请求散落在各个组件中,后端一改接口,全项目到处找
axios调用点 - 全局状态管理越用越乱,最后连
store里存了什么都要凭记忆 - 没有统一的逻辑复用规范,同样的校验逻辑、格式化逻辑在多个页面各写一遍
这套优雅的vue3项目组织模板的设计目标就是逐一解决上述问题。它不追求炫技,不堆砌依赖,而是把工程中最高频、最核心的组织方式固化下来,让你拿到手就能直接用。
从技术栈选择上看,构建工具直接用Vite而非Webpack,原因不言自明——Vite的esbuild预构建、native ESM加载开发服务器,在冷启动速度和热更新速率上比Webpack好一个量级。Vue3 + Vite + TypeScript + Pinia + Vue Router + Axios是当前兼顾主流度、生态成熟度和开发体验的最优组合,这套模板就基于这套栈组织。
2. 核心目录结构与工程化配置
2.1 分层清晰的源码目录设计
这套模板的目录组织采用"按业务域划分 + 按文件类型归类"的混合模式,既保证相关代码高内聚,又避免文件类型混乱。完整结构如下:
src/ ├── api/ # 接口请求统一存放 │ ├── modules/ # 按业务模块划分的接口文件 │ ├── types.ts # 接口相关的TypeScript类型定义 │ └── request.ts # Axios实例封装 ├── assets/ # 静态资源(图片、字体、全局样式变量) ├── components/ # 通用基础组件 ├── composables/ # 组合式函数(逻辑复用核心) ├── layouts/ # 布局组件(如侧边栏+顶栏+内容区框架) ├── router/ # 路由配置 │ ├── routes.ts │ └── index.ts ├── stores/ # Pinia状态管理 ├── styles/ # 全局样式与主题定制 ├── types/ # 全局类型声明(env.d.ts、vite-env.d.ts等) ├── utils/ # 纯工具函数(不涉及业务) └── views/ # 页面级组件,按业务模块分文件夹 └── dashboard/ ├── index.vue └── components/ # 该页面私有组件这套结构与传统的components大杂烩最大的区别在于三个认知:
views下的页面允许拥有自己的components子目录,页面私有组件不要塞到全局components里,组件只在被两处以上复用时才上提到全局api独立成目录,并按照后端业务模块拆分成模块文件,比如user.ts、order.ts、goods.ts,组件里禁止直接写axios.get(...)这种裸调用composables目录专门放逻辑复用函数,命名统一用use前缀,比如useTable.ts、useForm.ts、usePermission.ts
2.2 Vite别名与路径简化配置
路径别名是工程化基础配置中第一件要做的事,没有别名的Vue3项目等于没装修的毛坯房。不断出现../../../utils/format这种层级路径的代码,一旦目录调整,全部import路径跟着改,这谁受得了。
在vite.config.ts中配置:
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)), '@api': fileURLToPath(new URL('./src/api', import.meta.url)), '@views': fileURLToPath(new URL('./src/views', import.meta.url)), '@composables': fileURLToPath(new URL('./src/composables', import.meta.url)) } } })这里没有用path.resolve(__dirname, 'src')的方式,而是用fileURLToPath,原因是ESM模式下__dirname不是直接可用的,这个写法在Vite和Vitest环境下都通用,避免后续写测试时踩坑。
同时需要在tsconfig.json中配置对应的paths映射,否则TypeScript检查会报错找不到模块:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], "@api/*": ["src/api/*"], "@views/*": ["src/views/*"], "@composables/*": ["src/composables/*"] } } }2.3 Axios请求层的统一封装
接口层是前端项目最容易写乱的部分。很多项目的Axios配置、拦截器、错误提示分散在各处,甚至某些组件里直接import axios from 'axios'写死baseURL。这套模板把请求层收敛到一个request.ts中,核心工作在三个地方:
- 创建Axios实例,配置
baseURL、超时时间、请求头 - 请求拦截器:自动携带token、加时间戳防止缓存、统一处理请求配置
- 响应拦截器:统一摘取业务数据、错误码判断、401跳转登录、网络错误提示
// src/api/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 }) service.interceptors.request.use((config) => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.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) => { if (error.response?.status === 401) { // 清除登录状态,跳转登录页 } ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default service业务接口文件只是调用service并导出带类型的函数,组件中这样使用:
import { getUserInfo } from '@api/user' const userInfo = await getUserInfo(userId.value)这样做的好处是:接口改动只动api目录下的文件,组件层完全无感知。测试也方便,Mock接口只需替换api目录实现。
3. 组合式函数与组件通信实操
3.1 Composables如何封装业务逻辑
composables是Vue3组织逻辑的核心抓手。我强烈建议在模板中新建项目时,先约定一个规则:视图组件只负责"组装",业务逻辑都想办法往composables里拨一点。
这里分享一个最常见的表格页面的封装思路。后台管理系统80%的页面是"搜索条件 + 表格 + 分页"结构,如果每个页面都重复写加载逻辑、分页逻辑、重置逻辑,那就是在浪费生命。抽出useTable这个组合式函数:
// src/composables/useTable.ts import { ref, reactive, onMounted } from 'vue' import type { TablePaginationConfig } from 'ant-design-vue' export function useTable<T>( fetchApi: (params: any) => Promise<{ list: T[]; total: number }>, initParams: Record<string, any> = {} ) { const loading = ref(false) const tableData = ref<T[]>([]) const pagination = reactive({ current: 1, pageSize: 10, total: 0 }) const queryParams = reactive({ ...initParams }) const loadData = async () => { loading.value = true try { const { list, total } = await fetchApi({ page: pagination.current, pageSize: pagination.pageSize, ...queryParams }) tableData.value = list pagination.total = total } finally { loading.value = false } } const handleSearch = () => { pagination.current = 1 loadData() } const handleReset = () => { Object.keys(queryParams).forEach((key) => delete queryParams[key]) Object.assign(queryParams, initParams) handleSearch() } const handlePageChange = (page: number, pageSize: number) => { pagination.current = page pagination.pageSize = pageSize loadData() } onMounted(loadData) return { loading, tableData, pagination, queryParams, loadData, handleSearch, handleReset, handlePageChange } }页面中使用时,模板上直接绑定返回的属性,逻辑分层很清楚。如果是团队里使用,建议把这类高频composable沉淀到模板中,而不是每个人各自封装一套,这也是这套模板的最大价值——统一逻辑写法。
3.2 组件通信的规范与defineEmits类型约束
Vue3组件通信方式和Vue2相比变化很大,很多从Vue2转过来的同学容易用错。先说规范上的建议,再讲类型约束。
组件通信优先级从高到低建议是:props下行 +emit上行 >v-model>provide/inject>Pinia>mitt事件总线。能在局部解决的问题不要拿到全局状态里。
Vue3.3之后defineProps和defineEmits支持泛型写法,比传统的运行时声明更可控:
<script setup lang="ts"> interface Props { title: string count?: number items: { id: number; name: string }[] } interface Emits { (e: 'update:title', value: string): void (e: 'delete', id: number): void } const props = defineProps<Props>() const emit = defineEmits<Emits>() const handleDelete = (id: number) => { emit('delete', id) } </script>注意几点实操约定:
props在<script setup>中默认不是响应式的,不要直接对props做解构然后期望模板响应更新,Vue3.5的usePropsDestructure解决了一部分,但稳妥的做法是const count = computed(() => props.count)或者直接用props.count- 事件名建议用
kebab-case还是camelCase,全组统一。个人建议模板中监听用@delete-item这种方式,声明时用deleteItem,只要别混着用就行 - 组件中要修改父组件传递的状态,只有一种标准姿势——
emit事件由父组件改。不要用watch监听props然后改自身状态,那会陷入"同步地狱"
3.3 状态管理的选择与Pinia的Store设计
Vuex在Vue3中依然是可用的,但Pinia已经成为事实标准,模板直接采用Pinia。它的优势在于极简API、去掉了mutations、完美的TypeScript类型推导。
Store的组织方式有两种流派:按业务域切分(userStore、orderStore)或按页面切分(dashboardStore)。我推荐前者,因为状态往往跨页面共享,按页面切分会导致store数量爆炸。
举例,用户登录态store的设计:
// src/stores/user.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { login, getUserInfo } from '@api/user' export const useUserStore = defineStore('user', () => { const token = ref(localStorage.getItem('token') || '') const userInfo = ref<UserInfo | null>(null) const isLoggedIn = computed(() => !!token.value) const loginAction = async (username: string, password: string) => { const { token: newToken } = await login({ username, password }) token.value = newToken localStorage.setItem('token', newToken) } const fetchUserInfo = async () => { userInfo.value = await getUserInfo() } const logoutAction = () => { token.value = '' userInfo.value = null localStorage.removeItem('token') // 可选:router.push('/login') } return { token, userInfo, isLoggedIn, loginAction, fetchUserInfo, logoutAction } })采用的setup store写法(函数式)比options store更适合TypeScript,中间状态逻辑也更清晰。记住一点:Pinia中不要存所有数据,只有真正需要跨组件共享的高频数据才放入store,页面的临时筛选条件留在组件内部就行。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
把我在搭建和使用这套模板过程中实际遇到、帮同事解决过的问题整理成表,有同类问题可以按图索骥:
| 问题现象 | 根因分析 | 解决方案 |
|---|---|---|
Vite创建项目后@别名报红 | tsconfig.json缺少paths配置 | 在tsconfig.json中配置baseUrl和paths |
| 路径别名在Vite生效但TS报错 | 只在vite.config.ts设置了alias | 同步配置tsconfig.json的paths,再重启IDE |
props解构后模板不更新 | <script setup>中直接解构props丢响应性 | 用computed包一层再使用,或整体引用props.xxx |
defineEmits类型检查不通过 | 事件名大小写不一致 | 声明时用camelCase,模板监听用kebab-case,全项目统一 |
Axios拦截器里useUserStore()报错 | Pinia实例尚未安装 | 在main.ts中确认app.use(pinia)在组件渲染前执行,不在拦截器顶层调用 |
| 组件循环引用导致警告 | 组件A引B,B又引A | 将低频引用改为动态导入defineAsyncComponent,或调整组件结构 |
| 热更新时页面状态丢失 | 改动setup顶层函数导致组件重新挂载 | 这是Vite + HMR的正常行为,把昂贵状态移到Pinia |
| 刷新页面后Pinia状态丢失 | store中没有做持久化 | 手动从localStorage读取初始值,或用pinia-plugin-persistedstate |
4.2 创建项目时的几个实操坑
用npm create vue@latest(官方脚手架)和npm create vite@latest创建项目时,注意以下区别:官方Vue脚手架内置了ESLint、Prettier、Vue Router、Pinia的可选项,而Vite原生模板只提供最小骨架。模板工程建议基于官方Vue脚手架选择需要的特性选项,避免把时间花在重复配置lint和router上。
创建时需要留意:
package.json中的"type": "module"会让.js文件按ESM解析,有些旧的Node工具可能不兼容,工具配置尽量.mjs后缀处理npm install如果慢,用pnpm替代,Vite官方对pnpm的支持更好,按依赖的硬链接机制能省下一大块磁盘空间- 如果从
git clone一个模板,务必先删除node_modules和.git目录,重新npm install,避免上游依赖残留
4.3 一个真实的排错记录
有一次给项目添加sass时,scss全局变量在<style lang="scss">中一直无法引用,报错信息是SassError: Undefined variable。排查过程很典型:
第一反应是检查是否在vite.config.ts中配置了css.preprocessorOptions.scss.additionalData,确认有配置。接着发现报错只发生在部分组件中,另一些正常。逐文件对比差异后,发现报错的组件使用了<style scoped lang="scss">且内部重新@use了别的scss文件,导致覆盖了全局变量。
最终结论是:additionalData会在每个scss文件顶部注入,但如果样式里显式@use其他文件并带namespace,注入的全局变量会被清除。解决办法是把全局变量抽到独立的variables.scss文件中,组件中显式@use '@/styles/variables.scss' as *;,不要只依赖additionalData。
这种问题文档里很难查到,所以分享出来,遇到同类报错的同行可以少走弯路。
5. 模板的使用方法与扩展建议
5.1 三步启动新项目
拿到这套模板,从零开始一个新项目只要三步:
第一步,复制模板基础文件,重命名项目。如果模板托管在Git仓库,可以用degit(比git clone更干净,不带.git历史):
npx degit user/vue3-project-template my-project cd my-project npm install第二步,检查.env.development和.env.production环境变量文件,改掉VITE_API_BASE_URL并补充你的后端接口地址。环境变量的命名必须以VITE_开头,否则无法暴露给客户端代码:
# .env.development VITE_API_BASE_URL=/api VITE_MOCK_ENABLED=true # .env.production VITE_API_BASE_URL=https://api.example.com第三步,启动开发服务器,验证目录结构是否按项目实际业务调整:
npm run dev大概率你不需要大改,只需在views、api/modules、stores几个目录下新建对应业务模块的文件夹即可。
提示:把
src/api/modules下的示例文件删掉并替换成你的真实接口文件,避免脚手架自带的示例请求干扰。
5.2 按业务规模做减法
这套模板适合中小型管理系统、商城前端、工具型Web应用。如果你的项目很小(三五个页面),不必照搬全部结构,可以砍掉layouts、缩减composables。反之,如果项目预计会膨胀到几十个页面,建议在此基础上增加:
router中按业务模块使用动态import做路由懒加载- 引入
unplugin-auto-import和unplugin-vue-components实现自动按需导入(注意:这类插件会让代码中隐式导入变多,团队新人可能看不太懂,需要约定) - 增加
mock目录,本地开发时用vite-plugin-mock模拟接口数据,前后端并行开发不阻塞
其中路由懒加载在Vue3中写法如下:
const routes = [ { path: '/dashboard', name: 'Dashboard', component: () => import('@views/dashboard/index.vue'), meta: { title: '工作台', requiresAuth: true } } ]这比静态import的打包体积和首屏加载速度好很多,webpackChunkName注释在Vite中也能用。
5.3 团队落地的三条建议
模板最后好不好用,取决于团队能不能一致执行。这里根据我在多个前端团队推广规范化模板的经验,补充三条接地气的建议:
第一,模板的README要写清楚目录规范和命名约定。比如"组件文件一律PascalCase开头,组合式函数统一use前缀,接口模块文件用对应资源单词"等。规范写下来,新人才有据可查。
第二,提交代码前执行npm run lint和npm run type-check,在CI/CD流程中把类型检查和lint作为强制关卡。很多看起来"能用但很乱"的问题,其实都是类型不严格加没有lint导致的。
第三,模板要持续迭代。每做完一个项目,回头审视哪些代码被重复复写了、哪些目录变得臃肿,把这些经验固化回模板。模板不是建好就不动了,它应该跟着团队一起成长。
我个人在实际操作中最深的一个体会是:大多数项目不差技术,差的是"一致性"。统一的目录、统一的命名、统一的请求层、统一的逻辑复用方式,能让一个10人团队的前端代码看起来像一个人写的,这比单纯引入某个新框架或新工具带来的收益大得多。你在搭自己的Vue3项目时,先把这套组织骨架立起来,后续往里面填业务代码,就会省掉大量后面重构的力气。
本文还有配套的精品资源,点击获取