跳转至

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

按文件职能划分目录结构的优点在于,可以快速定位任何一种类型的源码,在源码之间导入导出也比较方便:

// src/components/App.tsx
import AdminContext from '../context/AdminContext';

当其中某个或者某几个目录中的文件数不断增多时,这种结构的缺点就暴露出来了:不容易定位到直接相关的源文件。比如 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应用基本都适用,目标都是保证优秀的用户体验。

思考题

  1. 其实在业界,React项目中经常会有一个名叫 common 的目录。如果在这节课里讲到的,大中型React项目推荐的文件目录结构中,设置一个这样的 common 目录,你会往这个目录里放什么文件?不放什么文件?为什么?
  2. 第5节课的思考题中:

除了浏览器,你在电脑上最常用的桌面应用是什么?是不是macOS的Finder或Windows的资源管理器
如果是的话就好办了。请你尝试把Finder或资源管理器当作要用React开发的Web应用,按自己的理解做一遍组件拆分。

现在已知Finder或资源管理器是大型React项目,请你为这个项目设计一套文件目录结构,记得把自己在第5节课设计的组件文件都放进去。

精选留言(3)
  • 船长 👍(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