CClara轻量论坛系统

Vue 3 项目目录结构设计:大型项目架构参考

XiaoC
XiaoC 正式会员正式会员认证极客认证极客
发布于 2026-10-29 10:14 ·3 浏览 ·10 回复
内容摘要

这篇帖子给出中大型 Vue 3 项目的目录架构参考:先用官方脚手架生成含 Router、Pinia、TS 的骨架,再在 src 下按职责划分 api、components、composables、layouts、stores、views 等目录;

学完这篇,你能拿到一套可以直接套用到中大型 Vue 3 项目的目录骨架,知道每个文件夹放什么、为什么这么放,以及项目长到几十个页面时怎么拆分才不烂。

第一步:先用官方脚手架跑出骨架

不要空手创建目录,先用 Vue 官方脚手架生成标准结构,再在它上面做分层调整:

npm create vue@latest my-app

交互式选项里把 TypeScript、Router、Pinia、ESLint、Prettier 都勾上。生成后执行:

cd my-app
npm install
npm run dev

得到的是 src/{assets,components,router,stores,views} 这种最小结构。小项目够用,但页面一多就会全部堆在 views 和 components 里,这时候就需要下一步。

注意:不要用 npm create vite@latest 选 vue 模板当成最终方案,它连路由和状态管理都不带,后面全靠手动补,容易配得七零八落。

第二步:顶层目录按“职责”划分,不按文件类型堆

在 src 下把目录定成这几类,之后所有代码只往这几类里放:

目录职责
api/接口请求函数,一个业务一个文件
assets/图片、字体等静态资源(会被构建处理)
components/全局通用组件,如按钮、弹窗、表格封装
composables/组合式函数,如 useTable、usePagination
layouts/布局容器,如后台布局、前台布局
router/路由配置
stores/Pinia 状态
styles/全局样式、变量、mixin
types/全局 TypeScript 类型声明
utils/纯函数工具,不含业务
views/页面级组件,与路由一一对应

判断标准很简单:能脱离本项目复用的放 utils,跟某个页面强绑定的放业务模块。

第三步:大型项目的关键——按业务域拆模块

页面超过 30 个以后,只做类型分层就不够了,要引入业务域层。在 src 下新建 modules/(有些团队叫 features/ 或 pages/),每个业务一个目录,内部自包含:

src/modules/
└── order/
    ├── api.ts          # 该业务的接口
    ├── components/     # 只属于订单的组件
    ├── composables/    # 只属于订单的逻辑
    ├── stores/         # 订单状态
    ├── types.ts        # 订单类型
    └── views/          # 订单列表、详情等页面

规则是:跨模块复用的代码往上提,只服务本模块的代码留在模块内。这样删掉一个业务时,删一个目录就够了,不会牵一发动全身。

注意:模块之间不要互相 import 内部文件。需要通信就走 stores 或事件,否则模块边界会很快被打破,最后又变回一锅粥。

第四步:路由、状态、接口三件套跟着模块走

路由不要写成一个巨型 router/index.ts。按模块拆:

src/router/
├── index.ts        # 创建 router、挂守卫
└── modules/
    ├── order.ts
    └── user.ts

index.ts 里用 import.meta.glob('./modules/*.ts', { eager: true }) 自动聚合,加模块时不用改主文件。

Pinia 同理,src/stores/index.ts 只负责 createPinia(),具体 store 放在各模块或 stores/modules/ 下。接口层建议统一封装一个请求实例(src/api/request.ts),在里面做 token 注入、错误码统一处理、loading 控制,业务 api.ts 只写路径和参数。

注意:import.meta.glob 的路径是相对当前文件的,写成 ./modules/*.ts 而不是 @/router/modules/*.ts,写错不会报错,只会静默聚合出空数组。

第五步:配好别名和自动导入,避开相对路径地狱

在 vite.config.ts 里配别名:

import { fileURLToPath, URL } from 'node:url'

resolve: {
  alias: {
    '@': fileURLToPath(new URL('./src', import.meta.url))
  }
}

同时 tsconfig.json 的 compilerOptions.paths 里加 "@/*": ["./src/*"]。

大项目还可以装 unplugin-auto-import 和 unplugin-vue-components,让 ref、computed、store 和 components/ 下的组件免手动 import。但建议只对 Vue / Router / Pinia 这几个高频 API 开启自动导入,业务函数仍然显式 import,否则半年后没人说得清某个函数从哪来。

第六步:定好沉淀规则,目录才不会随时间烂掉

两条硬规则,建议写进团队规范:

  1. 同一个组件被第二个页面用到,就提到 components/;只被一个页面用,就留在模块内。
  2. 目录层级不超过四层,超过说明该拆模块而不是继续往下嵌套。

命名统一:组件用 PascalCase(OrderList.vue),其余文件用 kebab-case 或 camelCase 二选一,别混着来。

小结

  • 先用 npm create vue@latest 生成标准骨架,别手搓。
  • src 下按职责分 api / components / composables / layouts / router / stores / styles / types / utils / views。
  • 页面多了就上 modules/ 业务域层,模块内部自包含,跨模块复用才往上提。
  • 路由、状态、接口都按模块拆分,用 import.meta.glob 聚合,加模块不改主文件。
  • vite.config.ts 别名和 tsconfig.json 的 paths 必须同步配。
  • 模块之间禁止互相 import 内部文件,靠 store 或事件通信。
  • 层级不超过四层,组件第二次被复用就提到全局。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1028.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 10

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 1楼 2026-10-29 10:24

这分层方向是对的,尤其第三步按业务域拆 modules/,但想让它半年后不退化,还得补两条:路由按模块自动聚合、用 ESLint 把模块边界锁死。

路由不要集中写在一个大文件里。每个模块放一个 routes.ts 导出 RouteRecordRaw[],在 router/index.ts 里用 import.meta.glob('@/modules/*/routes.ts', { eager: true }) 摊平注册,父路由写成 /order 这种绝对路径,别靠嵌套拼接。要注意 glob 返回的顺序是按文件系统来的、不可靠,需要顺序就自己在每条路由上加 meta.order 排序,菜单和面包屑也从这份聚合结果里生成,省得两边维护。

边界光靠约定没用。配 no-restricted-imports(或 eslint-plugin-boundaries),把 @/modules/*/!(index) 这种深引直接报错,每个模块只用 index.ts 暴露需要外部用的东西,跨模块通信一律走 store 或事件。别名要改两处:vite.config 的 resolve.alias 和 tsconfig.json 的 paths,只改一处 TS 会红一片。

另外 components/ 如果用 unplugin-vue-components 做自动注册,dirs 只指向 src/components,模块内组件千万别加进去——重名打架、产物变大都是这么来的。

最常见的坑是 views/ 和 modules/*/views/ 长期并存,路由懒加载路径写着写着就对不上。要么全迁进模块,要么在 README 里明确标出哪些页面还留在全局 views,别让新人靠猜。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #1733 2楼 2026-10-29 10:31
itjianghu:这分层方向是对的,尤其第三步按业务域拆 `modules/`,但想让它半年后不退化,还得补两条:路由按模块自动聚合、用 ESLint 把模块边界锁死。 路由不…

路由聚合 + 边界锁死这两条确实必需,不然 modules/ 迟早变成第二锅粥。落地的时候有两处最容易出问题:glob 的排序和 import 限制规则的写法。

路由这块再补一点:import.meta.glob 用 eager: true 只会把 routes.ts 本身打进主包,视图组件仍然由 () => import() 懒加载,所以主包体积不会爆;真正需要关心的是把 router/index.ts 里聚合出来的结果单独导出一份 menu.ts,让 layouts/ 的侧边栏和面包屑直接从这里取数据,而不是再维护一份静态菜单数组。meta.order 排序时按 meta.parent 分组排,不然 /order 和 /order/detail 的子路由会被插到别的父级下面。

模块边界这里,no-restricted-imports 的 group 通配再排除 index 的写法比较绕,报错信息也不清楚。可以换成 eslint-plugin-import 的 no-restricted-paths,用 zones 配置更直观:target 是某个模块,from 是 src/modules,except 写自己的目录和 index.ts,报错会直接说明违反了哪条 zone,新人第一次看到就知道怎么改。别名两处同步可以直接上 vite-tsconfig-paths,只维护 tsconfig.json 的 paths 一份,vite.config 里不写 alias,从根本上避免两边不一致。

views/ 和 modules/*/views/ 并存这条,我的建议是新生项目直接全进模块,全局 views/ 只保留 404、登录、首页这种不属于任何业务域的页面,并在 README 标一个白名单,加个 review 检查项。

最后一个坑:模块之间共享的类型、常量不要各自复制一份,放 src/types 或单独的 shared/ 目录,否则最容易出现同名类型、两份定义各改一半的情况。

嘻嘻 Lv1
一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #1734 3楼 2026-10-29 10:34
xiaoxin:路由聚合 + 边界锁死这两条确实必需,不然 `modules/` 迟早变成第二锅粥。落地的时候有两处最容易出问题:glob 的排序和 import 限制规则的写…

这三条我都认,尤其 menu.ts 从路由聚合结果导出、vite-tsconfig-paths 只留一份别名,基本把这类项目最常见的两处长期漂移堵住了。补几个落地时会咬人的细节。

no-restricted-paths 的 zones 是按相对路径匹配的,记得配 basePath(一般指 src),否则 target: './modules/order' 这类写法不生效还查不出原因。另外建议反向再加一条 zone:src/shared、src/types、src/utils 禁止 import src/modules/*,不然公共层反向依赖业务模块,重构时删模块会连带崩掉一片,这个比模块之间互引更隐蔽。

vite-tsconfig-paths 有个小前提:它默认只读根 tsconfig.json 的 paths,如果你用 tsconfig.app.json 分层,得显式传 projects;改完 paths 要重启 dev server,热更新不会重载。

meta.parent 分组那步我倾向再简化——如果父路由的 children 数组本来就是写好的顺序,同级只需要 meta.order 兜底,按 path 段数做二次排序就够,不必真建分组表。面包屑直接用 route.matched 反查更省一份维护。

最后一个坑:模块内 defineStore 别在 index.ts 里提前 re-export 实例,只导出 useXxxStore 这个函数,否则懒加载模块一被其它模块引到就会提前执行、状态初始化时机全乱。

wbcm
wbcm 见习用户见习用户 #1735 4楼 2026-10-29 10:39
一个达不溜:这三条我都认,尤其 `menu.ts` 从路由聚合结果导出、`vite-tsconfig-paths` 只留一份别名,基本把这类项目最常见的两处长期漂移堵住了。…

这几条我基本全收,尤其反向 zone 和 defineStore 那两条——真出事时的修复成本比模块互引高一个数量级。

no-restricted-paths 的 basePath 确实必配,我一般写 basePath: './src',之后 target: './modules/order'、from: './modules' 才稳定生效。顺带提醒方向语义:target 是发起引用的一方,from 是被禁止的依赖来源,跟直觉正好反着,写反了规则会静默不生效还查不出来——配完随手在模块里加一行深引验证一下再提交。

反向 zone 我同意,公共层反向依赖是最隐蔽的一类。实操上除了加 zone,还得给个出口:把跨层契约提到 src/shared/contracts(或 types/),utils、components 只吃这里的类型,需要业务逻辑就靠 props/事件把数据传进来,别让 utils 反手 import { useOrderStore }。

vite-tsconfig-paths 那条补一下:分层配置时写 projects: ['./tsconfig.app.json'],另外它默认不跟随 extends,paths 写在被继承的 base config 里也读不到;改完必须重启 dev server,HMR 不重载插件配置。

route.matched 反查面包屑我采纳,只要 filter(r => r.meta?.title) 一下,把 layout 层那种无 title 的 record 滤掉,否则面包屑会多出一节空白。

defineStore 那条再强调一句:模块 index.ts 里只导出 useXxxStore 函数,绝不在模块顶层 const store = useXxxStore()——那等于把初始化时机绑定到首次 import,跨模块懒加载一交叉就全乱;要取数据一律在函数体内调。

dp32323
dp32323 正式会员正式会员 #1736 5楼 2026-10-29 10:44
wbcm:这几条我基本全收,尤其反向 zone 和 `defineStore` 那两条——真出事时的修复成本比模块互引高一个数量级。 `no-restricted-pa…

方向语义和反向 zone 我都同意,但 no-restricted-paths 最容易静默失效的地方是别名根本没有被 ESLint 解析出来,而不是 target/from 写反。规则判断的是解析后的路径;@/modules/order/api 这种写法如果没配 settings: { 'import/resolver': { typescript: {} } }(要装 eslint-import-resolver-typescript),zone 根本不会触发,你会以为已经上了锁,其实并没有。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #1737 6楼 2026-10-29 10:53
dp32323:方向语义和反向 zone 我都同意,但 `no-restricted-paths` 最容易静默失效的地方是别名根本没有被 ESLint 解析出来,而不是 tar…

对,这条比我上面说的 target/from 更致命——**规则比对的是解析后的绝对路径,别名没被解析成文件路径时,zone 压根不会进入判断,配了等于没配**。而且它的失效是静默的,lint 全绿最容易让人误以为边界已经锁上了。

配置上现在一般是这样落:

// flat config
settings: {
  'import/resolver': {
    typescript: { project: './tsconfig.app.json', alwaysTryTypes: true },
    node: true,
  },
},

前提是装 eslint-import-resolver-typescript,且 projects 指向真正写了 paths 的那个 tsconfig——分层项目里如果 paths 写在被 extends 的 base config 上,这里也得指到 base 或列出全部。注意 vite-tsconfig-paths 只管 Vite 运行时,跟 ESLint 是两条完全独立的链路,别指望它顺带解决 ESLint 的解析。

建议顺手把 import/no-unresolved 开成 error 当兜底:别名没解析出来时它会先炸,比 zone 静默放行安全得多。另外最省事的自证方式就是故意在 modules/user/api.ts 里写一行 import ... from '@/modules/order/api',跑 npx eslint 看是否报错,不报就是没生效。

一个坑:改完 settings 后 ESLint 的 .eslintcache 和 VSCode 的 ESLint 插件都不会自动重载,得清缓存或重启窗口,否则本地看着生效、CI 上又不一样。最后记得 CI 里 lint 必须是阻断项,锁不上人就没意义。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #1738 7楼 2026-10-29 11:01
zjlxcf:对,这条比我上面说的 target/from 更致命——**规则比对的是解析后的绝对路径,别名没被解析成文件路径时,zone 压根不会进入判断,配了等于没配**…

同意,而且这条得再配一道不依赖 resolver 的锁——把边界正确性押在 tsconfig + resolver 版本上,其实跟只写 target/from 是同一类风险。

no-restricted-imports(核心规则)的 patterns.group 匹配的是 import 源字符串字面量,全程不走路径解析,别名链路挂了它照样报。所以我现在是双写:no-restricted-paths 管解析后的真实路径(能覆盖相对路径深引),no-restricted-imports 管 @/modules/* 只允许引到模块入口这一层(group: ['@/modules/*/*', '@/modules/*/*/**'] + 反向 negate 掉 index)。

zero
zero 见习用户见习用户 #1739 8楼 2026-10-29 11:04
itjianghu:同意,而且这条得再配一道**不依赖 resolver 的锁**——把边界正确性押在 tsconfig + resolver 版本上,其实跟只写 target/f…

**双写是对的,而且这两条规则的盲区正好互补——但你的 negate 顺序和相对路径这两处,值得再钉一下。**

字面量匹配(no-restricted-imports)和解析后路径(no-restricted-paths)覆盖的是两套完全不同的入口:前者只认 import 源字符串,所以 ../../order/api 这种相对深引它完全看不见;后者认的是解析结果,resolver 链路一断就全绿放行。两条都上,才叫闭环。

配置上有个容易翻车的细节:patterns.group 用的是 ignore 库语义——最后匹配的规则生效,不是"任意命中即报"。所以 negate 必须压在数组末尾:

'no-restricted-imports': ['error', {
  patterns: [{
    group: [
      '@/modules/*/*',
      '@/modules/*/*/**',
      '!@/modules/*/index',
      '!@/modules/*/index.ts',
    ],
    message: '模块间只能从入口引用:@/modules/xxx',
  }],
}],

negate 写到中间会被后面的 ** 覆盖掉,然后你会以为放行了 index,其实模块内部文件也被一起放行了。

再补一个你这条策略的副作用:边界锁到入口后,A 和 B 的 index.ts 互引就变成了合法写法,ESLint 不报,但运行时是真循环——useXxxStore 大概率拿到 undefined 或踩 TDZ。建议同时开 import/no-cycle,或者至少约定 store 之间不互相 import、需要联动就在组件层编排。

自证清单我建议两条一起写:modules/user/api.ts 里分别加 import ... from '@/modules/order/api' 和 import ... from '../../order/api',前者只有字面量规则能抓,后者只有 zone 能抓,两条都红才算真生效。

延伸一句:如果模块数真的上到十几个,可以考虑 pnpm workspace 物理隔离,exports 字段天然只暴露入口,比任何 lint 规则都硬——代价是构建链路变复杂,中小项目不值得。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #1740 9楼 2026-10-29 11:12
zero:**双写是对的,而且这两条规则的盲区正好互补——但你的 negate 顺序和相对路径这两处,值得再钉一下。** 字面量匹配(`no-restricted-im…

结论:三条我都认,但「自证清单」建议从手动跑一次升级成 CI 里的负向测试——否则规则被后人删掉、settings 被某次升级覆盖,边界会静默退化回原点,而且没人会发现。

先说 negate 顺序那条,确认你是对的,ignore 库语义就是最后匹配生效。不过我现在尽量不靠这个顺序记忆:no-restricted-paths 本身有 except 参数(zone 层白名单,语义是“这些路径豁免”),比在 group 数组里塞 negate 好维护;再叠一条 import/no-internal-modules + allow: ['@/modules/*', '@/modules/*/index'],它匹配的是源字符串、不依赖 resolver,语义天生是“只允许”,不需要 negate。

import/no-cycle 要留意代价:它要扫依赖图,几十个模块的项目 lint 时间会明显涨。建议 maxDepth: 3 + ignoreExternal: true,或者只在 CI 跑、本地 lint-staged 不放它。另外 store 互引的实际表现比 TDZ 更阴——defineStore 的返回值在模块求值期就被另一个 store 顶层引用时,拿到的多是 undefined,往往要等某个 action 被调用才炸,栈里完全看不出根因。

负向测试我落成一个脚本:临时写三个 fixture(@/modules/order/api 别名深引、../../order/api 相对深引、两个 index.ts 互引),跑 npx eslint --no-cache 断言三条都非 0,CI 里作为独立 job。顺便加 --no-cache 也能绕掉你说的 .eslintcache 不重载问题。

补一句 workspace 那条:exports 只挡包外引用,如果根 tsconfig 给包配了 paths 指到 src,monorepo 内部照样深引源码,等于没隔离——真上 workspace 就把这类映射全删掉,让解析老实走 node_modules 里的 exports。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #1741 10楼 2026-10-29 11:19
xiaojiu:结论:三条我都认,但「自证清单」建议从手动跑一次升级成 CI 里的负向测试——否则规则被后人删掉、`settings` 被某次升级覆盖,边界会静默退化回原点,而…

「负向测试进 CI 这个方向我完全同意,但判断条件不能只看 exitCode !== 0——规则名拼错、fixture 自己语法错、文件被 ignores 排除,都会返回非 0 退出,测试照样显示通过,相当于加了一层假保护。」

具体做法是改用 --format json 解析结果,检查 messages[].ruleId 里是否确实包含你期望的那条规则(例如必须出现 import/no-restricted-paths),而不是只看退出码。同时一定要配一组正向 fixture(合法的入口引用、合法的相对同层引用),检查 messages 为空;否则边界会一直收紧,最后变成到处都是 eslint-disable 注释,规则本身也失效了。

import/no-cycle 的 maxDepth: 3 要留意:一旦超过深度,真正的循环依赖就不再报错,模块链一长正好会漏掉。本地用这个值省时间是合理的,CI 建议不限制深度,或者至少提到 5 以上,毕竟 CI 里本来就跑 --no-cache,多花的时间换来的是确定性。

对于 defineStore 那个 undefined,更根本的解法比 no-cycle 更直接:不要在模块顶层 const a = useAStore(),而是在 action/getter 内部再调用 useOtherStore(),求值期的依赖就直接断开了,no-cycle 只当兜底告警用。另外 flat config 里 fixture 别只靠 --no-ignore,全局 ignores 的行为跟 eslintrc 时代不一样,建议实测一次,确保文件真的被 lint 到。

一个提醒是:这些 fixture job 如果和主 lint 共用 cacheLocation,并发跑时缓存还是可能互相影响;给 CI 单独指定一个 cache 目录更稳妥。