尖叫架构 - React 文件夹结构的演变
React 文件夹结构……这个话题已经存在很久了。但 React 不带任何主观意见的管理方式仍然经常引发疑问:“我的文件应该放在哪里?我的代码应该如何组织?”说实话,即使我拥有多年的经验,我也会问同样的问题。
因此,我去了解了目前最流行的 React 项目组织方法。根据我的研究,这些方法如下:
- 按文件类型分组(例如,组件、上下文、钩子等的单独文件夹)
- 使用全局文件夹对页面进行分组,用于上下文、钩子等
- 按页面分组,并共置相关组件、上下文和钩子
- 按特征分组。
这篇文章反映了我对这些文件夹结构在不断增长的代码库中演变的观察,以及它们可能造成的问题。它还包含一些最佳实践的简短列表,以及一个挑战,将我即将开设的课程中的设计转化为基于功能的文件夹结构。
我们不会罗列所有细节,而是从全局角度进行讲解。换句话说:App.js
文件存放位置的重要性远不及整体的文件组织方式。
为了让这个故事更精彩,我们将跟随一家新创公司(略带讽刺)的历程,经历不同的阶段和不断增长的代码库。一个巧妙的想法:我们将构建下一个待办事项应用!
目录
原型:按文件类型分组
显然,我们对我们的初创企业有着宏伟的愿景。颠覆传统,征服世界,你懂的。但每个人都必须从小事做起。
所以我们先从React 文档开始。我们了解到,决定文件夹结构的时间不应该超过 5 分钟。好的,让我们快速盘点一下:
作为我们待办事项创业项目的第一个版本,一个简单的待办事项列表就足够了。这应该能帮我们拿到一些早期种子轮融资,你觉得呢?
对于这种情况,最简单的文件夹结构似乎是 React 文档中提到的“按类型分组文件”选项。这让我们的工作变得轻松:组件放在components
文件夹中,钩子放在hooks
文件夹中,上下文也放在contexts
文件夹中。而且,由于我们不是原始人,所以我们为每个组件创建一个文件夹,其中包含样式、测试以及其他内容。
└── src/
├── components/
│ │ # I'm omitting the files inside most folders for readability
│ ├── button/
│ ├── card/
│ ├── checkbox/
│ ├── footer/
│ ├── header/
│ ├── todo-item/
│ └── todo-list/
│ ├── todo-list.component.js
│ └── todo-list.test.js
├── contexts/
│ │ # no idea what this does but I couldn't leave this folder empty
│ └── todo-list.context.js
└── hooks/
│ # again no idea what this does but I couldn't leave this folder empty
└── use-todo-list.js
这看起来很简单。对于编程新手来说,这是一个很好的入门方法,无需过多思考。
但正如您所猜测的,这种情况不会持续太久。
投资:更多文件→嵌套
我们的待办事项应用运行良好,但资金不足。是时候吸引投资者了!这意味着我们需要展示进展。而展示进展的最佳方式就是添加新功能,对吧?
我们真是个天才,我们有个主意:为什么不支持编辑待办事项呢?太棒了!我们只需要一个表单来编辑待办事项,或许再加一个模态框来显示表单。
└── src/
├── components/
│ ├── button/
│ ├── card/
│ ├── checkbox/
│ │ # this modal shows a form to edit a todo item
│ ├── edit-todo-modal/
│ ├── footer/
│ ├── header/
│ ├── modal/
│ ├── text-field/
│ │ # here is the form that is shown by the modal
│ ├── todo-form/
│ ├── todo-item/
│ │ # the edit modal is shown on top of the todo list
│ └── todo-list/
│ ├── todo-list.component.js
│ └── todo-list.test.js
├── contexts/
│ ├── modal.context.js
│ └── todo-list.context.js
└── hooks/
├── use-modal.js
├── use-todo-form.js
└── use-todo-list.js
还好,但组件文件夹有点拥挤。类似checkbox
and text-field
(两个表单字段)或edit-todo-modal
and todo-form
(父子表单)等相关文件夹之间的距离太远,也有点烦人。
也许我们可以对组件进行分组和共置?
└── src/
├── components/
│ ├── edit-todo-modal/
│ │ ├── edit-todo-modal.component.js
│ │ ├── edit-todo-modal.test.js
│ │ │ # colocate -> todo-form is only used by edit-todo-modal
│ │ ├── todo-form.component.js
│ │ └── todo-form.test.js
│ ├── todo-list/
│ │ │ # colocate -> todo-item is only used by todo-list
│ │ ├── todo-item.component.js
│ │ ├── todo-list.component.js
│ │ └── todo-list.test.js
│ │ # group simple ui components in one folder
│ └── ui/
│ ├── button/
│ ├── card/
│ ├── checkbox/
│ ├── footer/
│ ├── header/
│ ├── modal/
│ └── text-field/
├── contexts/
│ ├── modal.context.js
│ └── todo-list.context.js
└── hooks/
├── use-modal.js
├── use-todo-form.js
└── use-todo-list.js
使用此文件夹结构,可以更轻松地概览重要功能。我们components
通过两种方式消除了文件夹中的杂乱:
- 通过将子组件与其父组件放在一起。
- 通过对文件夹中的通用 UI 和布局组件进行分组
ui
。
当我们折叠文件夹时,更清晰的结构变得显而易见:
└── src/
├── components/
│ ├── edit-todo-modal/
│ ├── todo-list/
│ └── ui/
├── contexts/
└── hooks/
增长:我们需要页面
我们的初创公司不断发展壮大。我们向公众发布了这款应用,并积累了一些用户。当然,他们很快就开始抱怨。最重要的是:
我们的用户想要创建自己的待办事项!
经过一番思考,我们找到了一个简单的解决方案:我们添加了第二个页面,用户可以通过表单创建待办事项。幸运的是,我们可以重用该表单来编辑待办事项。这太棒了,因为它节省了我们开发团队的宝贵资源。
无论如何,拥有自定义待办事项意味着我们需要一个用户实体和身份验证。由于待办事项表单现在将在“创建待办事项页面”和“编辑待办事项模式”之间共享,因此我们应该将其再次移至文件夹中components
。
└── src/
├── components/
│ │ # we now have multiple pages
│ ├── create-todo-page/
│ ├── edit-todo-modal/
│ ├── login-page/
│ │ # this is where the todo-list is now shown
│ ├── home-page/
│ ├── signup-page/
│ │ # the form is now shared between create page and edit modal
│ ├── todo-form/
│ ├── todo-list/
│ │ ├── todo-item.component.js
│ │ ├── todo-list.component.js
│ │ └── todo-list.test.js
│ └── ui/
├── contexts/
│ ├── modal.context.js
│ └── todo-list.context.js
└── hooks/
│ # handles the authorization
├── use-auth.js
├── use-modal.js
├── use-todo-form.js
└── use-todo-list.js
你现在觉得文件夹结构怎么样?我发现了一些问题。
首先,components
文件夹又变得拥挤了。但不可否认,从长远来看,我们无法避免这种情况。至少如果我们想保持文件夹结构比较扁平的话。所以,我们先忽略这个问题。
其次(更重要的是),该components
文件夹包含不同类型的组件:
- 页面(这是应用程序的入口点,因此对于新开发人员了解代码库很重要)
- 具有潜在副作用的复杂组件(例如表单)
- 以及按钮等简单的 UI 组件。
解决方案:我们创建一个单独的pages
文件夹。我们将所有页面组件及其子组件都移动到那里。只有在多个页面上显示的组件才会保留在该components
文件夹中。
└── src/
├── components/
│ │ # the form is shown on the home and create todo page
│ ├── todo-form/
│ │ # we could also ungroup this folder to make the components folder flat
│ └── ui/
├── contexts/
│ ├── modal.context.js
│ └── todo-list.context.js
├── hooks/
│ ├── use-auth.js
│ ├── use-modal.js
│ ├── use-todo-form.js
│ └── use-todo-list.js
└── pages/
├── create-todo/
├── home/
│ ├── home-page.js
│ │ # colocate -> the edit modal is only used on the home page
│ ├── edit-todo-modal/
│ └── todo-list/
│ ├── todo-item.component.js
│ ├── todo-list.component.js
│ └── todo-list.test.js
├── login/
│ # don't forget the legal stuff :)
├── privacy/
├── signup/
└── terms/
对我来说,这看起来干净多了。当新开发人员加入公司时,他们现在可以轻松识别所有页面。这为他们提供了一个调查代码库或调试应用程序的入口。
这似乎是许多开发人员常用的文件夹结构。以下是两个示例:
- Tania Rascia 建议采用类似的文件夹结构并进行更详细的介绍。
- Max Rozen 使用类似的文件夹结构,但附加了一些指导原则。
但既然我们创业的目标是征服世界,我们显然不能就此止步。
统治世界:主机托管
我们已经发展成为一家真正意义上的公司。它是全球最受欢迎的待办事项应用(根据它的五星评价)。每个人都想投资我们的初创公司。我们的团队不断发展壮大,代码库也随之壮大。
└── src/
├── components/
├── contexts/
│ ├── modal.context.js
│ ├── ... # imagine more contexts here
│ └── todo-list.context.js
├── hooks/
│ ├── use-auth.js
│ ├── use-modal.js
│ ├── ... # imagine more hooks here
│ ├── use-todo-form.js
│ └── use-todo-list.js
└── pages/
抱歉,我创意用完了。你懂的:全局hooks
和contexts
文件夹太拥挤了。
与此同时,更复杂组件的代码仍然分散在多个文件夹中。组件可能位于pages
文件夹中的某个位置,使用文件夹中的共享组件components
,并依赖于contexts
和hooks
文件夹中的业务逻辑。随着代码库的不断增长,这使得追踪文件之间的依赖关系变得更加困难,并导致代码相互交织。
我们的解决方案:共置!只要有可能,我们就会将上下文和钩子移动到它们使用到的组件旁边。
└── src/
├── components/
│ ├── todo-form/
│ └── ui/
├── hooks/
│ │ # not much left in the global hooks folder
│ └── use-auth.js
└── pages/
├── create-todo/
├── home/
│ ├── home-page.js
│ ├── edit-todo-modal/
│ └── todo-list/
│ ├── todo-item.component.js
│ ├── todo-list.component.js
│ ├── todo-list.context.js
│ ├── todo-list.test.js
│ │ # colocate -> this hook is only used by the todo-list component
│ └── use-todo-list.js
├── login/
├── privacy/
├── signup/
└── terms/
我们移除了全局contexts
文件夹。可惜的是,文件没地方放,use-auth
所以全局hooks
文件夹暂时保留了。没什么特别的,但全局文件夹越少越好。它们很快就会变成垃圾场。
这种文件夹结构最重要的优点是:我们可以一次性掌握属于某个功能的所有文件。无需为了查找单个组件的代码而翻遍 5 个不同的文件夹。
但与此同时,也存在一些问题:
- 与“todo”实体相关的代码分散在多个文件夹中。一旦我们开始添加更多实体,就会变得有点混乱。
- 仅从文件夹结构来看,您是否会猜测该
todo-list
组件位于该文件夹中?home
└── src/
├── components/
├── hooks/
└── pages/
├── create-todo/
├── home/
├── login/
├── privacy/
├── signup/
└── terms/
退出:按功能分组
我们的梦想成真了:我们即将以数十亿美元的价格出售我们的初创公司。我们创造了一只独角兽🦄FAANGT。
但成功伴随着责任:我们的用户再次要求新功能。最重要的是,他们希望创建不同的项目,以便将工作待办事项与购物清单上的待办事项分开。谁能想到呢……
我们的解决方案:我们添加一个包含待办事项列表的新“项目”实体。
我们决定添加两个新页面。一个用于创建项目,另一个用于显示项目及其待办事项。主页也需要更改。它应该显示所有项目列表以及所有待办事项列表。
这意味着该todo-list
组件现在在两个页面上使用,因此必须将其移动到公共components
文件夹
└── src/
├── components/
│ ├── todo-form/
│ │ # is now shared between home and project page
│ ├── todo-list/
│ │ ├── todo-item.component.js
│ │ ├── todo-list.component.js
│ │ ├── todo-list.context.js
│ │ ├── todo-list.test.js
│ │ └── use-todo-list.js
│ └── ui/
└── pages/
├── create-project/
├── create-todo/
│ # shows now a list of projects and an overview of all todos
├── home/
│ ├── index.js
│ ├── edit-todo-modal/
│ └── project-list/
├── login/
├── privacy/
│ # shows a list of todos belonging to a project
├── project/
├── signup/
└── terms/
这看起来仍然很干净。但我发现两个问题:
- 查看
pages
文件夹,无法立即确定该应用包含待办事项、项目和用户。我们可以理解这些内容,但首先需要处理文件夹名称,例如create-todo
(待办事项实体)或login
(用户实体),并将它们与不重要的内容(例如隐私和条款)区分开来。 components
某些组件仅仅因为在多个页面上使用就放在共享文件夹中,这感觉有点武断。你需要知道某个组件在何处使用以及在多少地方使用,才能知道在哪个文件夹中可以找到它。
这时你可能会想:在 IDE 的帮助下,只需按文件名打开文件即可(例如,在 VS Code 中按“Ctrl + P”)。没错。但如果你一开始就记不住文件名,那就没什么用了。所以在我看来,如果能用多种方式浏览代码库总是好的。
让我们最后一次调整文件夹结构并按功能对文件进行分组。
“功能” 是一个相当宽泛的术语,您可以自由选择它的含义。在本例中,我们选择实体(todo
、project
和user
)的组合,以及一个ui
包含按钮、表单字段等组件的文件夹。
└── src/
├── features/
│ │ # the todo "feature" contains everything related to todos
│ ├── todos/
│ │ │ # this is used to export the relevant modules aka the public API (more on that in a bit)
│ │ ├── index.js
│ │ ├── create-todo-form/
│ │ ├── edit-todo-modal/
│ │ ├── todo-form/
│ │ └── todo-list/
│ │ │ # the public API of the component (exports the todo-list component and hook)
│ │ ├── index.js
│ │ ├── todo-item.component.js
│ │ ├── todo-list.component.js
│ │ ├── todo-list.context.js
│ │ ├── todo-list.test.js
│ │ └── use-todo-list.js
│ ├── projects/
│ │ ├── index.js
│ │ ├── create-project-form/
│ │ └── project-list/
│ ├── ui/
│ │ ├── index.js
│ │ ├── button/
│ │ ├── card/
│ │ ├── checkbox/
│ │ ├── header/
│ │ ├── footer/
│ │ ├── modal/
│ │ └── text-field/
│ └── users/
│ ├── index.js
│ ├── login/
│ ├── signup/
│ └── use-auth.js
└── pages/
│ # all that's left in the pages folder are simple JS files
│ # each file represents a page (like Next.js)
├── create-project.js
├── create-todo.js
├── index.js
├── login.js
├── privacy.js
├── project.js
├── signup.js
└── terms.js
请注意,我们index.js
在每个文件夹中都引入了文件。这些文件通常被称为模块或组件的公共 API。如果您不明白这是什么意思,可以在下面找到更详细的解释。
但首先,让我们讨论一下新的“按功能分组”文件夹结构。
讨论:功能驱动的文件夹结构和尖叫架构
鲍勃·马丁 (Bob Martin)在他的文章《尖叫的建筑》中说道:
你的架构应该告诉读者系统本身,而不是你系统中使用的框架。如果你正在构建一个医疗保健系统,那么当新程序员看到源代码库时,他们的第一印象应该是:“哦,这是一个医疗保健系统。”
让我们回忆一下我们最初的文件夹结构,其中我们按类型对文件进行分组:
└── src/
├── components/
├── contexts/
└── hooks/
这是否透露了一些关于系统或框架的信息?这个文件夹结构仿佛在喊:“我是一个 React 应用。”
我们最终的功能驱动文件夹结构怎么样?
└── src/
├── features/
│ ├── todos/
│ ├── projects/
│ ├── ui/
│ └── users/
└── pages/
├── create-project.js
├── create-todo.js
├── index.js
├── login.js
├── privacy.js
├── project.js
├── signup.js
└── terms.js
我们不知道使用了哪个框架。但这个文件夹结构会跳出来,让你尖叫:“嘿,我是一个项目管理工具。”
这看起来很像鲍勃叔叔所描述的。
除了描述性架构之外,features
还pages
为开发人员提供了应用程序的两个不同入口点。
- 如果我们需要更改某个组件,并且只知道它在主页上,则打开
pages/home.js
并单击参考资料。 - 如果我们需要更改
TodoList
但不知道它在哪里使用,我们只需打开features/todo
文件夹就会在里面的某个地方找到它。
最后,我们移除了全局contexts
和hooks
文件夹。如有需要,我们仍然可以重新引入它们。但至少目前,我们移除了这些潜在的垃圾场。
我个人对这个文件夹结构非常满意。我们可以继续改进一下功能模块内的文件夹结构。例如,这个todo
文件夹目前看起来有点乱。Alan Alickovic 在他出色的示例项目 Bulletproof React 中建议,应该按文件类型对每个功能模块内的文件进行分类(就像我们一开始做的那样)。
但在我看来,我们目前的文件夹结构足够清晰,描述性也足够强。由于“功能”本身就自成体系,因此在必要时应该很容易进行重构。同时,我们的文件夹结构也足够简单,可以从一开始就在项目中使用。从长远来看,这可能会为我们省去一些麻烦。
根据我的经验,许多项目的开发方式与本页所述类似。但由于时间紧迫,开发人员根本没有机会清理文件夹结构。因此,项目最终会陷入各种不同方法的混乱之中。从功能驱动的文件夹结构入手,有助于长期保持应用的整洁。
如果您想深入了解功能驱动的文件夹结构,这里列出了更多资源:
- Bulletproof React 的源代码及其有关文件夹结构的文档。
- Swyx 的一条推文。
- Kolby Sisk 发表了一篇很棒的博客文章,其中详细介绍了这一点。
- Robin Wieruch 的一篇博客文章启发了本文的灵感(尽管 Robin 似乎更喜欢按类型对文件进行分组)。
- Feature Sliced - 一种用于前端项目的架构方法(遗憾的是英文版本不完整)。
最佳实践
绝对进口
假设我们想在文件内的待办事项列表组件中渲染一个按钮features/todo/todo-list
。默认情况下,我们会使用相对导入:
import { Button } from "../../ui/button";
...
管理相对路径../..
可能会很麻烦,尤其是在重构过程中移动文件时。而且,确定..
需要多少个文件也很快变成了一种猜测。
作为替代方案,我们可以使用绝对导入。
import { Button } from "@features/ui/button";
...
现在,无论将组件移动到哪里都无所谓了TodoList
。导入路径始终保持不变。
使用 Create React App 设置绝对导入非常容易。只需添加一个jsconfig.json
文件(或tsconfig.json
TypeScript)并定义paths
别名:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@features/*": ["src/features/*"],
}
}
}
您可以在此处找到有关 React 的更详细演练,在此处找到有关 Next.js 的更详细演练。
index.js 作为公共 API
在最终的文件夹结构中,我们index.js
为每个功能和组件文件夹添加了一个。这里简单提醒一下:
└── src/
├── features/
│ ├── todos/
│ │ │ # this is used to export the relevant modules aka the public API
│ │ ├── index.js
│ │ ├── create-todo-form/
│ │ ├── edit-todo-modal/
│ │ ├── todo-form/
│ │ └── todo-list/
│ │ │ # the public API of the component (exports the todo-list component and hook)
│ │ ├── index.js
│ │ ├── todo-item.component.js
│ │ ├── todo-list.component.js
│ │ ├── todo-list.context.js
│ │ ├── todo-list.test.js
│ │ └── use-todo-list.js
│ ├── projects/
│ ├── ui/
│ └── users/
└── pages/
如上所述,这些index.js
文件通常被称为模块或组件的公共 API。
但这意味着什么?
以下是文件夹中索引文件的示例features/todo/todo-list
:
import { TodoList } from "./todo-list.component";
import { useTodoList } from "./use-todo-list";
export { TodoList, useTodoList };
该文件只是导入和导出了一些模块。以下是一个更简短的版本:
export { TodoList } from "./todo-list.component";
export { useTodoList } from "./use-todo-list";
该文件feature/todo/index.js
只是从其子文件夹中导出所有内容。
export * from "./create-todo-form";
export * from "./todo-list";
// ... and so on
这对我们有什么帮助?
假设你想TodoList
在文件内部渲染组件pages/home
。与其像这样从嵌套文件夹导入,不如
import { TodoList } from "@features/todo/todo-list/todo-list.component";
...
我们可以直接从待办事项功能导入。
import { TodoList } from "@features/todo";
...
这有几个好处:
- 看起来更漂亮。
- 开发人员不需要知道功能的内部文件夹结构即可使用其组件之一。
- 您可以定义要向外部公开哪些组件等。只有索引文件中导出的内容才能在应用程序的其他部分使用。其余部分是内部/私有的。因此,我们称之为“公共 API”。
- 只要公共 API 保持不变,您就可以移动、重命名或重构功能文件夹内的所有内容。
文件和文件夹名称采用短横线命名法
和许多其他人一样,我习惯用 PascalCase 命名组件文件(例如MyComponent.js
),用 camelCase 命名函数/钩子(例如useMyHook.js
)。
直到我换了一台 MacBook。
在一次重构过程中,我将一个名为 的组件文件重命名myComponent.js
为正确的格式MyComponent.js
。一切在本地运行正常,但不知何故,GitHub 上的 CI 开始报错。它声称下面的导入语句有问题。
import MyComponent from "./MyComponent";
事实证明,MacOS 默认文件系统不区分大小写。MyComponent.js
和myComponent.js
是同一个东西。所以 Git 从未检测到文件名的更改。不幸的是,GitHub 上的 CI 使用的是 Linux 镜像。而这个镜像是区分大小写的。所以根据我的 CI,该文件不存在,而我的本地机器却显示一切正常。
我花了好几个小时才明白这一点。显然,我不是唯一遇到这个问题的人:
解决方案:使用短横线命名法 (kebab-case) 来命名文件和文件夹。例如:
- 而不是
MyComponent.js
写my-component.js
。 - 而不是
useMyHook.js
写use-my-hook.js
。
Next.js 默认使用这种命名方式。Angular已将其纳入其编码样式指南。我不明白为什么不使用短横线命名,但它或许能帮你或你的队友省去一些麻烦。
挑战:您将如何根据此设计构建项目?
这是我即将开设的课程中针对 Web 应用程序(例如 Sentry)的错误日志记录工具的设计。
- 这个应用程序的基础实体是“组织”。
- 每个组织都有分配给它的项目和用户。
- 每个项目都有问题(例如从组织网站发送的错误)。
- 左侧导航中的每个顶部项目代表一个页面。
如何将此设计转变为基于功能的文件夹结构?(您可以在下面找到我的解决方案。不要偷看。)
………………………………
└── src/
├── features/
│ ├── alerts/
│ ├── issues/
│ │ # this contains the settings
│ ├── organization/
│ ├── projects/
│ │ ├── index.js
│ │ ├── project-card.js
│ │ └── project-list.js
│ ├── ui/
│ │ ├── index.js
│ │ ├── card/
│ │ ├── header/
│ │ ├── footer/
│ │ ├── side-navigation/
│ │ └── tag/
│ └── users/
└── pages/
├── alerts.js
├── issues.js
├── projects.js
├── settings.js
└── users.js