☰
Vue3中后台项目如何组织?模块化分层与组合函数实战
2026/10/18 4:45:06 网站建设 项目流程

简介:面向 Vue3 开发者与前端团队,这份压缩包提供了一套基于 Vite 的优雅项目组织模板,适合希望快速搭建中大型应用、统一代码结构的新项目。模板覆盖 Composition API 组合式逻辑划分、Vue Router 路由配置、Vuex 状态管理、组件层级设计等工程化实践,同时加入 ESLint/Prettier 代码规范,并配有 Sass/Less 等样式处理方案,整体兼顾开发效率与长期维护。资源共 233 个文件、约 9.9MB,其中 159 个 markdown 文档承载功能拆解与开发笔记,17 个 js 和 11 个 ts 文件对应脚本与类型定义,5 个 vue 组件展示入口,另有多个 css、html 及配置文件用于演示基础布局和主题样式,目录结构清晰,便于按模块查阅,已有 786 人学习下载。通过这套模板,可以快速获得一套完整的 Vue3 工程骨架:Vite 构建配置、路由与状态管理示例、公共工具函数,以及响应式布局常见实现。对于正在梳理 Vue3 最佳实践、或准备重构项目结构的开发者,这份模板能直接复用基础设施,减少从零搭建的重复劳动,并借助 md 笔记理解每个模块的设计意图。

1. 这个 Vue3 模板解决的不只是“能跑”,而是“能接手”

团队项目里最常见的 Vue3 项目长什么样?打开 src 目录,views 堆了四十多个文件,components 里既有订单弹窗又有按钮组件,api 目录按页面拆了十来个模块,谁改过谁的文件全靠 git blame 猜。这个模板的核心思路是给 src 内部划出一条清晰的边界:按业务域聚合,而不是按文件类型堆叠。它适合正在从“单页 Demo”往“多人协作的中后台系统”过渡的团队,也适合想看看项目结构还能怎么组织的开发者。模板本身不复杂,Vite + TypeScript + Pinia + Vue Router 这套标准组合,但它把目录分层、请求层封装、Store 拆分、组合式函数的放置位置都做了约定,落点很实,能直接抄。

2. src 内部的分层:按领域聚合,而不是按类型堆文件

2.1 顶层目录:modules 与 shared 的边界在哪里

这个模板的 src 顶层不是传统的 views / components / api 三层,而是拆成 modules 和 shared 两个大块。modules 下面按业务域立目录,比如 user、order、dashboard、settings,每个业务域内部自带完整的 api、components、composables、router、stores、views。shared 则放那些跨模块复用的基础组件、工具函数、全局类型和通用布局。

这套分层的直接好处是“改订单的需求不用离开 order 目录”。传统三层结构里,一个订单弹窗组件要放在 components,订单接口要放在 api/order.ts,订单状态要放在 store/modules/order.ts,一个需求需要同时改三四个目录,而且这些目录之间还互相 import。按领域聚合之后,需求改动基本被限制在同一个模块目录内部,跨模块的依赖只能通过 shared 进行,依赖方向清晰很多。

我接过一个项目,最初是按类型分层的,到后期出现了一个很尴尬的局面:两个业务域都要用同一个表格组件,但各自都不愿意把这个组件放到公共 components 里,因为那个目录已经成了无人维护的“垃圾场”。模板这种领域聚合的方式,从根上避免了这个问题——你没法把订单的表格组件放到 user 模块里,只能放进 shared,而 shared 被约束为无业务语义的纯展示组件。

2.2 为什么传统三层结构到后期会失控

三层结构(views / components / api / store)在小项目里没有问题,它的心智负担最低。但项目文件数到 300 以上时,问题就暴露了:components 目录出现大量“订单选择器”“商品选择器”这类带业务名的组件,api 目录里出现“orderAndUser.ts”这种缝合怪,store 模块之间开始互相引用,因为一个页面同时消费两个业务域的状态。代码没有坏,但“找代码”的成本急剧上升。

领域聚合的核心不是取消公共代码,而是把公共代码的准入标准提高。shared 里只能放“没有业务语义”的东西,比如分页表格组件、状态标签组件、日期工具函数。一旦一个组件开始依赖某个 API 接口或者某个 Pinia store,它就应该下沉到对应的业务模块里。这个边界是强制的,模板的目录结构本身就引导你这么做,而不是靠口头约定。

2.3 模板的目录树与每个目录的职责

动手前先看整体结构,模板解压后 src 内部大致是这样组织的:

src/ ├── api/ # 全局 API 层,仅放跨模块的基础请求 ├── assets/ # 静态资源与全局样式 ├── components/ # 跨业务域的通用组件(无业务语义) ├── composables/ # 全局共享的组合式函数 ├── modules/ │ ├── order/ │ │ ├── api/ # 订单模块的接口定义 │ │ ├── components/ # 订单模块私有组件 │ │ ├── composables/ # 订单模块组合函数 │ │ ├── router/ # 订单模块路由配置 │ │ ├── stores/ # 订单模块状态 │ │ ├── views/ # 订单模块页面 │ │ └── types/ # 订单模块 TS 类型 │ ├── user/ │ │ └── ... ├── plugins/ # 第三方插件注册 ├── router/ # 路由实例与全局守卫 ├── stores/ # Pinia 实例与全局 store ├── styles/ # 全局样式变量与主题 ├── utils/ # 工具函数 ├── App.vue └── main.ts

注意几个细节。第一,modules 下的 router 目录只导出该模块的路由配置片段,由全局 router 目录聚合注册,避免模块之间通过路由互相侵入。第二,stores 目录同理,模块内的 store 文件在模块内部创建,通过统一的 setup 方式导出。第三,types 目录在每个模块内是强制要求的,因为跨模块的类型引用最容易导致循环依赖,模块内单独维护类型文件可以降低这种风险。

这个目录结构不是银弹。如果项目只有一个业务域,按领域聚合反而显得绕,不如传统的三层结构来得直接。但如果你维护的是一个会持续迭代两年以上的中后台系统,模块化组织在长期维护上的收益是实打实的。

3. 工程化配置:从路径别名到 ESLint,把“约定”变成“强制执行”

3.1 路径别名的配置:@/ 指向 src,@m/ 指向 modules,别复制粘贴

模板里用了两级路径别名,第一级@/指向 src 根目录,第二级@m/指向 src/modules。后者的存在是为了让模块内部的引用路径写起来更短,也防止出现../../../../modules/order/api这种噩梦。先看 vite.config.ts 的配置:

// 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)), '@m': fileURLToPath(new URL('./src/modules', import.meta.url)) } } })

这个配置里,@和@m分别映射到 src 和 src/modules。在模块内部引用自己的私有组件时用@m/order/components/OrderTable.vue,跨模块引用 shared 目录时用@/components。两套别名把“模块内”和“全局共享”的边界在代码层面做了强制区分——你在 user 模块里看到@m/order/...,就知道这是一个跨模块的非法引用,代码评审时直接打回。

光配 Vite 还不行,TS 也需要同步配置,否则编辑器和 vue-tsc 会飘红。需要修改 tsconfig.json 中的 paths:

{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], "@m/*": ["src/modules/*"] } } }

3.2 ESLint 与 Prettier:规则不止是缩进,还有 import 顺序

模板的 ESLint 配置里,除了常规的 vue 和 typescript 规则之外,最值得关注的是 import 排序规则。拆过大型项目的人都有这个体感:import 语句乱成一团的文件,通常是多人维护且没有规则约束的文件。配置了 import/order 之后,模块内的外部依赖、内部别名、相对路径会被强制执行顺序:

// eslint.config.js import antfu from '@antfu/eslint-config' export default antfu({ rules: { 'import/order': ['error', { groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], pathGroups: [ { pattern: '@/**', group: 'internal', position: 'before' }, { pattern: '@m/**', group: 'internal', position: 'before' } ], 'newlines-between': 'always' }] } })

这里用到了@antfu/eslint-config这个预设包,它把 vue、typescript、json、yml 的规则都收敛到一份配置里,省去自己东拼西凑各种 plugin 的麻烦。import/order的pathGroups把@/与@m/开头的路径归为 internal 组,排在第三方依赖之后。newlines-between: 'always'强制在不同组之间加空行,这样代码评审看 diff 时能一眼分出新增的依赖是外部包还是内部文件。

如果没有这个规则,最常见的翻车现场是:某开发者把 import 语句按“自己复制的顺序”粘贴,两个 import 之间没有空行,ESLint autofix 跑一遍把全部 import 重排,git diff 里就混入了几十行无关改动。强制排序之后,这种噪音直接消失。多花几秒配置,省下后面每次评审的无效精力,这笔买卖值。

3.3 环境变量:区分全局配置与模块私有配置

模板里对环境变量的管理也很清晰,根目录下保留了 Vite 标准的.env.development和.env.production文件,但模块内部不会再新建.env文件。所有跟业务模块相关的环境变量都收敛到全局,比如 API 基础地址、文件上传地址、是否启用 mock。模块内部不直接读取import.meta.env,而是通过 utils 里的统一配置函数转发:

// src/utils/env.ts export function getApiBaseUrl(): string { const env = import.meta.env if (env.VITE_API_BASE_URL) { return env.VITE_API_BASE_URL } if (env.DEV) { return '/api' } return 'https://api.example.com' }

这个封装的目的不是多此一举。如果模块里直接写import.meta.env.VITE_API_BASE_URL,一旦 key 名变更或环境变量缺失,错误会散布在各个模块内部,排查时要全局搜索。统一走 getter 函数之后,哪天要改成从运行时配置读取,只改一个文件即可。Vite 环境变量的VITE_前缀本身就是第一层筛选,非 VITE_ 前缀的变量不会暴露给客户端,这是一个经常被忽略的安全边界。

4. 请求层与 Store 的组织:把网络状态从组件里剥出去

4.1 axios 实例封装:拦截器只做一件事

模板的请求层不是简单地封装一个 axios 实例,而是把拦截器的职责做了严格限定——请求拦截器只负责附加 token,响应拦截器只负责剥离业务数据和统一错误处理。先看核心代码:

// src/utils/request.ts import axios from 'axios' import { useUserStore } from '@/stores/user' const request = axios.create({ baseURL: getApiBaseUrl(), timeout: 15000 }) request.interceptors.request.use((config) => { const userStore = useUserStore() const token = userStore.token if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( (response) => { // 假设后端包裹结构为 { code, data, message } const res = response.data if (res.code !== 0) { // 业务错误交给调用方处理,但统一弹出提示 console.error(`[API Error] ${res.message}`) return Promise.reject(new Error(res.message)) } return res.data }, (error) => { // HTTP 层错误集中处理 if (error.response?.status === 401) { // 统一跳转登录页,避免每个页面自己写 const userStore = useUserStore() userStore.resetToken() window.location.href = '/login' } return Promise.reject(error) } ) export default request

注意几个设计决策。第一,响应拦截器把业务数据res.data直接返回,调用方拿到的就是后端 data 字段,不用在业务代码里每处都写response.data.data。第二,401 的跳转逻辑收敛在拦截器里,如果散落在业务代码里,会出现同一个接口被两个页面调用时一个跳转一个不跳转的诡异行为。第三,拦截器里拿 token 用的是 store 的 getter,这样 token 变化时请求层能感知,而不是把 token 存在一个模块级变量里。

请求实例导出后,业务模块的 api 文件就变成了薄封装。以订单模块为例:

// src/modules/order/api/index.ts import request from '@/utils/request' export function fetchOrderList(params: OrderListParams) { return request.get('/order/list', { params }) } export function createOrder(data: CreateOrderPayload) { return request.post('/order/create', data) }

这个薄封装的意义在于:接口 URL 和请求参数类型被限制在 api 文件内,组件和 composable 不直接操作 axios。后续接口路径调整时,只需要改 api 文件;参数类型变更时,TS 的编译错误会精确告诉你哪些调用点需要同步修改。

4.2 Pinia 的模块化拆分:setup store 不一定比 option store 优

模板的 stores 目录分两层:全局 stores 与模块 stores。全局 store 只放跨模块共享的状态,比如用户信息、权限集合、主题配置。模块 store 放在modules/[业务域]/stores里,只服务本模块的页面。看一个典型的模块 store 实现:

// src/modules/order/stores/order.ts import { defineStore } from 'pinia' import { ref } from 'vue' export const useOrderStore = defineStore('order', () => { // state const orderList = ref<OrderItem[]>([]) const loading = ref(false) // actions async function fetchList() { loading.value = true try { const data = await fetchOrderList() orderList.value = data } finally { loading.value = false } } function clearList() { orderList.value = [] } return { orderList, loading, fetchList, clearList } })

模板在 store 风格上推荐 setup store,但有一个使用前提:模块 store 只暴露 ref、computed、函数,不要在模块 store 内部 import 其他模块的任何东西。这个限制比风格本身更重要。如果订单 store 里引用了用户 store 的 state,两个模块的 store 就产生了隐式的运行时依赖,而 Pinia 实例统一注册时不会提示循环依赖的错误,等你排查问题时已经是“黑匣子”状态。

4.3 组件里怎么消费:组合函数把请求和 store 串起来

有了 api 和 store 之后,组件里不需要直接写业务逻辑。模板在 modules 下的 composables 目录里放组合函数,一个页面场景对应一个函数。订单列表页的消费方式是这样的:

// src/modules/order/composables/useOrderList.ts import { useOrderStore } from '../stores/order' export function useOrderList() { const store = useOrderStore() async function loadOrders() { await store.fetchList() } function resetOrders() { store.clearList() } return { orderList: storeToRefs(store).orderList, loading: storeToRefs(store).loading, loadOrders, resetOrders } }

组件里使用时,只要解构出所需的状态和函数:

<script setup lang="ts"> import { onMounted } from 'vue' import { useOrderList } from '../composables/useOrderList' const { orderList, loading, loadOrders } = useOrderList() onMounted(() => { loadOrders() }) </script>

这里的重点是storeToRefs。如果直接从 store 解构orderList,它会失去响应性,页面数据不会更新。这是一个高频翻车点,尤其是刚从选项式 API 转过来的人常常忽略 setup store 的响应式要求。组合函数把 store 的获取、解构和 action 的调用统一封装,组件写起来更清爽,也规避了这个常见错误。

5. 避坑:模板不生效、别名飘红、热更新不刷新的三个真实现场

5.1 现象:路径别名在 VS Code 里飘红但编译能过

拿到模板后第一件事就是跑npm install和npm run dev,页面能起来,但 VS Code 里所有@m/开头的 import 都标红色波浪线。重启 VS Code 没用,右下角提示的 TS 版本也没问题。

原因:VS Code 的 TS server 默认读取的 tsconfig.json 里没有配置paths,模板里只给了 vite.config.ts 的别名,没有同步 tsconfig 的 paths。编辑器报错不影响 Vite 的编译,因为 Vite 用的是 esbuild 的 alias 解析,跟 TS 的路径解析是两套体系。

解决:修改 tsconfig.json,在compilerOptions里补充paths配置,然后重启 TS server。操作路径是 VS Code 命令面板搜 “TypeScript: Restart TS Server”。这个步骤不能省,否则 vue-tsc 做类型检查时也会一路报错,CI 流程根本跑不过。

5.2 现象:改了某个模块的组件,页面热更新不刷新

场景:在 order 模块里加了一个新组件,页面引用之后,浏览器热更新没有生效,刷新页面才看得到。再改一个组件里的文案,热更新失效,必须手动刷新。

原因:模块之间存在隐式的循环 import。A 模块的 composable 引用了 B 模块的组件,B 模块的 store 又引用了 A 模块的 api,Vite 在预构建依赖时没有办法正确追踪这组关系,导致热更新变成了全量 reload。这类问题在项目初期不算显性,靠热更新热更新吃下去了,但会在“改一个文件 → 热更新失效 → 手动刷新”的循环里浪费大量时间。

解决:第一步,检查 modules 目录下是否存在跨模块的 import,模板约定模块间不得直接引用,全部经由@/共享层转发。第二步,排查 shared 目录里是否出现了带业务语义的组件,如果有,将其转移到对应模块。第三步,执行npm run dev -- --force强制重新预构建依赖,这个命令能解决大部分热更新失效的悬案。

5.3 现象:ESLint autofix 把 import 顺序改得面目全非

场景:在某模块的 api 文件里新增了一个接口,保存时 ESLint 自动修复,git diff 显示几十行 import 顺序变动。代码评审时被问到“你这个改动为什么要动这些行”。

原因:模板启用了import/order规则,但pathGroups的配置没有生效,@/和@m/开头的路径没有被归类为 internal,被当作 parent 或 sibling 路径处理。autofix 运行时按错误分组重排,导致一次改动产生大面积噪音。

解决:检查 eslint.config.js 里pathGroups的pattern是否使用了完整的 glob 写法。@/**这种写法能匹配@/components,但匹配不到@m/order/api,因为@m是独立前缀。改成如下配置:

pathGroups: [ { pattern: '@/**', group: 'internal', position: 'before' }, { pattern: '@m/**', group: 'internal', position: 'before' } ]

如果你已经把@m也映射到了@/modules,那只要保留一个@/**再额外加一行@m/**,两条规则不要合并。

5.4 现象:刷新页面后 Store 里的数据丢失

场景:登录之后跳转到首页,页面数据正常展示。按 F5 刷新,Store 里已获取的列表数据全部清空,页面回到 loading 状态。

原因:Pinia 的默认状态全部保存在内存里,刷新必然丢失。对列表页面来说,重新请求一次还能接受;但如果是用户信息、权限、购物车这类状态,刷新后全丢会让用户觉得系统不可靠。

解决:模板里对需要持久化的 store 使用pinia-plugin-persistedstate插件,在 store 定义时声明persist选项。要注意模块 store 的持久化 key 必须带模块前缀,比如order-list,否则多个模块的 store 写入同一个 key,后写入的会覆盖先写入的。

export const useOrderStore = defineStore('order', () => { // ... state 与 actions }, { persist: { key: 'order-list', pick: ['orderList'] } })

pick数组只持久化指定的 state 字段,像loading这类瞬时状态不应该被持久化,否则刷新后会出现“loading 残留 true”的界面卡死问题。这是一个很容易被忽略的细节。

5.5 现象:某示例组件怎么都 import 不进来,模块查询时提示“does not provide an export named”

场景:新建了一个组件,文件名是orderFilterPanel.vue,在页面里用import OrderFilterPanel from '@m/order/components/orderFilterPanel.vue',控制台报错说该组件没有默认导出。

原因:Vue 单文件组件在<script setup>模式下,默认导出会由编译器自动处理,eslint 的 vue 规则会在某些配置下禁止显式写export default。如果文件里的<script>没有加setup属性,写了普通的选项式 API,但 import 时用了默认导入,导出名称对不上就会报错。模板里统一约定:组件一律使用<script setup>语法,且文件名使用 PascalCase,与组件名保持一致。如果你按 kebab-case 命名文件,import 时用 PascalCase 也不会报错,但团队内部应该统一一种风格。模板选的是 PascalCase——因为 IDE 的自动导入对 PascalCase 文件名支持得更好,且与组件库(如 Element Plus)的引入方式保持一致。

6. 从模板到自己的项目:改造一个 useRemoteData 组合函数

模板自带的 composables 提供的只是基础模式,真正用起来的时候,你会发现很多场景是重复的:页面加载时请求数据、加载中显示 loading、失败时显示错误信息、轮询或刷新时复用同一段逻辑。基于模板的分层思想,我通常会在此基础上再封装一层通用的远程数据组合函数,把“请求状态管理”这件事收敛到一个函数里。

// src/composables/useRemoteData.ts import { ref, watch } from 'vue' import type { Ref } from 'vue' interface RemoteDataOptions<T> { immediate?: boolean defaultData?: T } export function useRemoteData<T>( fetcher: () => Promise<T>, options: RemoteDataOptions<T> = {} ) { const data = ref<T | undefined>(options.defaultData) as Ref<T | undefined> const loading = ref(false) const error = ref<Error | null>(null) async function run() { loading.value = true error.value = null try { data.value = await fetcher() } catch (e) { error.value = e as Error } finally { loading.value = false } } if (options.immediate ?? true) { run() } return { data, loading, error, run } }

这个函数的要点在于泛型约束和immediate参数。泛型T让每个业务模块的 fetcher 都能获得类型推导——订单列表的 fetcher 返回OrderItem[],data 就是Ref<OrderItem[] | undefined>;错误状态收敛在函数内部,不再散落在每个页面。

模板里自带的模块 composable 如果都基于这个useRemoteData重写,每个页面组件的逻辑会被压薄到只剩“定义 fetcher + 消费状态”。惯性思维里表单页和列表页是两套代码,但用了这个组合函数之后,列表页的分页、筛选、刷新都可以围绕run函数做扩展,表单页的提交、loading、错误提示也能复用同一套状态模型。

实际项目中我吃到过“没有统一远程状态封装”的亏。某次接手一个遗留模块,里面每个页面都自定义了一套 loading 布尔值和 catch 逻辑,一个接口报错时有的页面弹提示、有的页面静默失败,排错时需要在组件和拦截器之间来回跳。从那以后,凡是新项目我都强制要求所有请求必须先经过一层组合函数封装,状态管理不许写在组件里,这个习惯就是从类似这个模板的项目里养出来的。希望这份模板和其中的组织方式帮到你,省下一些我当年走过的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询