项目结构存在的问题

不知道你有没有遇到这样的困惑:

每当想在项目下创建一个新文件的时候,不知道应该把它放到哪里,比如你想新增一张图片,你看到项目下不仅有个assets目录还有个images目录,这时你就很纠结,到底应该放到哪个目录下呢?图片到底属于images还是assets呢?

A页面的一个子组件被B页面引用了,这个公共子组件应该放到哪里呢?是放到某个公共目录common下吗?可是这个子组件除了被A、B两个页面使用,别的页面都没有使用,有必要放到common目录下吗?如果不放我放到哪里呢?

每次想要修改一个bug的时候,明明你已经大概想到了bug可能对应的文件,但是你却找不到这个文件在哪里,于是你只能从项目的入口文件开始一层一层的分析查找,就像是沿着一个链表遍历一样,层层深入才能找到某个文件,或者只能借助编辑器的全局搜索功能才能找到它。

如果你也有这样的困惑,或者你之前都没有意识到目录划分有多么的重要,那么让我们一起来看看如何组织一个清晰的项目目录吧。一个清晰的目录背后对应着一个清晰的项目结构,一个清晰的项目结构背后必然对应着结构化的思考,反之亦然。

如何评价一个项目结构

怎么样才能算一个好的项目结构呢?我想我们可以从文件的增删改查四个维度来分析。

  • 增:创建文件时不会纠结,一个文件只应该放到某个特定目录下,不存在二义性
  • 删:可以轻松删除一个功能模块,不会和其他模块有过多耦合
  • 改/查:可以快速定位到文件,查找文件应该像访问一个哈希表,可以直接定位到,而不是像访问一个链表或者拓扑图,需要反复遍历

项目结构划分的底层思想

本小册重点讲解编程的方法论,力争发现问题背后的本质,所以会重点讲解一些设计原则和设计思想,而不是针对一个具体的问题提出一个具体的方案,而是从一个具体的问题入手,通过分析归纳,找出解决这一类问题的底层思想、设计原则和方法论。

目录划分虽然看起来简单,谁都可以,但是真的要做好这件事,却并不容易,要做好目录划分,我们必须要先了解下其背后蕴含的一些重要设计思想与原则。

MECE法则

MECE是Mutually Exclusive, Collectively Exhaustive的缩写,翻译过来就是”相互独立,完全穷尽”,是《金字塔原理》一书中提到的一种思维方式,目的是让我们思考问题可以更全面,更系统,做到既不重复又不遗漏。

理解MECE法则只需要理解这两个概念:相互独立、完全穷尽。

相互独立

所谓”相互独立”就是当我们对问题进行分解的时候,确保每个层级的问题与问题之间没有重复、交叉或相关性。

举个非常简单的例子,我们来到某个景区的公厕,如果这里的公厕分成了男厕和女厕,那么这种分法就符合相互独立这个原则,因为男厕和女厕之间没有相关性,不存在一个人既是男人也是女人,你不会上错厕所。而假如把厕所分成了男厕、女厕、儿童厕所和残疾人厕所,则不符合相互独立这个原则,试想下现在一个女童,应该去女厕还是儿童厕所呢,或者一个残疾女童,应该去女厕、儿童厕所还是残疾人厕所呢?之所以这样,就是因为几个分类之间互相包含,不独立。

在项目目录划分时,经常会遇到违反相互独立原则的情况,假设现在有个项目,其中目录划分如下图。

└── static   //静态资源
    ├── lib  //第三方库
    ├── js   //js资源
    └── css  //css资源

现在项目下想引入一个名为x.js的第三方js库文件,那么应该把它放到lib目录下还是js目录下呢,它既是第三方库又是js文件,很显然lib和js概念存在交叉,一个文件可以属于lib也可以属于js,所以针对这种情况应该删除lib目录,把x.js放到js目录下,或者删除js、css目录,只保留lib目录。

└── static
    ├── js
    └── css

再来看一个例子,有的项目下可能存在一个components目录,想要用来存放公共组件,而同时目录下又存在一个common目录,用来存放公共代码。这也是违反相互独立原则的,一个公共组件本身也是属于公共代码,common在含义上包含了components,所以应该将components目录挪到common目录下。

├── components
└── common

修改后:

└── common
    └── components

实际工作中,目录划分存在问题最多的场景就是目录之间职责不清晰,互相包含或者交叉,导致大家新建文件时不知道到底该放到哪个目录下,而在查找文件时也不确定文件会在哪个目录,造成混乱和效率低下,希望大家在划分目录时多注意这个问题,严格明确每个目录的概念内涵和职责边界。

完全穷尽

所谓”完全穷尽”则是要求所有的子问题或分类加起来必须覆盖整个问题的所有集合。这意味着在拆解问题时,不能遗漏任何重要的元素或方面。每个与问题相关的点都应该被纳入某个子问题中,以确保对问题的全面分析。

还拿上面提到的公厕来举例,如果把公厕分成了男厕和女厕,虽然满足了相互独立,但是满足了”完全穷尽”这个原则吗?对于性别来说,男和女都属于单一性别,而社会上还会存在一些无性别(比如过去的太监)或者双性别(比如身体属于男而精神上认为自己是女性)的人存在,只是这部分人比例比较少,从社会成本角度考虑,有的景区可能会推出无性别厕所供这些人使用。这也是为什么,在一些交友软件上设置性别时不只是男、女,有时还会有其他,通过这种方式穷尽所有选择。

回到目录划分上,一个大类下的子类,同样需要满足完全穷尽原则,比如在公共(common)目录下,除了要有组件(components)、工具库(utils),还需要有样式(style)、常量(const)、api服务(service)、资源(assets)等等,那么怎么能完全穷尽呢?这里给大家介绍几个具体方法。

  • 二分法

二分法就是找一个角度将事物一分为二,非黑即白,就像A和!A。比如按照国籍把人分为中国人和外国人,按地域把人分为本地人和外地人,按性别把人分为男性和女性等。

比如通过这种方法把项目分为代码(code)和非代码(如doc)。

├── code
└── doc

对于二分法,核心就是你要找一个维度,然后对事物一分为二。

  • 要素法

要素法就是根据事物的属性对其进行分类,比如按照人的年龄,把人划分为婴幼儿、儿童、青年、壮年、老年,按照人的籍贯把人分为北京、上海、广州…,按照人的学历把人分为高中以下、高中、大专、本科、研究生、博士等。

就像上面我们提到的,按照前端文件的类型,将common目录划分为组件components、工具库utils、常量const、配置config、api服务service、样式style、指令directive、中间件middleware、 资源assets。

  • 流程分析法

流程分析法按照事物发展的流程或顺序进行分解,每个步骤都是独立的,所有步骤共同构成完整的流程。比如将一个生产加工过程分为原材料采购、生产加工、质检、包装、销售和售后服务。

通过流程分析法,我们可以将项目的目录划分为这几类:开发前准备工作(接口mock、打包配置build)、业务开发(一般源码放在src目录)、测试(test目录存放测试用例)、部署(deployer目录存放部署脚本或CI/CD配置)、上线(doc目录存放帮助文档)。

  • 矩阵分析法

使用矩阵将问题划分为不同的象限或区域,每个区域代表一个独立的子问题,比如我们熟知的时间”四象限”法,把工作按照重要和紧急两个不同的程度进行了划分:重要紧急、重要但不紧急、紧急但不重要、既不紧急也不重要。

MECE法则是结构化思考的一个重要方法,它可以帮助我们更好地划分问题,从而更好地解决问题。项目目录划分只是MECE法则的一个很小的应用场景,它还可以应用到我们的任务拆解,总结汇报等多个工作生活场景中。

分层思维

分层思维是软件开发中的一个重要思维,通过分层将复杂的软件系统划分为一系列相对独立且功能明确的层次,每个层次都只关心这个层次赋予的职责,从而实现了关注点的分离;每个层次我们还可以通过MECE原则再分成多个子模块,进一步将一个复杂问题拆解为一个一个小问题,逐个击破,将很难完成的复杂任务慢慢消化掉。

特别是在一些后端项目中,分层思维随处可见,比如我们经常听到的MVC模式,M表示model(模型),负责处理数据的存储、检索和更新等操作,模型通常与数据库交互,执行数据的增删改查操作;V表示View(视图),负责数据的展示,视图从模型中获得数据,并将其以特定的格式(如HTML)呈现给用户,就是咱们看到的前端界面;C表示Controller(控制器),负责接收用户的输入(如页面请求、提交表单等),然后调用模型获取数据返回给用户或者将用户传来的数据保存到数据库中。

通常在一些PHP项目中都会看到这3个目录。

├── model        // 数据库的增删改查放这里
├── controller   // 链接view和model,负责业务逻辑处理
└── view         // 负责根据数据进行页面展示

回到我们前端项目,你的项目是如何分层的呢?有没有考虑过这个问题呢?

以我们前端的组件components为例,我们可以把组件划分成这四层:

  • 通用组件:

存放和业务无关的组件,比如button、input、select等,通常我们使用第三方组件库来作为通用组件,偶尔编写一些这类组件。

  • 项目基础组件:

项目中广泛使用但并不局限于某个具体业务的组件,比如基于通用组件封装的自己项目的弹窗、表单、表格等,这里的组件主要为了提高开发效率以及满足公司的UI/UE规范。

  • 业务组件:

具体业务模块的组件,比如小到一个下拉选择用户的小组件UserSelect,大到用户列表UserList、新增用户弹窗AddUserDialog这样的复杂业务组件,都是和业务紧密相关的。

  • 页面组件:

页面组件和路由一一对应,一个路由对应一个页面组件,页面组件将业务组件进行组合,从而构成一个完整的可供用户使用的页面。

根据上面分层,我们暂时可以划分成如下所示的目录结构,后面继续探讨如何更加合理的划分。

├── base               //公共资源目录
│    └── components    //项目基础组件
│       ├── MyTable    //通用table组件
│       ├── MyForm     //通用Form组件
│       └── MyDialog   //通用弹窗组件
└── src
    ├── components    //业务组件
    │   └── user
    │       ├── UserSelect
    │       └── UserList
    └── pages        //页面组件
        ├── user-list   //页面组件引用业务组件和基础组件
        └── user-detail

在对组件进行分层之后,组件的调用关系也变的简单了,只要遵循一个原则即可:高层组件可以调用底层组件,底层组件绝对不允许引用高层组件。也就是说页面组件可以引用业务组件和项目基础组件,但是项目基础组件中绝对不可以调用业务组件和页面组件,只要遵循这一个原则,就不会存在循环依赖的问题了,很多同学的引用关系混乱,就是因为没有好好地进行分层设计。

上面只是说了组件,其实CSS也是一样的,都可以如此划分,仔细想下,你的CSS做过分层设计吗,每一层你都是怎么设计的呢?每一层的职责是什么呢?

领域驱动设计DDD

经常听后端同学提到领域驱动设计,但在我们前端,却鲜有人提及这个概念,也鲜有人提及如何在前端落地这个概念。

领域驱动设计(DDD,Domain-Driven Design),简称DDD,就是以一种领域专家、设计人员、开发人员都能理解的通用语言作为相互交流的工具,在交流的过程中发现领域概念,然后将这些概念设计成一个领域模型;由领域模型驱动软件设计,用代码来实现该领域模型。

怎么理解呢,比如你要做一个电商的商品模块,如果你直接给非技术人员(如销售、库管、运营、产品等)看你的代码,他们肯定看不懂的,但是你如果给他们看一个模型,告诉他们你要设计的商品模块包含哪些属性,有哪些功能,他们是可以理解并且帮你去完善的。具体到代码实现上,可以简单理解为在应用层之下再抽象一个领域层,比如公司要开发了多个电商系统,而每个电商系统肯定都会有诸如用户、商品、订单、支付、日志等多个基础模块,不管哪个电商系统,用户、商品等一般底层逻辑都差不太多,如果我们抽象出一个比较好的领域层,那么后续再开始新的电商系统,就基于我们的领域层再开发一个新的业务层即可。

回到我们前端项目设计,我觉得可以借鉴这个概念,在前端项目中建立一个领域层,用来存放各个业务实体的核心设计。什么是业务实体呢?就是业务中的一个具体概念,通常都是一个名词,比如用户User、商品Product、订单Order等;存放哪些设计资源呢?可以存放这个业务相关的所有资源,以用户User模块为例,设计资源包括用户相关的一些常量配置(如api接口地址)、service(用户的增删改查接口调用方法)、静态资源(如默认用户头像)、配置(用户列表的columns配置)、组件(添加用户的表单)、utils(格式化用户手机号的方法)等等。

├── domain         //领域层
│    ├── user          //用户模型
│    │  ├── const          //用户相关的常量配置
│    │  │   ├── api.js         //用户功能涉及的接口配置,如 exports const userAPi = '/api/v1/user'
│    │  │   └── status.js      //用户功能涉及的一些状态常量 exports const UserType = {vip: 1, ordinary:2}
│    │  ├── service.js     //用户的增删改查接口调用方法
│    │  ├── components     //用户涉及的组件
│    │  │   ├── AddUser.vue       //添加用户组件
│    │  │   └── UserAvatar.vue    //用户头像组件
│    │  ├── config.js      //用户涉及的配置
│    │  └── utils.js       //用户涉及的utils工具库
│    └── product       //产品模型
│       ├── const    
│       ├── components 
│       ├── service.js   
│       ├── config.js     
│       └── utils.js
├── src          //应用层
│   └── pages        //页面组件
│       ├── user-list   //页面组件引用基础组件和领域层组件
│       └── user-detail
└── base            //基础层
     └── components    //项目基础组件
        ├── MyTable    //通用table组件
        ├── MyForm     //通用Form组件
        └── MyDialog   //通用弹窗组件       

可以看出,我们将整个项目分成了三大层:基础层、领域层和应用层,每一层都有明确的职责和边界,其中基础层主要是存放一些业务无关的基础组件和utils库等,领域层存放和业务模型息息相关的所有设计资源,应用层存放项目相关的资源(路由、store、页面等)。通过这样的设计,以后再新建文件时就不会纠结了,该放哪里一目了然,而如果你想修改添加用户的组件,你可以直接在 domain/user/components/AddUser.vue中瞬间找到它,不用再来回翻找了。

通过这种分层设计,日常我们的代码修改一般局限在领域层和应用层,基础层我们几乎不需要动了,也在一定程度上隔离了变化。

平时我们做项目的目标也是把基础层和领域层做大做强,而把应用层做薄,因为基础层和领域层的可复用性较强,可以在新项目中大量复用,而应用层则几乎不会在多个项目中复用,所以通过这种设计也有利于我们后期迅速开启新项目。

就近原则

在目录划分时我们需要尽量遵循就近原则,就近原则就是把逻辑上相近的资源存放在一起,这里”逻辑相近”我们可能很难把握,可以简单理解为如果因为同一个修改理由会同时修改A和B两个文件,那么A和B认为逻辑相近,应该把A和B放到距离较近的位置。

根据作者多年经验,目前我们项目中,大部分都根据文件类型划分目录,比如现在有用户和产品相关的组件、api资源和utils,我们通常这样存放:

├── components
│    ├── user
│    │  └── UserList.vue      
│    └── product
│       └── ProductList.vue    
├── api
│    ├── user.js
│    └── product.js
├── service
│    ├── user.js
│    └── product.js
└── utils
     ├── user.js
     └── product.js

这样存放当然也是可以的,但是会存在一些问题:

  • 新增

如果我们要新增一个模块,需要同时修改components、api、service、utils四个目录,需要在目录之间跳来跳去。

  • 编辑

如果用户列表出现问题了,你也可能需要同时翻遍四个目录,才能完成所有修改任务。

  • 删除

想要删除一个用户模块,同样地你也需要在四处进行删除操作,大概率很多同学就不管了,只把入口文件删除,其他的就当垃圾文件留在了项目中。

我们可能因为修改用户列表这个需求(同一个理由),而去修改上面的四个用户列表相关的文件,那么我们认为这四个文件应该是逻辑上相近的,这也是为什么,在上面的领域层中,我们把某个业务模块的所有资源聚集到一起。

按照就近原则我们可以将目录结构重新组织为如下形式,如果新增一个业务模块,直接复制一份之前的简单修改下即可;如果编辑一个业务模块,也只在该业务目录下进行文件查找,不需要来回跳转;删除一个模块更简单了,只需要把业务目录删除即可,可以看出,采用就近原则组织项目目录会极大提升我们的开发效率。

└── domain         //领域层
     ├── user          //用户模型
     │  ├── const          //用户相关的常量配置
     │  │   ├── api.js         //用户功能涉及的接口配置,如 exports userAPi = '/api/v1/user'
     │  │   └── status.js      //用户功能涉及的一些状态常量 exports UserType = {vip: 1, ordinary:2}
     │  ├── service.js     //用户的增删改查方法
     │  ├── components     //用户涉及的组件
     │  │   ├── AddUser.vue       //添加用户组件
     │  │   └── UserAvatar.vue    //用户头像组件
     │  ├── config.js      //用户涉及的配置
     │  └── utils.js       //用户涉及的utils工具库
     └── product       //产品模型
        ├── const    
        ├── components 
        ├── service.js   
        ├── config.js     
        └── utils.js

一致原则

一致原则指的是在整个项目中保持相似的结构和命名约定,以便开发人员能够快速理解项目结构。 通过保持一致性,可以降低学习成本、提高团队协作效率,并减少出错的可能性。

  • 目录/文件命名保持一致

一个项目的团队成员需要坐在一起制定下自己的命名规范,比如目录名称是用”_“链接,还是”—“链接,亦或是采用小驼峰或者大驼峰命名,虽然这个不影响使用,但是如果命名不一致就会让人感觉非常不专业,也让人在命名时产生纠结,这说起来像个笑话,但是实际工作中却是经常见到。

//让处女座抓狂的目录命名
 
└── components
     ├── user-list
     ├── user_detail
     ├── AddUser
     └── editUser
  • 目录下的内容要和目录名称保持一致

一个目录下的内容,如果和内容不一致,则会给查找时增加极大的阅读障碍,比如在components目录下放了一个config.js文件或者在css目录下放置less文件(是不是把css改为style更合适)。

  • 命名要和使用方保持一致

这个可能不太好理解,我举个例子,比如用户列表页的url为 “http://127.0.0.1:8080/user-list”, 那么我们这个路由对应的页面组件就应该和这个url上的命名保持一致,这样当你看到某个页面出现问题时,你会直接定位到对应的页面组件。

// http://127.0.0.1:8080/user-list 对应哪个页面组件?
 
└── pages
     ├── user-list    //很好,肯定就是这个
     ├── users        //一般,可能就是这个
     └── members      //很差,出乎意料,可能要从路由配置文件找起,才会知道原来对应的是这个
         └── list

项目实战

我们以一个单页面应用为例,来设计一个完整的项目结构。

首先我们通过MECE原则中的二分法,将项目代码分成两大部分:开发阶段文件和非开发阶段文件,非开发阶段文件主要包括上线部署相关的文件和帮助文档;

开发阶段的文件我们可以根据是否发布到生产环境,分成会发布到生产环境文件和不会发布到生产环境文件(也可以称为开发环境文件)。

开发环境文件我们使用要素法,根据文件作用分为三大类:数据mock、编译打包build、单元测试test;

生产环境文件按照我们之前探讨的,使用分层思维分成三层:基础层base、领域层domain和应用层src,基础层和领域层上面我们已经详细说过了,这里重点说下应用层,应用层我们根据文件作用分为资源assets、布局文件layout、页面组件pages、路由router和数据仓库store。

完整的思维导图如下:

如果完全按照我们这个思维导图进行项目目录划分,则层级太深,层级太深查找时就不太方便,我们可以考虑把子节点拿出来平铺到一起,以降低目录的层次。

最终我们梳理的项目结构如下:

├── dev         //工程代码
│    ├── mock      //mock文件
│    ├── build     //编译配置
│    └── test      //测试文件 
├── deployer    //部署文件
│    ├── Dockerfile      
│    └── nginx     
├── doc         //文档
│    └── help.md     
├── domain      //领域层
│    ├── user         
│    │  ├── const          
│    │  │   ├── api.js         
│    │  │   └── status.js      
│    │  ├── service.js     
│    │  ├── components     
│    │  │   ├── AddUser.vue       
│    │  │   └── UserAvatar.vue   
│    │  ├── config.js      
│    │  └── utils.js       
│    └── product       
│       ├── const    
│       ├── components 
│       ├── service.js   
│       ├── config.js     
│       └── utils.js
├── src          //应用层
│    ├── assets       //资源文件  
│    ├── layout       //布局文件  
│    ├── router       //路由配置  
│    ├── store        //共享数据  
│    └── pages        //页面组件
│       ├── user-list   
│       └── user-detail
└── base        //基础层
     ├── const         //常量配置
     ├── styles        //公共样式
     ├── utils         //公共库
     └── components    //项目基础组件

至此,项目的大致结构已经成型,接下来我们只需要慢慢丰富每个模块的内容。可以看到每个目录的职责和边界都非常清晰,不会出现一个文件可以放两个地方的情况出现;在短暂的熟悉之后,后续想要查找某个文件,可以快速的根据项目结构进行定位,符合我们前面对好的项目架构的评价标准。

当然,这只是利用我们本次所学的各个思维和原则进行的一种实践尝试,并不是唯一的正确答案,也没有绝对的正确答案,我们可以根据自己的情况按照不同的维度去进行拆分,只要满足这些原则,相信都不会太差。

代码示例

总结

本节我们探讨了如何组织一个清晰的项目结构,一个好的项目结构在创建时不纠结,在查找时快的就像访问一张哈希表。

组织一个清晰的项目结构需要具备这几个思维和原则:

  • MECE法则:强调在划分目录时既要相互独立,又要完全穷尽,可以通过二分法、要素法、流程分析法和矩阵分析法可实现这个法则。
  • 分层思维:合理的分层可以降低项目的开发难度,解除循环依赖,在设计项目时要通过分层思维对我们的组件、样式等进行层次划分,定义好每个层次的职责
  • 领域驱动设计DDD:通过增加领域层来组织业务代码是一个很好的实践策略,领域层中每个模块就是业务中的一个具体概念,是一个名词,某个领域的全部资源都应该放到领域模块下。
  • 就近原则:如果因为同一个理由需要修改多个文件,那么这几个文件最好放到一起
  • 一致原则:团队应该制定一个统一的命名规范,同时目录下的文件内容要和目录名保持一致,页面组件的命名要和路由path保持一致

课后任务:利用本节提到的这些思维和原则来重新审视下我们的项目,看看结构划分存在问题吗,如何利用这些原则去重构我们的项目结构?