在开始往下读之前,希望各位先在心里回答我的一个问题——划分目录结构的意义是什么?
意义
对于上面的问题,想必有的人的回答是:「规范文件的存放位置,找起来方便。」这没错,但过于表层了,没有说到实质。
另外一些人会说:「目录结构实际就是模块拆分的体现,是架构的一部分,其划分方式应具有让开发者把文件放到正确位置的指导作用。」这个说法我认为说到了实质,也是我当前的看法。
也正因为人们对划分目录结构的意义的理解有所不同,才产生了不同的目录结构划分模式。
模式
对于一个前端项目来说,其目录结构的划分模式主要有三种——
野生
这是一种大部分项目会采用的很常见的模式,目录结构大致为:
project/src
├── api
│ └── ...
├── assets
│ └── ...
├── components
│ └── ...
├── pages
│ └── ...
├── plugins
│ └── ...
├── router
│ └── ...
├── types
│ └── ...
├── App.vue
└── main.ts
虽然这个示例是一个 Vue 项目,但无论是 React 项目、jQuery 项目或是其他非 Angular 的项目,一般都会采用这种模式。
此模式最大的特点就是「符合直觉」,按照文件的类型或其所扮演的「角色」进行划分:纯资源型文件放到 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
是对领域模型或业务实体的描述,repository.ts
则主要用来存取资源;在页面中显示和操作的数据的结构,是 model.ts
中描述的,需要通过 repository.ts
中定义的方法去发送请求或者其他方式推送或拉取:
// model.ts
type RobotEntity = {
id?: string;
name: string;
description: string;
};
// repository.ts
class RobotRepository {
public async getAll(): Promise {}
public async getList(condition: any): Promise {}
public async getOneById(id: string): Promise {}
public async insert(data: RobotEntity): Promise {}
public async update(data: RobotEntity): Promise {}
public async deleteOneById(id: string): Promise {}
}
model.ts
中所描述的数据结构与后端返回的并不需要一致,视情况而定是否要一致以及一致的程度。
在 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
文件,用于描述该模块依赖哪些模块的什么资源(请求服务、部件/业务组件等),以及它向其他模块提供什么资源。
为了提高灵活性,最好设计并实现一套模块注册与查找机制,以替代常规的 import
、export
。理想状况下,每个模块都可以跨应用使用。
对于开发者来说,该如何看待这一个个模块呢?就当它们是 npm 包或 Git Submodule 好了。
改善后的依赖关系如下图所示:
与「分层」模式相比,「模块化」模式进入了「工业化时代」——高度内聚,便于集成。
总结
本文所阐述的三种目录结构划分模式的前提是在常规的手工开发的前端项目中。前两种模式与民工叔在《世界是平的吗?——从不同角度看前端》中所提到的三种组件化体系中的前两种可以说是一一对应的;而第三种模式则与他的第三种体系有所相似。
虽然这三种模式是递进关系,后者比前者更完善,但并非后者一定比前者更适合项目所面临的场景,并且还得考虑团队人员构成。总之,要因材施教,因地制宜。