前言

开发大型项目时,如何定义一套良好且合理的文件夹结构,是我们经常碰到且颇具挑战的问题之一。

那么,为什么要划分目录结构呢?它的意义是什么?

对于这个的问题,有的人的回答是:「规范文件的存放位置,找起来方便。」

这没错,但太过于表层,没有说到实质。

目录结构实际就是模块拆分的体现,是架构的一部分,其划分方式应具有让开发者把文件放到正确位置的指导作用。

也正因为人们对划分目录结构的意义的理解有所不同,才产生了不同的目录结构划分模式。

默认扁平的项目结构(野生模式)

一般情况下,使用流行的前端框架搭建新项目基架时,其生成的目录结构是扁平的,不遵循任何层次结构,大致表现为:

project/src ├── api ├── assets ├── components ├── pages ├── plugins ├── router ├── types ├── App.vue └── main.ts
  • assets 目录存储静态资产,例如整个应用程序中使用的图像、字体和 CSS 文件
  • components 目录包含可重用的 Vue 组件,建议使用平面层次结构
  • main.ts 充当应用程序的入口点,启用 Vue 初始化和插件或其他库的配置
  • App.vue 表示应用程序的根组件,充当其他组件的容器并充当主模板

此模式最大的特点就是「符合直觉」,按照文件的类型或其所扮演的「角色」进行划分: 纯资源型文件放到 assets,UI 组件的话就放到 components,页面的全部扔进 pages……

要说这种模式的优点?那我觉得「符合直觉」勉强算是它唯一的「优点」

然而实际上,「符合直觉」正是该模式众多缺点的罪魁祸首——

这种模式很容易导致「页面驱动」的思维模式,也就是说,无论是产品需求、UI 设计还是开发,都以页面为中心去思考、交流与协作,而不是以领域或业务为出发点;带来的后果就是,无论是产品需求、UI 设计还是开发,都不成体系。

  • 页面中容易耦合大量与展示及交互无直接关系的逻辑,并且这些逻辑无法被很好地自动化测试。
  • 通常按菜单结构拆分模块,同一个模块的资源散落在各处,若该模块对应的业务需求有所变动,得到处找要修改的文件,当项目或人员变得复杂时会让代码维护变得更加困难。
  • 模块间的依赖关系混乱,会出现 B 模块页面依赖定义在 A 模块页面文件夹下的 UI 组件的情形,也会有 B 模块页面依赖 A 模块 HTTP API 的同时 A 模块页面又反向依赖 B 模块 HTTP API 的情况。
  • 另外,这种模式很难看出前端架构的模样,对于开发者缺乏指导性和约束性。

它因为「符合直觉」,比较「本能」而没有什么约束,同时,也未被业务需求所「驯化」。

对于一个大型项目来说,这种架构很快就会失控。我们需要使用某种模块化,通过设置功能之间的边界,以便能够轻松地定位指定文件。同时最大限度地减少代码耦合和副作用,使代码库更易于理解。

这么说,那是不是这种模式就不应该存在?也不是,它适用的场景是那些,生命周期短、功能相对简单、不会长久维护的项目。

分层模式

当一个需要长期维护的项目变得越来越复杂时,如果一开始采用的是「野生」模式,为了解决和避免它所带来的种种问题,可以考虑重构一波了。

一个比「野生」模式更适合复杂前端项目的是「表现-领域-数据」相分离的「分层」的目录结构。 这里说的「领域」不一定是严格意义上的「领域逻辑」,也可以是「业务逻辑」。

虽说是「表现-领域-数据」,但在一个前端项目中大多只有「表现-领域」就够了:

project/src ├── domain │ └── ... ├── presentation │ └── ... ├── shared │ └── ... ├── App.vue └── main.ts

可以看到,与「野生」模式相比,src 文件夹下以三个用相对宽泛的词语命名的文件夹来划地盘—— 与「表现-领域」相对应的 presentation 和 domain 文件夹,以及用来存放共享资源的 shared 文件夹。

共享资源是类型定义、工具函数、全局样式、基础组件等领域及业务无关的:

shared ├── components │ ├── button │ │ └── ... │ ├── icon │ │ └── ... │ ├── ... │ └── index.ts ├── styles │ ├── normalize.scss │ ├── reset.scss │ └── utils.scss ├── types │ ├── ... │ └── index.ts ├── utils │ ├── date.ts │ ├── url.ts │ ├── ... │ └── index.ts └── ...

领域层中只有视图库/框架无关的代码,因此就算从 Vue 换成 React 之类的,对核心的领域逻辑/业务逻辑也不会造成什么影响。

借鉴领域驱动设计(DDD)的思想,先按照领域或业务去拆分模块,再在每个模块中维护领域模型和业务规则等的相关文件;领域建模相关知识这里不展开 这部分代码不会受页面的变化而改变,只有业务的变化或者对抽象的完善才会改变:

domain ├── knowledge-base │ ├── model.ts │ ├── repository.ts │ ├── ... │ └── index.ts ├── knowledge-graph │ ├── model.ts │ ├── repository.ts │ ├── ... │ └── index.ts ├── robot │ ├── model.ts │ ├── repository.ts │ ├── ... │ └── index.ts └── ...
  • model.ts 是对领域模型或业务实体的描述,其实也就是在页面中显示和操作的数据的结构
  • model.ts 中所描述的数据结构与后端返回的并不需要一致,视情况而定是否要一致以及一致的程度
  • repository.ts 则主要用来存取资源,model.ts 中描述的数据,需要通过这里定义的方法去发送请求或者其他方式推送或拉取
// model.ts type RobotEntity = { id?: string; name: string; description: string; }; // repository.ts class RobotRepository { public async getAll(): Promise<RobotEntity[]> {} public async getList(condition: any): Promise<RobotEntity[]> {} public async getOneById(id: string): Promise<RobotEntity> {} public async insert(data: RobotEntity): Promise<void> {} public async update(data: RobotEntity): Promise<void> {} public async deleteOneById(id: string): Promise<void> {} }

在 presentation 文件夹下维护与领域及业务相关且受视图库/框架影响的代码:

presentation ├── aspects │ ├── http.ts │ ├── router.ts │ ├── ... │ └── index.ts ├── layouts │ └── ... ├── router │ └── ... ├── views │ ├── knowledge-base │ │ ├── knowledge-base-detail │ │ │ ├── KnowledgeBaseDetail.vue │ │ │ ├── ... │ │ │ └── style.scss │ │ ├── knowledge-base-form │ │ │ ├── KnowledgeBaseForm.vue │ │ │ ├── ... │ │ │ └── style.scss │ │ ├── knowledge-base-list │ │ │ ├── KnowledgeBaseList.vue │ │ │ ├── ... │ │ │ └── style.scss │ │ ├── helper.ts │ │ └── KnowledgeBaseView.ts │ └── ... └── widgets └── ...
  • views 下是按领域或业务拆分模块(与领域层相对应)的视图/页面
  • widgets 是跨模块使用的部件/业务组件
  • layouts 是视图/页面会用到的整体布局
  • router 是按菜单结构划分的路由配置
  • aspects 则是请求拦截、路由守卫等切面

在本模式中,模块间的依赖关系大概如下:

很明显,相对于「野生」模式来说,「分层」模式能够看出前端架构的模样,并为项目扩张留下了增长空间;模块间的依赖关系更加清晰,不至于让阅读代码的人脑子里一团糟;与展示及交互无直接关系的逻辑从各类 UI 组件中剥离出去,既让表现层变得轻薄,又让这部分逻辑能够更好地被自动化测试。

更为重要的是,这种模式会促使开发者在动手写代码前先思考下要写的代码属于哪种,依赖关系是怎样,然后看看划好的地盘,挖好的坑,再决定往哪个坑里放。

如果说「野生」模式是「原始社会」,那「分层」模式就是进入了「文明社会」——写代码时有所约束,更重视流程和体系。

模块化

「分层」模式足以应对日趋复杂的前端项目,看起来已经很完美了,为什么还要有「模块化」模式?

难道「野生」模式和「分层」模式不是模块化的吗?当然是!上文所说的那两种模式都是模块化的。

「分层」模式主要的一个痛点在于,内聚性没有理想中那么高!

从「分层」模式的模块间依赖关系图中可以看出,按照领域或业务拆分的同一个模块(绿色方块)被分层给隔开了;于是看似是一个模块,实际已经割裂成两个模块,模块依赖关系也不具备完整性了。

「模块化」模式就是为了弥补「分层」模式的缺陷,从而提升模块的内聚性和依赖关系的完整性。

「分层」模式的目录结构划分方式是先纵向分层再各自横向按领域或业务拆分模块,而「模块化」模式正好反过来——先横向按领域或业务拆分模块再在各模块内部看情况进行纵向分层——正如《什么是耦合,什么是内聚》中所描述的「通过移动包含的边界,达成内聚」。

基于「模块化」模式的目录结构调整后的结果大体如下:

project/src ├── [domain-specific-module] │ ├── views │ │ ├── [detail-view] │ │ │ ├── [DetailViewComponent].vue │ │ │ ├── ... │ │ │ └── style.scss │ │ ├── [form-view] │ │ │ ├── [FormViewComponent].vue │ │ │ ├── ... │ │ │ └── style.scss │ │ └── [list-view] │ │ ├── [ListViewComponent].vue │ │ ├── ... │ │ └── style.scss │ ├── widgets │ │ └── [domain-specific-widget] │ │ └── ... │ ├── helper.ts │ ├── index.ts │ ├── model.ts │ ├── repository.ts │ └── ... ├── entry │ ├── aspects │ │ ├── http.ts │ │ ├── router.ts │ │ ├── ... │ │ └── index.ts │ ├── layouts │ │ └── ... │ └── router │ └── ... ├── shared │ └── ... ├── App.vue └── main.ts

调整后的目录结构与之前的差异点在于——

把 presentation 下的 views 和 widgets 拿掉后重命名为 entry,顾名思义,就是「入口」,汇集了其他各个模块的资源。

将 domain 与 views 和 widgets 进行了整合,形成完全的按领域或业务拆分的模块。上面目录结构图中带方括号的命名是形式化的,实际操作时需要根据具体模块所代表的领域或业务进行命名。

整合后的 widgets 意义发生了变化,不再是跨模块的,而是当前模块特定的。虽说如此,但仍可被其他模块所使用——通过模块依赖指定的形式。

每个领域/业务模块下有一个 index.ts 文件,用于描述该模块依赖哪些模块的什么资源(请求服务、部件/业务组件等),以及它向其他模块提供什么资源。

为了提高灵活性,最好设计并实现一套模块注册与查找机制,以替代常规的 importexport。理想状况下,每个模块都可以跨应用使用。

对于开发者来说,该如何看待这一个个模块呢?就当它们是 npm 包或 Git Submodule 好了。

改善后的依赖关系如下图所示:

与「分层」模式相比,「模块化」模式进入了「工业化时代」——高度内聚,便于集成。

在实施这种模式时,最好就是把每个模块都看成是像 npm 包或 Git Submodule 这类,它们自己是相对独立的。「多个业务公用的业务代码」可以是一个单独的模块,暴露出让其他模块可用的资源,这有点考察建模能力。

当有多个页面用到看起来一样或相似的逻辑/组件时,要想下——

  1. 这个看起来一样或相似的逻辑/组件是不是或可不可以是一个泛化的 util 或基础组件?
  2. 如果不是,那它是不是或可不可以是哪个领域/业务模块的?
  3. 前两个步骤的结果都是「否」的话,那就让那逻辑/组件重复好了
总结

本文所阐述的三种目录结构划分模式的前提是在常规的手工开发的前端项目中。前两种模式与民工叔在《世界是平的吗?——从不同角度看前端》中所提到的三种组件化体系中的前两种可以说是一一对应的;而第三种模式则与他的第三种体系有所相似。

虽然这三种模式是递进关系,后者比前者更完善,但并非后者一定比前者更适合项目所面临的场景,并且还得考虑团队人员构成。总之,要因材施教,因地制宜。