20 大型项目:源码越来越多,项目该如何扩展?
你好,我是宋一玮,欢迎回到React应用开发的学习。
上节课提到,为了应对大中型React项目的复杂应用逻辑,我们会分为局部和整体两个部分来学习。对于作为局部的组件逻辑,可以通过抽象来简化组件的设计和开发。我们学习了React中的自定义Hooks和组件组合这两种抽象方式,也学习了在这两种抽象基础上的代码复用,尤其是高阶组件的写法。
从局部到整体,复杂度会在代码量上直观地展现出来。在前端工程化和团队协作的基础上,大型React项目代码量上10万很常见。项目从最初的几行代码到如今的数十万代码,你可能会遇到如下问题:
- 新功能的组件、Hooks、样式要不要分文件写,源文件都放到哪里?
- Redux的action、reducer、store都写到哪里?
- 公共的代码放到哪里?
- 代码文件多到找不到怎么办?
这节课我们会继续讨论React应用的整体逻辑,看看大中型React项目在代码增多后,整体扩展上会遇到的挑战,以及如何应对这些挑战。
几种典型的React项目文件目录结构
项目源码的文件目录结构并不等同于应用的整体逻辑,但却可以作为把握应用整体逻辑的一张“地图”。一个良好的文件目录结构是自解释的,可以帮助新接触项目的开发者快速熟悉代码逻辑。
React应用项目有以下五种典型的文件目录结构:
- 单文件结构;
- 单目录结构;
- 按文件职能划分目录结构;
- 按组件划分目录结构;
- 按业务功能划分目录结构。
接下来我们分别看一下。
单文件结构
是的,你没看错,单文件结构就是指,在单个React组件文件中开发所有业务逻辑。
你肯定亲眼见过这种结构,比如在第3节课里CRA创建的 oh-my-kanban
项目,不算样式的话,我们当时把所有代码都写在了 src/app.js
中。需要注意的是,这种结构只适合代码演示或微型的React项目。
单目录结构
比起单文件结构,这种结构拆分了组件文件,拆分的文件都放在同一个目录下。前面第12节课末尾,oh-my-kanban
项目完成大重构第一步时的项目结构就是这样。目录树结构(有省略)如下:
src
├── App.css
├── App.js
├── KanbanBoard.js
├── KanbanCard.js
├── KanbanColumn.js
├── KanbanNewCard.js
├── index.css
└── index.js
单目录结构比起单文件结构,能支撑更多组件以及相关逻辑,适合微型React项目。
按文件职能划分目录结构
顾名思义,在这种结构下,组件文件放一个目录,自定义Hooks文件放一个目录,context文件放一个目录,如果使用了Redux的话,actions、reducers、store各占一个目录(或者Redux Toolkit的slices和store目录)。
在第13节课,oh-my-kanban
项目完成大重构后,加入的第一个context就放到了独立的 src/context
目录。不只JS项目,在第18节课,我们尝试为TS项目加入的类型定义src/types/KanbanCard.types.ts
,也放到了专门的 types
目录下。目录树结构(有省略)如下:
src
├── components
│ ├── App.css
│ ├── App.tsx
│ ├── KanbanBoard.tsx
│ ├── KanbanCard.tsx
│ ├── KanbanColumn.tsx
│ └── KanbanNewCard.tsx
├── context
│ └── AdminContext.ts
├── hooks
│ └── useFetchCards.ts
├── types
│ └── KanbanCard.types.ts
├── index.css
└── index.tsx
按文件职能划分目录结构的优点在于,可以快速定位任何一种类型的源码,在源码之间导入导出也比较方便:
当其中某个或者某几个目录中的文件数不断增多时,这种结构的缺点就暴露出来了:不容易定位到直接相关的源文件。比如 hooks/useFetchCards.ts
目前只有 components/App.tsx
在用,这从目录结构上是看不出来的,必须进到源码里去看,当 components
目录下的文件足够多时,要花些功夫才能确认这两个文件的关联关系。
按组件划分目录结构
这种目录结构为每个组件都划分了一个独立、平级的目录,只要跟这个组件强相关,都往这个目录里招呼。这种设计出于两个考虑:
- React的基本开发单元是组件;
- 同一组件的相关代码要尽量共置(Colocation,这里翻译成“托管”不太合适)。
目录树结构的例子如下:
src
├── components
│ ├── App
│ │ ├── AdminContext.js
│ │ ├── App.css
│ │ ├── App.jsx
│ │ ├── App.test.jsx
│ │ ├── index.js
│ │ └── useFetchCards.js
│ ├── KanbanBoard
│ │ ├── KanbanBoard.css
│ │ ├── KanbanBoard.jsx
│ │ └── index.js
│ ├── KanbanCard
│ │ ├── KanbanCard.css
│ │ ├── KanbanCard.jsx
│ │ ├── KanbanNewCard.jsx
│ │ └── index.js
│ └── KanbanColumn
│ ├── KanbanColumn.css
│ ├── KanbanColumn.jsx
│ └── index.js
├── index.css
└── index.jsx
在每个目录中都有一个 index.js
,负责把当前目录的组件重新导出(Re-export)到目录外面去,这样其他组件在导入这个组件时,不需要关心目录里都有哪些实现,只关注作为入口的 index.js
就行。入口文件示意代码如下:
// src/components/KanbanCard/index.js
export { default as KanbanCard } from './KanbanCard.jsx';
export { default as KanbanNewCard } from './KanbanNewCard.jsx';
这种目录结构的优势在于,能为特定组件提供一定的封装性,在它专属的目录中能找到它强相关的所有代码。但它也有不足,面对一些跨组件复用的逻辑,可能会出现放到哪个组件目录都不太合适的窘境。
按业务功能划分目录结构
按业务功能划分目录结构,它与我们刚刚讲过的结构都不同,意味着目录划分的主要依据不再是具体框架中的某个具体技术概念(包括React的组件、Hooks、context,也包括Redux的action、reducer、store)。这使得按业务功能划分目录结构成为一个框架无关的方案,也就是说,其他框架的应用也可以利用这种目录结构。
目录树结构的例子如下:
src
├── features
│ ├── admin
│ │ ├── AdminContext.js
│ │ ├── AdminDashboard.jsx
│ │ ├── AdminSettings.jsx
│ │ └── index.js
│ ├── kanban
│ │ ├── KanbanBoard.jsx
│ │ ├── KanbanCard.jsx
│ │ ├── KanbanColumn.jsx
│ │ ├── KanbanNewCard.jsx
│ │ ├── index.js
│ │ └── useFetchCards.js
│ ├── login
│ │ ├── Login.css
│ │ ├── Login.jsx
│ │ ├── LoginForm.jsx
│ │ └── index.js
│ └── user
│ ├── Password.jsx
│ ├── UserProfile.jsx
│ ├── UserSettings.jsx
│ └── index.js
├── index.css
└── index.jsx
按业务功能划分目录结构可以说,它是这五种结构中最适合大中型React项目的。它既强调了相关源文件的共置,也在增加业务功能时具有良好的可扩展性。但它也具有与按组件划分目录结构类似的缺点,面对一些跨业务功能复用的逻辑,放在哪个业务目录下都不太合适。
如何选取合适的文件目录结构?
可以参考以下表格:
前端应用逻辑架构的功用
当提到React应用的整体逻辑时,不知你是否还记得第2节课这张应用逻辑架构图:
我工作早些年间,流行的开发流程是瀑布式开发(Waterfall Model),当时的概要设计阶段和详细设计阶段,对设计文档尤其是架构图的要求非常严格(我画的这种五颜六色的图……基本会被驳回重画)。近些年软件行业追求效率,敏捷开发已经成为主流,但敏捷开发并没有拒绝文档化,也绝不应该被当作拒绝架构设计的借口。
无论是否精确、美观,这样的架构图有助于我们把握项目的整体走向,对于大中型React项目而言是一个值得的先期投入。
也许你会问:“架构图应该是由架构师来画吧?”
我会这样理解这个事情:架构设计是一项工作、一项技能,而架构师是一个职位,两者间没有直接的等号。以我个人的经历举例,我参加工作后画的第一张架构图,那是在工作第二年。后来那张图有幸被用到了几场技术评审中,在与技术Leaders交流的过程中起了大作用,反过来也让我学到了很多。当然,这纯粹是我的个人理解,具体情况因人而异。
你的下一个问题大概是:“好的,我知道架构图有用了,那具体来说,画架构图对文件目录结构有什么用?”
因为前面提到了:
项目源码的文件目录结构并不等同于应用的整体逻辑,但却可以作为把握应用整体逻辑的一张“地图”。
那么应用逻辑架构图就可以当作是“地图”的“地图”。
下面马上来了解一下,我们为大中型React项目推荐的文件目录结构,也看看跟上面的应用逻辑架构图有什么样的对应关系。
大中型React项目推荐的文件目录结构
当React项目规模属于中型或大型时,文件目录结构需要满足以下几个目标:
- 便于横向扩展(即增加新功能点或视图);
- 易于定位相关代码;
- 鼓励代码复用;
- 有利于团队协作。
为了满足上面的目标,我推荐你以按业务功能划分为主,结合按组件、按文件职能的方式,划分目录结构。
参考的目录树结构(有省略)如下:
src
├── components
│ ├── Button
│ ├── Dialog
│ ├── ErrorBoundary
│ ├── Form
│ │ ├── Form.css
│ │ ├── FormField.jsx
│ │ ├── Form.jsx
│ │ └── index.js
│ ├── ...
│ └── Tooltip
├── context
│ ├── ...
│ └── UserContext.js
├── features
│ ├── admin
│ ├── dashboard
│ │ ├── activies
│ │ │ └── ActivityList.jsx
│ │ ├── charts
│ │ │ └── ...
│ │ ├── news
│ │ │ ├── news.png
│ │ │ ├── NewsDetail.jsx
│ │ │ └── NewsList.jsx
│ │ ├── Dashboard.css
│ │ ├── Dashboard.jsx
│ │ └── index.js
│ ├── kanban
│ │ ├── KanbanBoard.jsx
│ │ ├── index.js
│ │ └── useFetchCards.js
│ ├── home
│ ├── login
│ ├── ...
│ └── user
├── hooks
│ ├── ...
│ └── useLocation.js
├── servies
│ ├── kanbanService.js
│ ├── ...
│ └── userService.js
├── index.css
└── index.jsx
对应上面的例子,首先建立 features
目录, features
下面的一级目录都对应一个相对完整的业务功能,目录中有实现这一功能的各类代码。
对于部分体量比较大的功能,可以根据需要在一级目录下加入二级目录,每个二级目录都对应一个相对独立的子功能(业务),目录内部是实现子功能的各类代码。必要时还可以加入三级、四级目录,但总体目录层级不应过深。所以我们说,在 features
目录,可以从横向、纵向两个方向扩展功能点。
在 features
目录之外,为公用的代码建立一系列职能型的目录,包括可重用组件的 components
目录、可重用Hooks的 hooks
目录; context
目录的主要目的不是重用,而是跨业务功能使用context; services
目录下,集中定义了整个应用会用到的远程服务,避免四散到各个业务模块中,甚至硬编码(Hardcode)。这些公用代码的目录层级不宜太深,以一到二级为主。
从代码的导入导出关系来看,在 features
目录下,原则上同级目录间的文件不应互相导入,二级、三级目录只应被直接上一级目录导入,不能反过来被下一级目录导入。features
目录的代码可以导入公用目录的代码,反过来公用目录的代码不能导入features
目录的代码。在任何时候都应该避免循环导入(Circular Import)。
来对应一下前面的逻辑架构图:
- 图中的功能模块1、功能模块2再到功能模块m这一行,可以对应到features目录;
- 图中的服务器通信、前端路由、表单处理、错误处理这一行,可以分别对应到公用的services目录、hooks目录、components目录;
- 虽然在目录结构中没有体现,但用户认证、用户授权、前端监控等,也可以放到公用目录中实现。
这节课到目前为止讲到的目录结构,都是以单个React项目为前提的。
根据实际项目需要,也有很多React项目使用了多项目或者monorepo的方式来开发和扩展,虽然编译构建、CI/CD更加复杂了,但更有利于多个团队的协作,提高整体开发效率。在这样的实践中,可以把追加功能点到同一个React项目(或monorepo的包)看作纵向扩展,把特定模块、可复用组件和逻辑抽取为独立React项目(或monorepo的包)看作横向扩展。
模块导入路径过长怎么办?
在大中型React项目中,有时会遇到这样的import语句:
// src/a/b/c/d/e/f/g/h/MyComponent.jsx
import Dialog from '../../../../../../../../components/Dialog';
这种情况,首先要确定MyComponent.jsx是否真有必要放到这么深的路径下。如果发现这在项目中是个普遍情况,那可以利用Node.js的Subpath Imports功能(Vite中尚未支持),或是由前端构建工具提供的非标准的module别名(Alias)功能。
// Subpath Imports
import Dialog from '#components/Dialog';
// Alias
import Dialog from '@components/Dialog';
小结
今天这节课,我们了解了如何从整体层面应对大中型React应用逻辑的扩展,学习了5种典型的React项目文件目录结构,对比了它们各自的优劣势和适用项目规模。
然后学习了以按业务功能划分为主,结合按组件、按文件职能划分的目录结构的方式,来应对大中型React项目。同时也强调了前端应用逻辑架构对应用逻辑扩展的指导作用。
专栏的第三模块到目前为止,我们已经学习了大中型React项目的数据流、局部和整体逻辑的相关实践。
下节课我们会暂时从应用业务开发中跳出来,了解一下常见的React性能问题和优化方案。这些性能优化方案对各种规模的React应用基本都适用,目标都是保证优秀的用户体验。
思考题
- 其实在业界,React项目中经常会有一个名叫
common
的目录。如果在这节课里讲到的,大中型React项目推荐的文件目录结构中,设置一个这样的common
目录,你会往这个目录里放什么文件?不放什么文件?为什么? - 在第5节课的思考题中:
除了浏览器,你在电脑上最常用的桌面应用是什么?是不是macOS的Finder或Windows的资源管理器?
如果是的话就好办了。请你尝试把Finder或资源管理器当作要用React开发的Web应用,按自己的理解做一遍组件拆分。
现在已知Finder或资源管理器是大型React项目,请你为这个项目设计一套文件目录结构,记得把自己在第5节课设计的组件文件都放进去。
- 船长 👍(3) 💬(2)
模拟的 Finder 目录结构,原本想采用按【业务功能】拆分的结构的,但做的过程中发现项目比较简单,采用这种模式反而会复杂,于是采用了按【文件职能】拆分的结构,结构如下: YeahMyKanBan │ │ └── FinderSimulate │ │ ├── components │ │ │ ├── Column.tsx │ │ │ ├── CommonCard.tsx │ │ │ └── SystemOperate.tsx │ │ ├── context │ │ │ └── myContext.ts │ │ ├── index.tsx │ │ └── pages │ │ ├── HeaderMenu.tsx │ │ ├── LeftMenu.tsx │ │ └── MainContent.tsx 后面用了 vercel 进行了部署(codesanbox 部署 umi 项目有问题) 在线预览:🔗 https://react-learn2-orpin.vercel.app/YeahMyKanBan/FinderSimulate 源码地址:https://github.com/TanGuangZhi/ReactLearn/tree/main/src/pages/YeahMyKanBan/FinderSimulate
2022-10-18 - 船长 👍(1) 💬(1)
common 文件夹顾名思义是放公共文件、可复用逻辑的地方,比如网站的 Header,Footer,公共 util 等。 比如本次 Finder 项目的 左上角最大最小化按钮就应属于 common 目录的一部分,应为不止 Finder 用到了,macos 下所有的窗口都有这部分
2022-10-18 - 船长 👍(1) 💬(4)
在写这个 demo 的时候想起之前困惑的一个问题,想请教下宋老师,即在 jsx 中 <childComponent/> 与 childComponent() 这 2 种调用组件的方式有什么区别? 这是一个 demo,上面的输入框是用<childComponent/>这种方式调用的,在输入时会有个输入框失焦的问题,下面用childComponent() 调用的就没这个问题。 demo 地址:https://codesandbox.io/s/fervent-ishizaka-mwusdq?file=/App.tsx),
2022-10-18