
1. 从零搭建Vue3工程默认目录为什么要这么分1.1 create-vue生成的骨架到底包含什么每次新开一个Vue3项目我都建议直接用官方脚手架create-vue而不是自己手动搭webpack或者vite配置。原因很简单官方脚手架生成的目录结构每一层都是经过社区反复验证的你在这个基础上做加减法远比从零拼装要稳定得多。用create-vue创建项目之后进入根目录你会看到下面这些默认文件。my-vue3-project/ ├── index.html ├── package.json ├── vite.config.js ├── public/ ├── src/ │ ├── main.js │ ├── App.vue │ ├── assets/ │ ├── components/ │ ├── router/ │ ├── stores/ │ └── views/ ├── .env.development ├── .env.production ├── .eslintrc.cjs ├── .prettierrc.json └── README.md很多人刚接触这个结构时会有个疑惑为什么public目录和src/assets目录同时存在两者都能放静态资源到底有什么区别简单说public目录里的文件会被原封不动地复制到构建输出的根路径下不经过任何打包处理适合放favicon.ico、第三方静态脚本、robots.txt这类不需要被模块引用的文件。src/assets目录则会被webpack或者vite的模块系统处理参与打包、压缩、hash重命名适合放图片、样式文件等被组件引用到的资源。我见过不少项目把图片往public里一丢就完事了。如果只是三五张小图问题不大但一旦图片多了public目录会越堆越乱还失去了构建工具的压缩与指纹能力每次改图都要手动处理浏览器缓存。所以我的习惯是凡是能被import或require引用的一律放src/assets只有那些必须保持固定URL的全局文件才放public。还有一点值得注意setup是Vue3的标配组件标签里的script需要写script setup语法它会自动暴露变量给模板不用写return代码量比Vue2的Options API少一大截。等会儿我会详细说它对目录结构的影响这里先记下这个关键点script setup改变了我们组织组件的方式。1.2 src目录内部的职责边界官方骨架里的src目录是最朴素的五件套main.js、App.vue、assets、components、views加上手动添加的router和stores。这七个角色各管一摊互相尽量不越界是Vue3项目目录纪律的基石。main.js负责应用入口干的事固定三件创建应用、挂载路由、挂载Pinia或者Vuex。很多小伙伴喜欢往main.js里塞一堆全局注册代码比如全局组件、全局指令、全局方法。这是典型的“入口膨胀”。main.js一旦超过一屏项目的初始化逻辑就很难读。我的做法是main.js只保留最核心的启动三步其他任何集成逻辑都抽到独立的模块文件中比如plugins/目录下建一个ant-design.js专门按需注册UI组件建一个directives.js统一注册自定义指令。App.vue是根组件只管布局框架顶部导航、侧边栏、内容区路由出口router-view。它不关心页面数据和业务逻辑子页面通过路由懒加载挂在views下面。components目录放通用可复用组件views目录放页面级组件。这个边界得划清楚否则到项目后期必然混乱。通用组件是那些和业务解耦的按钮、弹窗、表格封装的二次封装、表单控件等。页面组件是根据路由一一对应的登录页、首页、用户管理页、商品列表页。那业务场景里提取出来的共享组件放哪里比如后台管理系统中“用户管理页”和“角色管理页”都用到同一套带权限的表格组件直接放components里好像不够通用放views里又没法跨页面复用。我的方案是额外加一个components/business/子目录或者src/business-components/专门放业务组件和纯通用组件分开。后面讲到后台管理系统组织时我会再展开。建议先从“官方的约定”开始理解再根据项目体量动态调整。目录结构不是越复杂越好而是要让新成员进来10分钟内能说清楚“这个功能的代码在哪”这就成功了。2. 目录结构背后的组织思想2.1 按功能分层还是按业务模块划分接下来是比较核心的话题目录结构到底按什么逻辑划分我见过的Vue3项目中划分方式无非两种按技术功能划分、按业务模块划分。按技术功能划分就是官方骨架的做法所有路由归router所有状态归stores所有组件归components。这种方式在小项目里非常好用因为技术定位清晰查找方便。但项目一旦做到几百个页面、上千个组件问题就出现了一个业务功能往往散落在router、stores、components、views、api五个目录里想改一个“订单管理”功能要在这五个地方来回跳转。按业务模块划分则是先切业务线再在业务模块内部建立各自的技术子结构。比如电商后台src/ ├── views/ │ ├── order/ │ │ ├── OrderList.vue │ │ └── OrderDetail.vue ├── components/ │ ├── order/ │ │ ├── OrderStatusTag.vue │ │ └── OrderLogTable.vue ├── stores/ │ ├── order.js ├── api/ │ ├── order.js └── router/ └── order.js这样划分的好处是一个业务的所有相关代码天然内聚改动时上下文不跳转。坏处是如果业务模块很多views/order和components/order下面的目录树会非常深而且跨模块共用的组件难以安放。以我做了多个中大型项目的经验来看更合理的做法是混合式顶层按业务模块建立目录每个业务模块内部再按技术类型组织。以上面目录为模板src/ ├── modules/ │ ├── order/ │ │ ├── pages/ │ │ ├── components/ │ │ ├── store.js │ │ ├── api.js │ │ └── router.js │ ├── user/ │ ... ├── shared/ │ ├── components/ │ ├── hooks/ │ ├── utils/ │ └── constants/shared目录存放跨模块共用的东西modules目录按业务域聚合。这种组织方式在微前端、大型应用中很常见逻辑清晰扩展性强。当然它也有代价初始化时几十个大目录叠着对新手不够“即视”。但项目规模上来之后这种代价是值得的。实际上前端架构的主要矛盾往往是“可管理性”和“上手成本”的平衡。我的建议是10个页面以内的项目官方骨架够用几十个页面的项目最好开始引入模块化目录上百个页面则必须考虑模块化和共享层分离了。2.2 Composition API对目录组织的直接影响Vue3对目录结构最大的冲击其实来自Composition API和script setup语法。Vue2时代组件内代码天然按data、methods、computed、watch划分这是Options API强制规定的。单一组件代码过长时你只能“按类型切块”。但一个业务逻辑往往横跨这些块你改了一个响应式数据就得同时改对应的methods和watch代码行数一多人脑很难同时照顾三个位置的上下文。Vue3的组合式函数则改变了这一切。你可以把一个业务逻辑完整地封装到独立的src/hooks/或composables/目录中// src/hooks/useOrderList.js import { ref, computed, onMounted } from vue import { fetchOrderList, deleteOrder } from /api/order export function useOrderList(params) { const loading ref(false) const list ref([]) const total ref(0) const totalPrice computed(() list.value.reduce((sum, item) sum Number(item.price), 0) ) async function load() { loading.value true try { const res await fetchOrderList(params) list.value res.records total.value res.total } finally { loading.value false } } async function removeOrder(id) { await deleteOrder(id) await load() } onMounted(load) return { loading, list, total, totalPrice, removeOrder } }然后在组件里只需一行引入就能把逻辑接进来script setup import { useOrderList } from /hooks/useOrderList const { loading, list, total, removeOrder } useOrderList() /script这就是组合式函数Composable的价值逻辑可以被抽出、复用、测试不需要依赖组件实例上下文。目录结构也随之响应式地调整——hooks/目录在Vue3项目中会自然出现并且逐渐变大。很多人对比Vue3 Composition API和Vue2 Options API时只盯着写法不同。但目录结构才是关键落点Composition API允许你把“同一业务的代码”放在一起而不是把“同一类型的代码”放在一起。所以当你看到一个新项目只有一个巨大的views/Home.vue、里面写了两千行代码这说明它没有真正拥抱Composition API的思维方式还是用Options API的心智在写Vue3。2.3 资源管理与静态文件策略资源管理这块也是目录结构的重要组成部分。除了前面说过的public和assets区别之外Vue3项目的样式组织和静态资源路径管理也有些讲究。在组件内写样式时Vue3默认支持scoped属性会让样式只作用于当前组件的DOM节点。如果你在Assets或全局样式表中写了同名类名很可能出现“全局样式改了组件样式没变”或者反过来串样式的情况。我的策略是三层样式划分全局基础样式放在src/styles/index.scss或者src/assets/styles/下负责reset、变量、mixin、覆盖UI框架默认行为组件内样式优先使用scoped负责当前组件私有样式跨组件共享的样式变量或暗黑主题通过CSS变量--primary-color等从根节点注入组件内用var()引用。静态资源路径方面Vite大环境下绝对路径别名一般会被配置到src目录。这是团队协作的关键约定。如果你用相对路径写../../assets/images/xxx.png不仅丑而且只要组件挪一层目录就得改一遍。配置好别名后写法统一为/assets/images/xxx.png挪目录不再影响引用。还有一个容易忽略的点环境变量。.env.development和.env.production分别对应开发与生产环境变量命名必须以VITE_开头才暴露到前端。建议把后端请求的基础路径、第三方Key、部署环境等都放这里不要硬编码在任何组件或工具函数里。养成这个习惯后切换后端地址时只需要改环境变量文件不用动代码。# .env.development VITE_API_BASE_URLhttp://localhost:8080/api VITE_API_TIMEOUT150003. 针对后台管理系统的最佳实践3.1 页面、路由、状态管理与权限控制后台管理系统Admin是Vue3项目里最常见的类型。它的目录结构相比通用项目有几个特殊之处路由需要动态加载、状态管理需要保存个人信息和权限、菜单需要根据路由自动生成。很多热词提到“vue3后台管理系统”这类项目的路由设计通常采取两步走。第一步静态路由包括登录页、404页、重定向页这些页面不需要权限所有人能访问。第二步动态路由根据登录后返回的权限信息前端过滤出当前用户可访问的路由表调用router.addRoute()动态添加。来实现这个目录层面要支持“路由模块化”。我的推荐结构是src/ ├── router/ │ ├── index.js # 创建路由器、全局守卫 │ ├── static.js # 静态路由表 │ └── dynamic.js # 动态路由加工函数 ├── stores/ │ ├── user.js # 用户信息、登录状态、权限集合 │ ├── menu.js # 菜单状态 │ └── tabs.js # 多页签状态以用户状态为例登录后后端返回用户信息和一个按钮权限标识数组前端将其存入Pinia。页面加载时全局守卫里判断是否已登录然后从user中取权限再交给dynamic.js的buildRoutes(permissions)函数生成可访问路由表。这里有几个值得注意的坑。第一个是刷新页面时Pinia里的数据会清空因为Pinia默认不持久化。所以刷新后要重新请求用户信息、重新重建动态路由。否则就会出现“页面白屏”或“直接跳到登录页”。解决办法包括用pinia-plugin-persistedstate做持久化或者在全局守卫里判断当前路由是否在白名单中且store中没有用户信息则重新拉取。第二个坑是动态添加的路由在退出登录时需要重置。router.addRoute()添加的路由是累计的如果不清理下次换账号登录上一个账号的所有页面还在路由表里直接可以通过URL访问不该看的页面。Vue Router 4里有一个removeRoute能力你需要把每次动态添加的routeName记录下来退出时逐个移除。权限控制我见过很多种花样核心其实就三个层面路由级权限控制页面能否访问、菜单级权限控制入口是否显示、按钮级权限控制操作是否可点。按钮级权限一般封装成自定义指令v-permission目录上放在src/directives/permission.js。// src/directives/permission.js import { useUserStore } from /stores/user export default { mounted(el, binding) { const { value } binding const requiredPermissions Array.isArray(value) ? value : [value] const userStore useUserStore() const hasPermission requiredPermissions.some((perm) userStore.permissions.includes(perm) ) if (!hasPermission) { el.parentNode?.removeChild(el) } } }3.2 组件与业务模块的拆分方案后台管理系统里组件拆分往往是最头疼的环节。我见过最糟糕的情况是一个“用户管理”页面把所有表格、弹窗、表单、批量操作全部写在同一个views/user/index.vue里最后这个文件接近三千行任何一次小改动都要小心翼翼。以我的经验一个页面组件超过300行就该开始考虑拆了。怎么拆推荐按“区块”拆。拿用户管理页面举例src/ ├── views/ │ └── user/ │ ├── index.vue # 页面容器负责组合区块 │ ├── UserSearchForm.vue # 搜索区组件 │ ├── UserTable.vue # 表格区组件 │ ├── UserEditModal.vue # 新增/编辑弹窗 │ ├── UserDetailDrawer.vue # 详情抽屉 │ └── useUserPage.js # 这个页面组合逻辑的hook页面容器只负责摆放区块和调用useUserPage()这个hook区块组件负责各自的UI展示hook负责所有数据和增删改查逻辑。各组件之间的数据传递通过props和emit复杂场景下再引入Pinia。这样拆完之后的收益很明显需求变更时不用在三千行代码里找修改点——改搜索条件去UserSearchForm.vue改表格列去UserTable.vue改字段校验去UserEditModal.vue。每个文件的代码量都控制在一屏到两屏之间肉眼阅读体验好代码评审效率也高。热词里提到的“vue3动态添加删除form表单一行数据”这类交互逻辑也适合抽成独立组件。比如配送地址管理每行有一组表单控件支持添加和删除行。你可以封装一个DynamicFormList组件内部管理行数据对外暴露modelValue通过v-model双向绑定这样各处页面复用起来就特别方便。template div v-for(row, index) in modelValue :keyrow.id el-form-item label地址 el-input v-modelrow.address / /el-form-item el-form-item label电话 el-input v-modelrow.phone / /el-form-item el-button clickremoveRow(index)删除/el-button el-button clickaddRow添加/el-button /div /template这里的关键逻辑不是“把表单行数据存到数组里”而是组件如何管理增删后的校验状态与v-model同步这个咱们后面实操里细说。3.3 API层设计与请求封装后台管理系统的核心是数据操作API层的目录设计直接决定了业务代码的整洁度。判断一个项目的API层好不好标准就一条业务组件里是否能看到裸的axios或者fetch调用。如果没有说明封装到位如果满屏是http.get(/user/list)说明API层还没建立起来。推荐的API层结构如下src/ ├── api/ │ ├── http.js # axios实例、拦截器、统一错误处理 │ ├── modules/ │ │ ├── user.js # 用户模块接口 │ │ ├── order.js # 订单模块接口 │ │ └── dashboard.js # 首页统计接口 └── utils/ └── request.js # 类型定义、通用参数处理http.js负责创建axios实例配置baseURL从环境变量读取设置超时时间然后注册请求拦截器和响应拦截器。请求拦截器里干的事一般是从Pinia或cookie中取token放到请求头带上统一的时间戳参数统一处理loading。响应拦截器里干的事一般是根据业务状态码判断成功还是失败成功直接返回业务数据体失败统一弹Message提示并做特定错误码的降级处理——比如401时跳登录页并清理本地用户信息。请求函数必须在api/modules/里面按业务域划分。以订单模块为例// src/api/modules/order.js import http from ../http export function fetchOrderPage(params) { return http.get(/order/page, { params }) } export function fetchOrderDetail(orderId) { return http.get(/order/detail, { params: { orderId } }) } export function createOrder(data) { return http.post(/order/create, data) } export function updateOrder(data) { return http.post(/order/update, data) }组件里调用时只接触业务层import { fetchOrderPage } from /api/modules/order const { records, total } await fetchOrderPage({ page: 1, size: 10 })API层的组织原则要始终如一组件不直接处理HTTP层级的逻辑不知道token怎么加不知道错误码怎么判断。它只需要调用一个语义清晰的函数拿到处理好的数据。这一点比目录名起得多优雅更重要。4. 常见问题与排查技巧实录4.1 页面打不开、路由不跳转做Vue3项目时我最常被问到的就是“页面打不开路由不跳转”。这类问题看起来神秘其实排查方向很固定。先看一下路由不跳转的最常见原因动态路由添加失败。很多人用router.addRoute()添加动态路由后在模板里写router-link to/order/list点击之后地址栏变了但页面内容不动。这种情况下先确认动态添加的是不是父路由。如果你只添加了一个/order为父级、子路由是/order/list的动态路由那么在添加路由之前/order/list这个完整路径在路由表里不存在。Vue Router 4匹配到不存在的路径时会落到通配符路由比如404页。你虽然看到了URL变化但页面永远渲染404。排查步骤很简单打开浏览器控制台输入router.getRoutes()查看当前所有已注册路由。如果/order/list没在里面说明动态添加没生效或者被覆盖了。解决的套路一般是添加父路由时带上完整children路径定义确保/order/list能被直接匹配。第二种常见原因是路由表里存在重复配置兄弟路由用了一个相同的组件路径或者相同的name。Vue Router 4对重复name会报警告但某些情况下不报错就静默出错。我的做法是所有页面路由都显式指定name且name全局唯一。调试时方便很多。第三种情况是动态import的路径写错了。如果你把views/order/OrderList.vue写成views/order/orderlist.vue在大小写不敏感的开发模式下可能正常但生产构建却报错或者白屏。这是因为构建工具在某些操作系统上区分大小写。所以目录名和组件文件名都要统一规范目录用kebab-case或camelCase都可以但必须全局一致文件名里首字母大写的情况要清晰。4.2 状态管理数据丢失“明明登录了刷新页面就退出登录”“页面刚跳转去另一个Tab回来一看数据没了”——这类问题的根源几乎都出在Pinia没有做持久化上。Pinia本身就是内存态管理刷新必然清空。如果要保持登录态必须把必要信息token、用户基本信息持久化到localStorage或sessionStorage。用pinia-plugin-persistedstate是最省事的方案// src/stores/user.js import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: , nickname: , permissions: [] }), persist: { key: admin-user, storage: localStorage, paths: [token, nickname, permissions] } })这里有个细节paths只持久化需要保留的字段不要把loading这种临时状态也存进去。否则可能把上次的loading状态一起存下来刷新后按钮还是锁定状态看着很诡异。另一个状态丢失的场景是你在一个组件里改了store里的数据但另一个组件视图没有更新。这种情况通常是没用storeToRefs取store里的响应式数据。直接结构赋值会丢失响应性// 错误写法 const { count } useCounterStore() // 这个count不是响应式的正确写法// 正确写法 import { storeToRefs } from pinia const store useCounterStore() const { count } storeToRefs(store)这是很多从Vue2切Vue3的人容易踩的坑记住一条原则从Pinia store里取state必须用storeToRefsstore里的actions和getters直接从store实例上拿即可。4.3 TypeScript类型报错与组件响应式问题热词里有“若依vue3 ts报错”这指向的是很多后台管理模板项目里TS类型定义不完善的问题。具体来说常见的有两种报错。第一种是引入.vue文件时TS不认识。Vue3Vite的项目需要在src/env.d.ts里声明.vue模块/// reference typesvite/client / declare module *.vue { import type { DefineComponent } from vue const component: DefineComponent{}, {}, any export default component }如果不加这个声明你在import UserPage from /views/user/index.vue时TS会报“找不到模块”。若依这类生成项目里有时模板版本更新后忘记带上这个d.ts文件就会出现满屏报错。第二种是ref和reactive的误用。Vue3里ref主要处理基础类型和需要显式.value的场景reactive处理对象和数组。但reactive在嵌套很深时会遇到类型推导问题比如const formData reactive({ user: { name: , address: } })当你给formData.user.address赋值时TS有时推导为never或string | undefined导致赋值报错。更稳妥的方案是尽量用ref包裹整个对象配合TS接口定义interface UserFormData { name: string address: string } const formData refUserFormData({ name: , address: })操作时写formData.value.name响应性一样但类型推导完全正常几乎不会出什么怪问题。值得一提的是热词里的“uni-app vue3 ref万能对象”这个说法。在实际开发中ref确实能涵盖绝大多数响应式场景从字符串到对象到复杂嵌套都能用ref管理。reactive适合那些希望自动解包、不想写.value的简单场景一旦遇到类型复杂或属性动态添加的情况ref比reactive稳定得多。4.4 UI框架没有样式或样式不生效“vue3引入所有的ui框架都不生效”这个热词指向一个很经典的问题按需引入UI框架的样式丢失。以element-plus为例如果用了unplugin-auto-import和unplugin-vue-components组件被自动按需引入但样式没有同步加载就会看到“功能有、外观没有”的页面。这种问题先检查vite.config.js中的配置。正确的配置是把ElementPlusResolver同时传给两个插件确保组件和样式一起解析// vite.config.js import AutoImport from unplugin-auto-import/vite import Components from unplugin-vue-components/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers export default { plugins: [ AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ] }另一个样式不生效的原因是全局样式覆盖了组件样式。比如你在src/styles/index.scss里写了一套自定义的el-button样式但没有用更高优先级的选择器或者没有开启scoped导致按钮样式混乱。排查方式很简单打开开发者工具追踪目标元素的最终生效样式来自哪个文件再针对性调整覆盖方式一般用:deep()或者自定义CSS变量。热词里还有一个“vue3修改tabs标签页样式”。tabs标签页在很多后台管理系统里是核心导航组件之一修改样式时如果你的代码里使用了scoped且深层的el-tabs样式直接用普通类名去覆盖大概率不生效。正确做法是style scoped :deep(.el-tabs__item.is-active) { color: #409eff; } /style:deep()是Vue3里替代/deep/和的写法任何需要穿透scoped限制去修改子组件内部样式的场景都优先考虑它。5. 目录结构的进化路径与实用建议5.1 从Vue2迁移到Vue3的项目怎么调整目录很多老项目是Vue2Element-UI栈团队决定升级到Vue3Element-Plus时如果只换依赖、改语法写法目录结构往往还是旧的。这会导致一个很尴尬的情况代码全换成了Composition API但目录还是按data、methods心智来组织的逻辑复用依然困难。我的建议是迁移时不要只做“语法平移”要借机重构目录结构。从旧项目里把纯工具函数抽到utils/、把可复用逻辑抽到hooks/、把API调用统一收敛到api/modules/。这看起来工作量大但如果只是平移语法几个月后你会面临比迁移更难受的维护问题。如果一个页面级组件在Vue2里是两千行迁移时请务必拆分不要原封不动改完语法就收工。拆出来的子组件和hook会让代码可维护性成倍上升这才是升级到Vue3最大的红利。5.2 新项目按这套结构落地时怎么少走弯路我刚入门前端那会儿搭目录全是按自己喜好来结果项目一复杂改个功能要找半天文件。这几年积攒的经验告诉我一个靠谱的目录结构要满足三个“说得清”新同事能说清页面代码在哪、后端联调时能说清接口定义在哪、开会讨论时能说清每个模块的归口是谁。如果你是从零起步我建议第一步先跑一遍官方的create-vue模板然后立刻做三次“公理应会”的检查第一路由表能不能一眼看出有哪些页面第二项目里有没有重复封装axios的地方第三几个页面都能共用到的组件你是不是从第一行代码开始就放到了components/里。这三次检查能帮你把目录结构的坑提前踩掉。另外别急于引入复杂的模块化目录。项目只有几个页面时模块化反而带来多余层级。评估的标准就一条当你在views里找某个页面时从展开目录到定位文件超过三步说明该考虑更合理的分类或更深层的模块划分了。5.3 目录结构与团队协作的关系有个点容易被技术细节盖过去目录结构在团队协作里的作用比我们想象中大得多。目录清晰了CR代码评审时大家能快速定位到改动文件交接时不用靠口头讲解新人上手成本也低。反过来如果目录乱成一团新人进来光是找“修改一个按钮文案”的文件就可能耗掉半小时这个成本在项目组里会线性累积。所以我强烈建议创建一个docs/目录里面放一份PROJECT_STRUCTURE.md用几行文字加一棵目录树写下当前项目的组织约定。内容不用长重点是回答三个问题“页面代码放哪里”“API定义放哪里”“公共组件放哪里”。维护这份文档的成本很低但它能让所有成员都站在同一个认知平面上。目录结构本质上是一种团队契约。它约束的是每个人在放新文件时不需要问别人就能自己做出合理的判断。结尾一点个人体会我从Vue2时代做后台管理系统做到Vue3时代最大的感受是目录结构从来不是“好看不好看”的问题而是“团队协作效率”和“长期维护成本”的问题。一个清晰的项目结构让新人敢改代码、让老人敢放手比任何技术框架选型都更能影响项目的延续性。每次从零搭项目我都会花至少半小时先画目录树而不是急着去写第一行代码。先想清楚约定再开始动手往往比边写边整理要顺畅得多。单独讲某一个目录怎么建意义有限重要的是整个项目从入口到数据层始终守规矩。数据层有统一的API封装视图层按区块拆组件状态层做好持久化和类型化这一套配合下来Vue3项目的开发体验会顺畅很多。