目录结构到底怎么设计?别再把 components 和 utils 当垃圾桶了

目录结构到底怎么设计?别再把 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

每个文件都好像有理由存在,但你已经说不清:

  • 哪些是纯公共能力?
  • 哪些其实是业务模块的一部分?
  • 哪些只被一个页面用?
  • 哪些应该沉到底层包?
  • 哪些以后会被多个应用复用?
  • 哪些压根不该放在这里?

这时候,componentsutils 就不再是目录,而是垃圾桶。

这篇文章想聊一个很实际的问题:前端项目的目录结构,到底应该怎么设计?

一、目录结构不是为了"看起来整齐"

很多人设计目录时,第一反应是套模板:

txt 复制代码
src/
├── assets/
├── components/
├── hooks/
├── router/
├── store/
├── utils/
├── views/
└── api/

这套结构不能说错。小项目、后台管理系统、一次性活动页,用它完全可以。

问题在于,很多人以为有了这套目录,项目就有架构了。

不是。

目录结构的价值,不是让文件摆得好看,而是回答三个问题:

  1. 职责边界:这个文件属于哪一层?
  2. 变化原因:它会因为什么原因被修改?
  3. 依赖方向:它允许依赖谁?谁允许依赖它?

如果一个目录不能回答这三个问题,它迟早会变成"随便放"。

比如 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 层。

变化原因,是目录设计里最容易被忽略、但最重要的问题。

十一、最后

目录结构不是玄学,也不是模板比赛。

它本质上是在回答:

这个项目里的代码,按什么边界组织,未来变化时才不会互相拖累?

componentsutils 不是不能用。

但如果你把所有说不清归属的组件都丢进 components,把所有说不清性质的函数都丢进 utils,项目一定会越来越难维护。

真正好的目录结构,应该让人一眼看出:

  • 哪些是应用装配
  • 哪些是业务模块
  • 哪些是共享能力
  • 哪些是服务层
  • 哪些是基础 UI
  • 哪些是构建或工程配置

目录结构不是为了让今天写代码更爽,而是为了让半年后的你、同事、新人,甚至 AI,都能知道代码应该往哪里放、从哪里找、能依赖谁、不能依赖谁。

如果你已经写了几年业务,但一搭项目就乱,可以先别急着换框架。

先从整理目录边界开始。

下一篇我想继续聊:

前端请求层到底怎么封?为什么全局 axios 单例越用越难维护。

我会继续结合 LYStack 里的 services 设计,拆一下请求层为什么应该做依赖反转,而不是把 token、router、UI 消息全写死在一起。

项目地址:

相关推荐
柯克七七17 小时前
我把 bug 修得太干净了,测试组以为这个模块本来就没问题
前端·javascript·typescript
Null15517 小时前
浏览器唤起桌面端应用(终极篇)
前端·javascript
Quz17 小时前
QML Label 完整用法与 Text 组件选型
前端·qt
妙码生花17 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(三十四):后台初始化请求实现
前端·javascript·go
天若有情67317 小时前
前端性能优化:从“按下预加载”到“AI级智能预判系统”
前端·性能优化·js·active·hover·首屏加载
Csvn18 小时前
📦 CSS Container Queries 实战踩坑:5 个让你怀疑人生的隐藏陷阱
前端
大家的林语冰18 小时前
👍 Rust 为 Node 插上翅膀,反超 Bun 和 pnpm,一体化工具我也有!
前端·javascript·node.js
坐而论道_18 小时前
兼容低版本 Android:让现代 Web 应用在旧 WebView 上稳定运行
前端·面试
ssshooter18 小时前
Promise 和 async/await 被 try-catch 包裹时的行为差异
前端·javascript·面试