目录结构到底怎么设计?别再把 components 和 utils 当垃圾桶了
很多前端项目变乱,都是从两个文件夹开始的:
txt
components/
utils/
刚开始它们看起来很合理。
公共组件放 components,工具函数放 utils。简单、直观、大家都懂。
但项目做了半年,你再打开它们,里面可能已经变成这样:
txt
components/
├── BaseButton.vue
├── UserCard.vue
├── OrderStatus.vue
├── UploadDialog.vue
├── SearchForm.vue
├── PermissionWrapper.vue
├── BusinessTable.vue
└── SomeOldModal.vue
utils/
├── format.ts
├── request.ts
├── auth.ts
├── tree.ts
├── permission.ts
├── download.ts
├── route.ts
├── date.ts
└── xxx-helper.ts
每个文件都好像有理由存在,但你已经说不清:
- 哪些是纯公共能力?
- 哪些其实是业务模块的一部分?
- 哪些只被一个页面用?
- 哪些应该沉到底层包?
- 哪些以后会被多个应用复用?
- 哪些压根不该放在这里?
这时候,components 和 utils 就不再是目录,而是垃圾桶。
这篇文章想聊一个很实际的问题:前端项目的目录结构,到底应该怎么设计?
一、目录结构不是为了"看起来整齐"
很多人设计目录时,第一反应是套模板:
txt
src/
├── assets/
├── components/
├── hooks/
├── router/
├── store/
├── utils/
├── views/
└── api/
这套结构不能说错。小项目、后台管理系统、一次性活动页,用它完全可以。
问题在于,很多人以为有了这套目录,项目就有架构了。
不是。
目录结构的价值,不是让文件摆得好看,而是回答三个问题:
- 职责边界:这个文件属于哪一层?
- 变化原因:它会因为什么原因被修改?
- 依赖方向:它允许依赖谁?谁允许依赖它?
如果一个目录不能回答这三个问题,它迟早会变成"随便放"。
比如 utils。
它听起来是"工具函数",但什么叫工具?
日期格式化是工具,树结构转换是工具,请求封装是工具,权限判断也是工具,路由跳转也有人放进去。
最后 utils 变成了一个没有边界的收纳箱。
目录一旦没有边界,代码就会慢慢失去边界。
二、按文件类型分目录,只适合项目早期
最常见的目录设计方式,是按文件类型分:
txt
components/
hooks/
utils/
api/
store/
views/
这种方式的优点是:上手快。
新手一看就知道,组件放 components,请求放 api,状态放 store,页面放 views。
但它的问题也很明显:它只能告诉你"这个文件是什么",不能告诉你"这个文件属于谁"。
举个例子。
你有一个订单模块,里面有:
- 订单列表页面
- 订单详情页面
- 订单状态组件
- 订单金额格式化逻辑
- 订单接口
- 订单相关类型
- 订单筛选表单
如果严格按文件类型分,它会散落在这里:
txt
views/order/List.vue
views/order/Detail.vue
components/OrderStatus.vue
components/OrderSearchForm.vue
utils/order.ts
api/order.ts
types/order.ts
store/order.ts
看起来每个文件都放在了"正确类型"的目录下。
但订单这个业务模块被拆散了。
你想改订单逻辑,需要在 6 个目录里来回找。新人想理解订单模块,也得自己拼图。
这就是按类型分目录的局限:
它让同类文件靠近,却让同一个业务的相关代码分散。
项目小的时候无所谓。项目大了,理解成本会越来越高。
三、真正该关注的是"这个文件为什么会变"
我现在更倾向于用一个问题判断文件该放哪里:
这个文件会因为什么原因被修改?
如果两个文件总是因为同一个业务变化一起改,它们就应该靠近。
如果一个文件和业务无关,只是纯技术能力,它就应该沉到基础层。
如果一个文件只服务某个页面,就不要急着放进公共目录。
比如一个 OrderStatus.vue。
它到底该放 components,还是放订单模块下面?
不要看它是不是组件,要看它的变化原因。
如果它只服务订单业务,状态文案、颜色、流转规则都和订单领域绑定,那它就不该放全局 components。它应该靠近订单模块。
txt
features/order/
├── pages/
├── components/
│ └── OrderStatus.vue
├── services/
├── types.ts
└── utils.ts
如果它是一个纯展示的 Tag 组件,和订单无关,任何业务都能用,那它才应该进入共享 UI 层。
txt
shared/ui/
└── StatusTag.vue
判断标准不是"它是不是组件",而是"它是不是业务专属"。
再看一个 formatAmount。
如果它只是把数字格式化成金额:
ts
formatAmount(1234.5) // ¥1,234.50
它可以放共享工具层。
但如果它包含订单业务规则,比如不同订单类型展示不同精度、不同币种、不同折扣口径,那它就不再是通用工具,而是订单领域逻辑。
很多项目混乱,就是因为把业务逻辑伪装成了 utils。
四、components 不是所有组件的家
components 这个名字最大的问题是太宽。
只要是 .vue 文件,好像都能往里放。
但组件至少可以分成三类:
1. 基础 UI 组件
比如 Button、Input、Modal、Table、StatusTag。
它们不关心具体业务,只关心展示和交互。
这类组件适合放在共享 UI 层:
txt
packages/ui/
└── src/
├── button/
├── modal/
└── status-tag/
或者在单应用里:
txt
src/shared/ui/
2. 业务组件
比如 OrderStatus、UserRoleSelector、InvoicePreview。
它们强绑定业务概念,离开这个业务就没意义。
这类组件应该放在对应业务模块里:
txt
src/features/order/components/OrderStatus.vue
src/features/user/components/UserRoleSelector.vue
3. 页面局部组件
比如某个页面里的 FilterPanel、EditDialog、DetailHeader。
它们只被一个页面使用。
这类组件不用急着公共化,直接放页面附近就好:
txt
src/features/order/pages/list/
├── index.vue
├── FilterPanel.vue
└── EditDialog.vue
很多人项目一乱,就是因为太早把东西放进公共目录。
公共目录不是荣誉殿堂,不是越多越好。
公共意味着承诺:
- 你要维护它的稳定性
- 你要考虑复用场景
- 你要避免业务污染
- 你要承担后续兼容成本
如果一个组件只有一个地方用,就让它待在原地。
等它真的被第二个、第三个场景复用,再考虑抽出去。
五、utils 也不是所有函数的家
utils 更容易失控。
因为函数不像组件那么显眼。一个函数今天只有 10 行,明天加个参数,后天塞个业务判断,很快就从工具函数变成业务规则。
我建议至少把函数分成四类。
1. 纯通用工具
这类函数不依赖业务,不依赖框架,不依赖运行环境。
比如:
- 日期格式化
- 数字格式化
- 空值判断
- 树结构转换
- 字符串处理
它们可以放在共享工具层。
txt
shared/utils/
或者 monorepo 里:
txt
packages/shared/src/utils/
2. 业务规则函数
这类函数看起来像工具,但其实和业务规则绑定。
比如:
- 判断订单是否可取消
- 判断用户是否可编辑某资源
- 根据业务状态计算展示文案
- 根据订单类型计算金额展示
它们应该放在对应业务模块里。
txt
features/order/domain.ts
features/order/rules.ts
features/order/utils.ts
如果你把它们放进 shared/utils,共享层就会被业务污染。
3. 平台能力封装
比如:
- localStorage 访问
- download 文件下载
- clipboard 剪贴板
- bridge 调用
- 路由跳转辅助
这些函数依赖浏览器、客户端或框架环境,不是纯工具。
它们应该有更明确的目录名,比如:
txt
shared/platform/
shared/browser/
shared/runtime/
不要用 utils 模糊掉它们的依赖。
4. 服务层辅助
比如 token 注入、请求错误处理、响应转换。
这些不该放 utils,而应该属于 services 层。
txt
services/
├── core/
├── interceptors/
└── api/
目录名越具体,边界越清楚。
utils 不是不能用,但它应该很小、很纯。
如果一个项目的 utils 越来越大,通常说明你没有认真划分职责。
六、一个更可维护的目录思路
如果是一个普通中后台单应用,我会更倾向于这样的结构:
txt
src/
├── app/ # 应用装配:路由、插件、全局初始化
├── shared/ # 与具体业务无关的共享能力
│ ├── ui/ # 基础 UI 组件
│ ├── utils/ # 纯工具函数
│ ├── platform/ # 浏览器/运行时能力
│ └── types/ # 通用类型
├── services/ # 请求层、接口实例、拦截器
├── features/ # 业务模块
│ ├── order/
│ │ ├── pages/
│ │ ├── components/
│ │ ├── services/
│ │ ├── types.ts
│ │ └── rules.ts
│ └── user/
│ ├── pages/
│ ├── components/
│ ├── services/
│ ├── types.ts
│ └── rules.ts
└── main.ts
这个结构不一定适合所有项目,但它比单纯的 components/utils/views/api 多回答了几个问题:
app负责应用启动和装配shared放业务无关的共享能力services放请求和服务通信features按业务模块组织- 模块内部允许有自己的组件、类型、规则和接口
它的核心不是目录名,而是边界:
业务相关的东西靠近业务,业务无关的东西沉到共享层,应用启动逻辑收口到 app 层。
七、如果是 Monorepo,目录结构要再往上一层看
单应用里,我们讨论的是 src 怎么组织。
但如果你的团队有多个应用,比如:
- PC 管理后台
- H5 活动端
- App 内嵌页
- 运营工具
- 数据看板
这时候问题就变了。
你不只是要组织一个应用内部的目录,还要组织多个应用之间的共享关系。
这也是我在 LYStack 里采用 Monorepo 的原因。
LYStack 的结构是:
txt
LYStack/
├── apps/
│ ├── example-vite/
│ ├── example-rsbuild/
│ └── example-rsbuild-mpa/
├── packages/
│ ├── build-config/
│ ├── shared/
│ ├── services/
│ └── ui/
└── pnpm-workspace.yaml
这里的重点不是"Monorepo 更高级",而是它把共享能力从应用里拿出来,放到了更稳定的位置。
apps/*是具体应用,会随业务变化packages/shared是跨应用共享的类型、常量、环境变量读取packages/services是跨应用复用的请求层能力packages/ui是可复用 UI 基础能力packages/build-config是构建配置抽象
这比在某个应用的 src/utils 里塞一堆公共逻辑要清楚得多。
因为一旦某个能力要被多个应用共享,它就不再属于某个 app。
它应该进入 packages,成为真正的基础设施。
八、目录结构背后,其实是依赖方向
很多目录争论,表面上是在争文件放哪里,实际上是在争依赖方向。
比如:services 能不能 import router?
很多项目会这么写:
ts
// services/request.ts
if (status === 401) {
router.push('/login');
}
短期很方便,长期会出问题。
因为 services 层开始依赖应用层路由。
更好的方式是:services 层只暴露注入点,应用启动时告诉它 401 怎么处理。
LYStack 里是这样做的:
ts
configureServiceAuth({
getToken: () => localStorage.getItem(STORAGE_TOKEN_KEY) ?? '',
onAuthenticationFailure: () => {
// 真实项目里这里跳转登录页
},
});
这样 services 不需要知道 token 从哪来,也不需要知道登录页怎么跳。
这就是目录结构和依赖方向的关系。
如果你的目录结构允许底层随便 import 上层,项目迟早会纠缠在一起。
一个相对健康的方向应该是:
txt
app/features -> services/shared/ui
services -> shared
ui -> shared
shared -> 不依赖业务层
越底层,越稳定,越不应该认识上层业务。
九、不要一上来就抽公共
很多人有个误区:为了显得项目"架构好",一开始就疯狂抽公共。
结果项目刚开始没多久,就出现一堆过度设计:
- 只用一次的组件被放进 shared
- 只有一个页面用的 hook 被做成公共 hook
- 还没稳定的业务规则被抽成 utils
- 为了未来可能复用,提前设计一堆参数
这不是架构,这是负担。
我更建议遵守一个简单原则:
第一次先放近一点,第二次发现重复,第三次再考虑抽象。
公共化应该是被需求推出来的,不是被想象推出来的。
一个文件从页面局部移动到业务模块,再从业务模块移动到 shared,应该是逐步发生的。
比如:
txt
pages/order/list/FilterPanel.vue
如果只有订单列表用,就待在页面旁边。
后来订单详情也要用,可以移动到:
txt
features/order/components/OrderFilterPanel.vue
再后来多个业务都要类似筛选面板,且业务规则已经剥离干净,才考虑:
txt
shared/ui/FilterPanel.vue
抽象不是越早越好。
抽象的前提是你已经看到了稳定的重复。
十、判断文件该放哪里的 6 个问题
以后你不知道一个文件该放哪里,可以先问 6 个问题。
1. 它是否绑定具体业务?
如果绑定,就优先放业务模块。
不要因为它是组件或函数,就直接放 components / utils。
2. 它是否只被一个页面使用?
如果只被一个页面使用,就放页面附近。
页面局部代码不丢人,乱抽公共才麻烦。
3. 它是否能被多个业务无差别复用?
如果能,而且不含业务语义,才考虑进入 shared。
4. 它是否依赖运行环境?
依赖浏览器、客户端、路由、UI 框架的代码,不要伪装成纯 utils。
给它一个更明确的目录。
5. 它是否属于应用启动装配?
插件注册、认证注入、错误消息注入、全局初始化,应该收口到 app/bootstrap,而不是散落在组件里。
6. 它未来变化的原因是什么?
因为订单规则变化而变,就靠近订单。
因为构建工具变化而变,就放构建层。
因为 UI 规范变化而变,就放 UI 层。
因为环境读取机制变化而变,就放 env 层。
变化原因,是目录设计里最容易被忽略、但最重要的问题。
十一、最后
目录结构不是玄学,也不是模板比赛。
它本质上是在回答:
这个项目里的代码,按什么边界组织,未来变化时才不会互相拖累?
components 和 utils 不是不能用。
但如果你把所有说不清归属的组件都丢进 components,把所有说不清性质的函数都丢进 utils,项目一定会越来越难维护。
真正好的目录结构,应该让人一眼看出:
- 哪些是应用装配
- 哪些是业务模块
- 哪些是共享能力
- 哪些是服务层
- 哪些是基础 UI
- 哪些是构建或工程配置
目录结构不是为了让今天写代码更爽,而是为了让半年后的你、同事、新人,甚至 AI,都能知道代码应该往哪里放、从哪里找、能依赖谁、不能依赖谁。
如果你已经写了几年业务,但一搭项目就乱,可以先别急着换框架。
先从整理目录边界开始。
下一篇我想继续聊:
前端请求层到底怎么封?为什么全局 axios 单例越用越难维护。
我会继续结合 LYStack 里的 services 设计,拆一下请求层为什么应该做依赖反转,而不是把 token、router、UI 消息全写死在一起。
项目地址:
- GitHub: github.com/liangy0323/...