本文是「前端工程现场」系列的第 6 篇。
下面使用一个假设的 Vue 3 后台项目说明问题。目录、文件名和代码均为简化示例,不对应具体线上项目。
设想一个后台系统要给订单列表增加退款状态筛选。
需求本身不复杂,首先给页面多一个下拉框,然后请求参数增加 refundStatus,接口结果里能够展示退款状态,查询条件也可以保存到页面状态中,那任务就算完成了。
举个例子,开发者打开项目后,实际要修改的文件分散在这些位置:
text
src/
├─ api/order.ts
├─ views/order/OrderList.vue
├─ components/order/OrderFilter.vue
├─ hooks/useOrderTable.ts
├─ stores/order.ts
└─ types/order.ts
六个文件不算多,麻烦在于它们分布在六套顶层目录中。开发者先从页面跳到 api,再去 hooks 找请求参数,接着到 types 补类型,最后还要确认 stores 中有没有保存筛选条件。
改动完成后,git diff --stat 看起来像这样:
text
src/api/order.ts | 4 +++-
src/components/order/OrderFilter.vue | 12 +++++++++
src/hooks/useOrderTable.ts | 6 ++++-
src/stores/order.ts | 3 +++
src/types/order.ts | 5 +++++
src/views/order/OrderList.vue | 8 +++++-
6 files changed
这些文件的确是分属于不同技术类型:接口、组件、组合函数、状态和类型,可它们也共同服务于同一个订单功能。
当项目规模较小时,这点距离几乎没有影响,但是随着业务模块增加、维护人员变多,定位成本会慢慢超过创建目录时省下的那点时间,那我们该怎样优化我们的目录呢?
小项目按技术类型分类
当我们开始初始化项目的时候,一般都会采用这样的目录结构:
text
src/
├─ api/
├─ assets/
├─ components/
├─ hooks/
├─ router/
├─ stores/
├─ types/
├─ utils/
└─ views/
它的目录很直观:
| 文件内容 | 放置目录 |
|---|---|
| 请求函数 | api |
| 页面组件 | views |
| 可复用组件 | components |
| 组合函数 | hooks |
| 状态管理 | stores |
| 类型定义 | types |
新项目只有几个页面时,这种结构很清楚。如果想修改路由就进入 router 目录,想找接口就进入 api 目录,公共组件集中放在 components 目录下面。
但是随着订单、用户、商品、权限和报表逐渐增加,每个顶层目录内部又会出现业务分类:
text
src/
├─ api/
│ ├─ order.ts
│ ├─ user.ts
│ └─ product.ts
├─ hooks/
│ ├─ useOrderTable.ts
│ ├─ useUserTable.ts
│ └─ useProductTable.ts
├─ stores/
│ ├─ order.ts
│ ├─ user.ts
│ └─ product.ts
└─ types/
├─ order.ts
├─ user.ts
└─ product.ts
这时目录已经同时包含两种分类方式:
text
第一层:按技术类型分类
第二层:按业务模块分类
两种目录都包含页面、接口、状态和类型,差别主要在第一层按什么关系聚合。下面把一次订单需求需要经过的路径放在一起比较。

第一种目录看起来目录结构仍然整齐,但一次订单需求的修改需要横跨 api/order.ts、hooks/useOrderTable.ts、stores/order.ts 和 types/order.ts。这带来一个后果,当页面的组件,接口和功能变得越来越多的时候,开发链路会变得极其复杂,理解一个功能需要打开更多位置,重命名或删除业务模块时也更容易漏掉文件,那有什么方法可以解决这个问题吗,接着往下看。
判断目录是否开始拖慢开发,不要只看文件数量
一个目录里有两百个文件,看起来很乱,但是如果开发者能通过搜索快速定位,并且文件之间边界稳定,它未必是当前最值得处理的问题。
更实用的判断方式,是观察一次需求会跨越多少位置。
以前面的退款状态筛选为例,可以先从提交记录中查看改动范围:
bash
git show --stat --oneline HEAD
也可以搜索订单模块的引用:
bash
grep -R "refundStatus" src
grep -R "useOrderTable" src
假设一个业务字段增加后,通常都要修改下面几处:
text
页面表单
请求参数
接口类型
状态保存
列表展示
这些代码变化原因接近。它们都在描述订单筛选,却被目录结构拆到项目不同位置。
可以用三个问题判断目录是否需要调整:
| 检查问题 | 出现什么情况时值得关注 |
|---|---|
| 一个需求通常要跨多少顶层目录 | 同一业务改动长期横跨多个目录 |
| 删除一个业务模块是否容易漏文件 | 页面删除后,接口、类型或 Store 仍留在其他目录 |
| 新成员能否从页面找到完整链路 | 需要依赖全局搜索和口头说明才能定位 |
项目只有五六个页面时,全局搜索通常足够。几十个业务模块并行维护后,每次都靠搜索拼出功能边界,开发者需要重复完成同一种定位工作。
目录调整替项目省下的,也主要是这部分时间:减少跨目录寻找、降低删除遗漏、让代码评审更容易看出一次改动属于哪个业务。
把订单相关代码放回同一个业务模块
一种调整方式是按业务模块组织代码:
text
src/
├─ modules/
│ ├─ order/
│ │ ├─ api/
│ │ │ └─ orderApi.ts
│ │ ├─ components/
│ │ │ └─ OrderFilter.vue
│ │ ├─ composables/
│ │ │ └─ useOrderTable.ts
│ │ ├─ store/
│ │ │ └─ orderStore.ts
│ │ ├─ types/
│ │ │ └─ order.ts
│ │ └─ pages/
│ │ └─ OrderList.vue
│ ├─ user/
│ └─ product/
├─ shared/
└─ router/
订单筛选的相关代码现在集中在 modules/order 中,当我们要修改跟订单页面相关的代码的时候,改动只会发生在order目录的下面,当开发人员修改退款状态筛选时,主要在一个模块目录中工作,删除订单模块时,也更容易确认相关页面、请求、类型和状态是否一起移除。
这里容易产生一个误解:按业务模块组织,不代表每个模块都必须拥有 api、components、store、types 和 pages 这几个目录,过度设计也不可取。
一个简单模块可能只有:
text
modules/profile/
├─ ProfilePage.vue
└─ profileApi.ts
为了保持形式统一而创建一排空目录,只会增加浏览成本,目录应当在文件数量和职责确实需要分组时再出现。
哪些代码应该进入 shared
业务代码集中以后,项目还会遇到另一个问题:多个模块都在使用的组件和工具放在哪里?
例如订单页和用户页都使用分页表格,三个模块都使用日期格式化,登录信息也会被多个模块读取,所以这些代码通常会进入共享区域:
text
src/shared/
├─ components/
│ ├─ BaseTable.vue
│ └─ EmptyState.vue
├─ composables/
│ └─ usePagination.ts
├─ utils/
│ └─ formatDate.ts
└─ types/
└─ pagination.ts
判断一段代码是否应该进入 shared,可以先看它是否同时满足两个条件:
- 已经被多个业务模块使用;
- 它的内容不需要理解某个具体业务。
例如:
ts
export function formatDate(value: string) {
return new Intl.DateTimeFormat(
'zh-CN'
).format(new Date(value))
}
日期格式化不需要理解订单或用户,放入共享工具比较自然。
下面这段代码虽然被两个订单页面使用,却仍然属于订单业务:
ts
export function canRefundOrder(
status: string,
paidAmount: number
) {
return status === 'paid' && paidAmount > 0
}
它依赖订单状态和退款规则,仅仅因为调用了两次,就移进全局 utils,会让共享目录开始理解业务,详细内容请移步前端工程化的上一篇,这里不再赘述。
可以用下面的表格区分:
| 代码 | 更合适的位置 | 判断依据 |
|---|---|---|
| 通用日期格式化 | shared/utils |
不依赖具体业务 |
| 基础分页组件 | shared/components |
多个模块使用,交互规则一致 |
| 订单是否允许退款 | modules/order |
依赖订单规则 |
| 用户角色名称转换 | modules/user |
只解释用户领域数据 |
| 通用请求客户端 | shared/api 或基础设施目录 |
服务多个业务模块 |
共享目录最容易变成新的杂物间。一个文件暂时不知道放哪里时,直接放进 shared 很方便,几个月后它可能同时包含组件、业务规则、接口常量和页面配置,这就背离我们设置 shared 文件夹的初衷了。
模块之间不要随意穿过内部目录
按业务组织后,订单模块可能需要使用用户信息:
ts
import {
getUserName
} from '@/modules/user/utils/getUserName'
这种直接进入另一个模块内部目录的写法,短期很方便。随着引用增加,模块边界会重新变得模糊。
用户模块以后调整内部结构时,订单、报表和权限模块都可能需要修改。
可以为模块提供一个公开入口:
text
modules/user/
├─ api/
├─ components/
├─ types/
├─ pages/
└─ index.ts
入口文件只导出允许其他模块使用的内容:
ts
export { getUserSummary } from './api/userApi'
export type { UserSummary } from './types/user'
其他模块从公开入口导入:
ts
import {
getUserSummary,
type UserSummary
} from '@/modules/user'
这项约束改变的是依赖链路。调用方只依赖用户模块对外提供的内容,不需要知道内部文件放在哪个文件夹下面。
如果模块规模很小,额外的入口文件可能显得多余。项目只有少量开发者、模块也不会独立调整时,直接导入具体文件也并不会造成问题。当模块经常重构,或者多人分别维护不同业务时,公开入口能减少内部目录变化向外扩散。
还要避免两个模块互相导入:
text
order → user
user → order
这种循环依赖可能带来初始化顺序问题,也会让模块难以独立理解。出现双向依赖时,可以检查双方共同使用的类型或能力是否应该上移到共享层,或者由更上层页面负责组合。
路由应该负责连接页面,不要拥有整个业务实现
业务页面移动到模块目录后,路由配置仍然需要找到它:
ts
const OrderList = () =>
import('@/modules/order/pages/OrderList.vue')
export const routes = [
{
path: '/orders',
component: OrderList
}
]
路由目录应该负责应用级页面入口、权限守卫和模块连接,像订单筛选、订单类型和订单请求仍然留在订单模块中而不应该放到路由目录里面。
如果项目路由很多,也可以让每个模块导出自己的路由配置:
ts
export const orderRoutes = [
{
path: '/orders',
component: () =>
import('./pages/OrderList.vue')
}
]
应用路由再进行汇总:
ts
import { orderRoutes } from '@/modules/order'
import { userRoutes } from '@/modules/user'
export const routes = [
...orderRoutes,
...userRoutes
]
这种方式让路由和业务模块靠得更近,但也增加了模块公开内容。项目只有十几个路由时,集中配置通常更直接。
选择哪种形式,可以看路由是否经常随业务模块一起增删。模块由不同人员维护、路由数量较多时,模块导出路由更方便。路由规则统一、权限处理集中时,保留应用级配置更容易控制。
应该如何迁移目录
目录调整会改变大量 import 路径。即使业务逻辑一行没改,提交记录也可能出现几百个文件移动。
这种改动有几个直接成本:
- 正在开发的分支更容易产生合并冲突;
- Git 历史中的文件追踪变得不够直观;
- 路径别名和测试配置可能需要同步;
- 某些大小写路径问题会在 Linux CI 中暴露;
- 循环依赖可能在搬迁后才被发现。
更稳妥的做法是按业务模块逐步迁移。
例如先处理订单模块:
text
第一步:创建 modules/order
第二步:移动订单页面和专用组件
第三步:移动订单接口、类型和组合函数
第四步:更新导入路径
第五步:运行类型检查、测试和构建
顺序重要的地方在于,每次迁移范围保持可验证。一个模块完成后再处理下一个,出现错误时更容易定位。
迁移提交也可以和业务改动分开:
text
提交 A:只移动文件和修改 import
提交 B:增加退款状态筛选
代码评审时,提交 A 主要确认路径是否完整,提交 B 才检查业务行为。把两类变化混在一起,评审者很难判断某一行修改来自搬迁还是需求。
迁移后至少执行项目现有的验证命令,例如:
bash
pnpm type-check
pnpm test
pnpm build
具体命令应以项目脚本为准。构建通过只能说明依赖路径和语法大体可用,不会证明所有页面交互都正确。订单模块仍然需要回归筛选、分页、详情跳转和权限等现有行为。
一套可维护的目录,也需要团队遵守边界
假设项目最终调整为:
text
src/
├─ app/
│ ├─ router/
│ └─ store/
├─ modules/
│ ├─ order/
│ ├─ user/
│ └─ product/
├─ shared/
│ ├─ components/
│ ├─ composables/
│ ├─ utils/
│ └─ types/
└─ main.ts
它只是一种适合当前假设项目的结果,不是前端项目的标准答案。
目录能够表达大致边界,但不能阻止下面这类代码出现:
ts
import {
orderInternalState
} from '@/modules/order/store/internal'
也不能阻止某个业务判断被塞进 shared/utils。边界最终仍然需要代码评审、导入约定或静态检查来维护。
项目可以从简单规则开始:
| 规则 | 解决的问题 |
|---|---|
| 业务代码优先留在对应模块 | 减少功能文件分散 |
shared 不依赖具体业务模块 |
防止共享层反向理解业务 |
| 跨模块只使用公开入口 | 降低内部结构变更影响 |
| 应用层可以组合多个模块 | 保持模块依赖方向清晰 |
规模较大的项目还可以通过 ESLint 导入规则限制跨层引用,但这会增加配置和维护成本。团队当前连目录约定都没有形成时,先写清规则并在评审中执行,通常比立刻加入复杂插件更合适。
留给日常开发的一点提醒
这次假设问题看起来只是增加一个订单筛选,实际修改却横跨接口、页面、组件、组合函数、状态和类型六个目录。目录第一层只表达技术类型时,一个业务功能由哪些代码组成并不直观。
按业务模块组织后,订单相关代码集中在同一范围内,共享组件和通用工具进入 shared,跨模块调用通过公开入口完成。它减少的是寻找文件、删除遗漏和内部重构向外扩散的成本。
代价也很具体:迁移会产生大量路径修改,模块边界需要持续维护,过小的功能被强行拆成多层目录后反而更难浏览。项目页面不多、团队规模较小时,原来的技术目录完全可以继续使用。
以后改一个功能时,可以先看 git diff 会跨多少顶层目录。如果同一业务的文件长期散落在项目各处,再考虑按模块收拢。目录调整应该从实际修改路径出发,而不是看到别人的项目有 modules,自己的项目也连夜补一个。
下一篇会继续讨论全局状态。目录已经按业务拆开后,哪些数据应该进入 Store,哪些状态留在页面或 URL 中,会直接影响模块之间是否重新耦合在一起。