百分百正确的 React 应用结构?没这回事!

David Gilbertson 原作,授权 New Frontend 翻译。

理想的 React 应用结构让浏览代码便利无比。 我将在这篇文章介绍如何安排 React 应用结构,以及我决策的依据。我会提到一些我没有使用的选项,因为它们不适合我,但这些选项也许对你有用。 我也想知道你是怎么做的,欢迎留言评论。

什么最适合你

也许你早就清楚这一点,但我最近才领悟到:应用的结构和计算机无关。

想象一下,一个应用只有一个文件,组件、reducer、存储、工具函数等等所有的一切都在一个文件里。

这当然是一个糟糕的想法。不过现在我们需要想一想为什么这样做不好。

好吧,我知道你不会真的停下来思考这个问题,所以让我直接告诉你我是怎么想的。使用一个巨大文件的问题在于,浏览起来很麻烦。不过,如果每块代码都加上书签呢?比如每个函数都有一个书签?也许我们需要多级书签?如果有一个所有书签的目录呢?

也许这看起来像是一个思想实验,但我认为这确定了一件事:选择文件结构时需要考虑的唯一一件事是最大化浏览代码的便利程度。「文件」只不过是是为了标记代码的不同部分。毕竟,这些代码最终会被打包成一个 JS 文件。

这正是「我的应用最佳结构是什么?」这个问题永远没有一个标准答案的原因。它取决于你自己的习惯和偏好,所以别人没法替你回答。

为了得出最适合我自己的应用结构,我决定评估自己最频繁的代码相关活动:

  • 创建新组件。 通常是复制/粘贴一些现存组件。
  • 在一个模块中引入另一个模块。 我指的是输入 import { SomeComponent } from '../blah/de/blah.js'; 之类的语句。
  • 在源码间跳转。 其实这一点本来没啥可解释的,不过强迫症晚期的我想让这个列表中的每一项都有一致的格式,所以我就把我脑子里正在想的东西敲出来好了:我要打开前面提到的那个 header nav 组件,那么我就要使用一个键盘快捷键,输入文件名,然后打开文件。唉,我原本不需要写这些东西的,结果却把这一点弄成了整个列表里最长的一项。
  • 打开已知文件。 使用键盘快捷键,输入文件名,打开文件。
  • 浏览不知道名字的文件。 也许我过去没有动过用户照片下拉菜单组件,也不知道它到底叫什么。所以我需要浏览目录找到这个组件。
  • 跳转到另一个打开的文件。 我现在打开了 7 个文件,想要点击标签名称或使用键盘快捷键,从一个标签跳转到另一个标签。

接下来,我需要考虑做这些事情的频繁程度。我统计了去年创建的组件数,平均引入数,其他数值则进行了一些大胆的猜测,最终得到如下结果:

每周频次

有了这些数据后,我就可以客观地审视组织一个 React 应用的各个方面了。下面我们逐一考虑每个方面。

目录结构

一般而言,如果一个模块(工具、组件)仅用于另一个模块,那么我会使用嵌套结构,就像这样:

嵌套目录结构

<HeaderNav> 仅用于 <Header> 组件,所以位于 <Header> 之下。而 <Button> 可能出现在任何地方,所以把它放在顶层。

这算是一条相当好的规则,不过,我也清楚,遵循一套超级严格的规则可能很麻烦。从技术上说,一切组件都在 AppPage 之下。但我并不打算在我的目录结构中表示这一点,因为我不想这么干。

这个乍看起来很轻率的做法,其实符合一个相当基本的原理。如果遵循自己的规则会形成一个难以浏览的结构,那么你已经迷失了方向。

我认为,组件以外的目录结构不是特别重要。你可能会为 reducer 和 action creator 应不应该和服务放在一个文件夹里而苦恼万分。要我说,使用基本的结构、合理的目录名(actionCreators、reducers、data 等)就可以。

这可能是我们需求不同的第一个地方。我很少通过浏览目录结构来打开文件,自然觉得目录结构不是特别重要。我也从未接触过包含超过数百个组件的项目。

如果你更依赖于浏览目录结构,或者你媲美 Facebook 的项目有三万个组件,那么你的需求可能有所不同。

还有一件事:我建议以全局唯一的全称命名组件。例如,HeaderNavHeader 内部,所以你可能主张可以命名为 Nav。如果你觉得合适,那么这是一种很酷的命名方式。但我会输入文件名称打开文件,也会查看标签页的名称切换文件。在这两种情况下,使用全称很有用。

当然,如果你遵循 BEM 风格,那么你总是需要全局唯一的组件名称。

容器组件怎么办?

容器组件有点棘手,某种意义上说它们算组件,某种意义上说不算。

可以把对待容器组件的方式粗略分成两类:

  1. 像展示组件一样对待
  2. 放在目录结构以外,位于幕后,只为组件提供数据。

第一种情况, 会在标记中实际引用这些容器组件。例如,一个页首组件可能具备自身的容器,像这样:

import React from 'react';
import HeaderContainer from './HeaderContainer/HeaderContainer';
import Page from './Page/Page';
import Footer from './Footer/Footer';

const App = (props) => (
  <div>
    <HeaderContainer />

    <Page data={props.pageStuff} />

    <Footer {...props.propsRelevantToFooter} />
  </div>
);

export default App;

这里我会向 <Page><Footer> 组件传入一些特定的数据,而很明显 <HeaderContainer> 会自行处理所需数据。

如果代码是这样组织的,那么符合逻辑的结构应该类似这样:

HeaderContainer 结构

第二个选项是把容器组件置于结构之外;将它们视为封装了相应组件的实现细节。

因此 <Header> 也许会在导出时将自己封装进容器组件,就像这样:

import React from 'react';
import headerContainer from './headerContainer';

export const Header = () => (
  <header>
    Just header stuff
  </header>
);

export default headerContainer(Header);

然后这样引用:

import React from 'react';
import Header from './Header/Header';
import Page from './Page/Page';
import Footer from './Footer/Footer';

const App = (props) => (
  <div>
    <Header />

    <Page data={props.pageStuff} />

    <Footer {...props.propsRelevantToFooter} />
  </div>
);

export default App;

这一做法的缺点在于, <Header /> 的数据由其他地方提供,这一点不是非常清楚。好处是容器组件在组件层级里浅了一层。

如果代码是这样组织的,那么容器组件可以和其提供数据的组件放在同一目录下:

另一种 HeaderContainer 结构

(注意,我在默认导出封装进容器的 Header 之外,还导出了「原始」Header,以供单元测试使用。linter 也许会提示你导出了一个和文件同名的非默认常量。我倾向于认为 linter 的提示没错。)

我在一个中等规模的项目中采用了第一种方式,效果相当不错。最近我在一个新项目中尝试了第二种方式(新项目只有 6 个容器组件),感觉不太顺。所以我会坚持使用第一种方式。

不过我觉得这两种方式都没有什么问题。

附注:意识到在什么时候说「你知道,其实它并不重要」,然后继续你的生活,这是一项精巧的艺术。

自包含组件

我的规则:如果项目包含的组件数超过了克莱因瓶的面数,那我会把每个组件以及相应的 CSS 和测试文件放在一个单独的目录中。这条规则的流行自有其道理,不过,即使你把所有相关文件整齐地放在同一目录下,你仍有可能犯下大错……

看下你最近使用的包含组件的文件。在文件开头,你应该会看到引入其他组件的语句,这其实是依赖文件的一张清单。

除非你在组件之间共享 CSS 类,这些 CSS 类会成为未列出的依赖。

没错,.modal-wrapper 已经提供了你需要的阴影效果,在你的 <DropDown> 组件中直接使用这个类能为你省下 7 秒,然而,你知不知道这么做会给未来的自己带来多少烦恼?

(我删除了 14 个自然段,这些自然段论述为什么这会是一个坏主意。(译者注:作者原文如此,并非翻译时省略。))

尝试劝说一些人不要在组件间共享 CSS 类就像劝说人们「避免在 JavaScript 中使用全局变量」或者「给鸡接种疫苗」——有些家伙就是不想听。

毫无疑问,CSS 模块的用户看到这里尾巴能翘上天,他们如此高兴不无道理—— CSS 模块强制明确引入 CSS 类。如果你担忧 CSS 和组件耦合过密,那你也应该使用 CSS 模块。

文件命名

我觉得有一条规则特别有用:

文件命名和导出项保持一致

对有些人而言,这是一条显而易见、不值一提的规则。但我看到有很多代码并没有遵循这条规则,给浏览代码造成了很多不便。

(别忘了,这些全都是个人意见。当我说「浏览起来很不方便」的时候,你完全可能觉得「对我来说这样一点不慢」,因此决定文件命名和默认导出项保持一致毫无意义。)

我频繁进行的一个操作是通过输入文件名来打开文件。如果我有一个名为 toString 的工具函数,那我完全可以期望有一个名为 toString 的文件,我输入 toString 就可以打开这个文件。另一个我频繁进行的操作是切换标签页,因此我期望标签页会显示 toString.js

像下面这样的目录结构,我觉得非常有必要回炉重造一下:

所有目录内的文件都是 index.js、style.scss、test.js

有些人乐意这样工作,我深感震惊:

所有标签页都显示 index.js、style.scss、test.js

即使你的 IDE 十分机智地在标签页为非唯一的文件名同时显示目录名,这仍然显示了很多冗余信息,挤占了宝贵的屏幕空间,只能显示很少几个标签页名称,同时你仍旧不能仅仅输入文件名来打开文件。我都不需要实际尝试一番,就知道自己不会喜欢这种做法。这就像我表弟最近着迷的「质谱仪」乐队,我都不需要实际去看演出就知道自己不会喜欢这个乐队,所以表弟没可能说服我开车和他一起去观摩这个「实际上不属于任何流派」的乐队的演出。

话是这么说,我理解这么做不是没有理由的——引入语句可以写成这样:

import Link from '../Link';

而不是

import Link from '../Link/Link';

很明显这里需要权衡一下。到底是偏向更短的引入语句,还是偏向文件名与导出项命名保持一致。

现在让我来算一下……我平均每周需要写 18 个引入语句,平均输入 840 次文件名来打开文件,平均查看标签页名称 1892 次。

所以我会选择在引入模块的路径中增加一个单词,何况引入的时候还能使用自动补全。

机智的读者读到这里会在屏幕前大喊:有两种方案可以让文件名和输出项一致,而不需要在引入语句中输入两次。

第一个方案是在每个需要导出组件的目录中放上一个 index.js 文件,就像这样:

组件的 index 文件

因为 Node 会在解析引入路径时查找 index.js 文件,所以路径 ../Link 其实表示 ../Link/index.js,这个 index.js 是一个指向实际组件的文件。

如果在引入语句中少打几个字对你来说意义重大,那么也许在目录下放上一个额外的文件是个好主意。我认为这笔交易并不划算,不过让我再强调一次,在这件事上有不同意见完全没有问题。

第二个「方案」比较诡异:

使用 package.json

看到这里你会意识到如果 Node 没找到 ../Link/index.js,它会检查 ../Link/package.json 是否存在。如果存在,会解析到 main 属性的值。

我觉得如果你都到了为每个组件创建一个 package.json 文件的地步,那你一定恨透了在引入语句中多打一个词。这一思路过于清奇。你的代码越诡异,那么你这个人也就越……

这两种「重定向」文件方案也意味着引入语句不再指向包含实际定义的文件。

这在鸿蒙时代意味着「跳转到源代码」无法工作,对于我而言,能够快捷方便地浏览代码,这一跳转功能至关重要。WebStorm 很智能,可以识别这些中转点(它「知道」我并不想跳转到 index.js 文件,我想跳转到 Link.js 文件)。不过,如果你的文本编辑器没有这么智能,可能会导致最终打开了一大堆 index.js 文件,甚至也许跳转到源代码都不能工作。

所以在选择这些方案前,先尝试一番,看看会不会妨碍你现在的工作方式。

.js 和 .jsx 扩展名

直到最近为止,任何包含 JSX 的文件我都用 .jsx 扩展名,只在原生 JavaScript 文件上使用 .js 扩展名。在打开和查看文件时,这一命名方式很好地区分了两者。这也带来一项额外的福利,在 GitHub 上查看时,能够正确高亮 JSX 语法。

然而,Facebook 建议不要使用 .jsx 扩展名,所以我最近开始统一使用 .js。我很高兴自己没有浪费太多时间权衡利弊,因为这对我而言没什么差别。

我建议根据丢硬币的结果决定是否统一使用 .js

工具类入口文件

在写这篇文章的过程中,我仔细思考了什么对我来说比较重要。所以实际上我在应用结构的个人偏好上进行了一项小调整。

我以前会为工具函数创建 index.js 文件,像这样:

工具 index.js

这样我就可以一下子引入多个工具函数:

import {
  formatDate,
  getAtPath,
  toNumber,
  toString,
} from '../../../../utils';

井井有条!

当我添加一个新工具函数的时候(平均每周 0.8 次),我只需增加包含工具函数的文件,并在 index 文件中添加一条记录。当我看到添加工具函数而忘了在 index.js 中新增记录的 PR 时,我会提醒开发者加上记录。偶尔我也会发现 index.js 没有包含的工具函数,这时我会自己加上。多么优雅的方案!

直到 2017 年 9 月我才意识到这仅仅增加了复杂性。实际上,抛弃 index.js 效果更好:

import formatDate from '../../../../utils/formatDate';
import getAtPath from '../../../../utils/getAtPath';
import toNumber from '../../../../utils/toNumber';
import toString from '../../../../utils/toString';

代码行数减少了,也少了一个文件,需要向新开发者解释的事情也少了一件。

不过这些长长的引入路径比较辣眼睛,所以让我们来看下有没有什么解决方案。这种耦合的问题经常会有两种解决方案,这次也不例外。

方案一是使用 Webpack 的别名解析,这样不需要相对路径就可以引用工具目录。我把 src/app/utils 映射为 Utils。结果很不错,和引入其他工具的方式一致。

像引入 lodash 一样引入 Utils

这里大写了 U,以便和 npm 包区别开来。

在鸿蒙时代,这么做会迷惑某些文本编辑器,因为它们不知道 Utils/formatDate 是什么,在哪里。我的 IDE 很智能,会读取 Webpack 配置(实际上它会在幕后运行 webpack),能够连接到正确的文件(所以我能够进行跳转到源代码、自动补全等操作)。

所以,这是一个美好、整洁的方案。不过,幕后是怎么样的呢?

/*  --  webpack.config.shared.js  --  */
export const sharedConfig = {
  alias: {
    'Utils': path.resolve(__dirname, '../src/app/utils/'),
    'Components': path.resolve(__dirname, '../src/app/components/'),
  },
};
  

/*  --  webpack.config.dev.js  --  */
import { sharedConfig } from './webpack.config.shared.js';

const config = {
  // development config
  resolve: {
    alias: sharedConfig.alias,
  },
};


/*  --  webpack.config.prod.js  --  */
import { sharedConfig } from './webpack.config.shared.js';

const config = {
  // production config
  resolve: {
    alias: sharedConfig.alias,
  },
};


/*  --  SomeComponent.js  --  */
import toNumber from 'Utils/toNumber';
import toString from 'Utils/toString';

这是一个很不错的方案,但是它有两个缺点:

  1. 它增加了复杂度。我们需要使配置更多东西得到同样的输出。
  2. 它降低了清晰度。不熟悉 Webpack 配置的人没法通过查看导入语句知晓它指向哪个文件。

方案二是说服自己,不必在意那些点儿,这样挺好。

为了说服自己,我考虑了下输入这些引入路径有多频繁,结果发现和我泡咖啡的频率差不多。接着我就开始想,在机器里放入咖啡胶囊,量取一茶勺糖放进杯子,叫来黛西挤点奶(译者注:黛西是常见的奶牛名),按下绘有咖啡杯图案的按钮,和这些相比,输入八个点和五条斜杠真不是什么难事。

这两个选项的利弊很有代表性,在生活和编程中,很多不同的决策都面临类似的问题,所以这也许是我的清晰/晦涩和简单/复杂矩阵登场的良机。

ClObSiCo 矩阵

对我而言,在这两种方案间选一个很困难。不过最后我还是决定尽可能地偏向清晰度和简单性。所以尽管引入语句中的 ../../../../ 比较刺眼,从保持清晰简单的角度而言,这是较优的方案。

组件类入口文件

这不是我的菜,不过你也许会在组件上碰到同样的引入语句中点太多的问题。

也许这能为你提供加速度:

import React from 'react';
import {
  Button,
  Footer,
  Header,
  Page,
} from 'Components';

你已经知道如何通过配置 Webpack 做到这一点:

const config = {
  // other stuff
  resolve: {
    alias: {
      'Components': path.resolve(__dirname, '../src/app/components/'),
    },
  },
};

然后在你的组件目录添加一个 index.js 文件,列出每个组件,就像这样:

组件 index.js

搞定!

一个文件多项导出

大部分情况下,一个文件只需要一个输出项(文件名称与输出项名称一致)。我觉得对组件和工具函数而言,这是一条很好的一般规则。

不过我认为这不适用于常量。我也喜欢把所有 action creator 放在一个文件中(直到数量太多造成不便为止)。reducer 同理。根据我的经验,在单个文件中写上 8 个 10 行的 reducer 和使用 8 个不同的文件没有太大的差别。

如果你认为这会对你能否快速定位某段代码产生重大影响,那么请选择适合自己的方案。哎呀呀,也许你钟意 Redux ducks。很好,请随意。

团队协作

看起来现在是解释本文标题的好时候。如果是单人项目,那么你也许能找到一个对你而言百分百正确的 React 结构。事实上我觉得这是一个值得追求的目标。

但是团队中的成员越多,找到「最优」方案的概率就越低,需要考虑其他因素的概率就越高。

最重要的是妥协。小心差异偏误。你大概能从上文得出结论,如果团队成员表达了强烈的偏好,我可能很乐意采用另一种方案。如果有人特别想要使用 .jsx 扩展名或者 Utils 别名,那我会很淡定,因为尽管这不符合我个人的偏好,但它并不会拖慢我的工作。但是如果有人非要把每个文件命名成 index.js,那我会让他知道厉害的。

还有一重考虑:如果你的团队有,比方说,30 个开发者,你新开了一个项目,你也许想要确保新项目的结构尽可能接近之前的那些项目,而不是采用重新发明的轮子。或者也许你想要吸取过去的教训,采用不同的结构,修复发明得不好的轮子。

另外一件小事:随着团队的扩大,git 冲突会变成日常,文件宁小勿大有好处。

如果团队水平参差不齐,那么你应该偏向简单明晰的方案。相反,如果你的团队由资深前端工程师组成(嗯,资深),那么你可以浪一下,想搞多复杂都可以。只要确保所有人都处于同一频段,外人看起来项目有多么诡异无关紧要。

结语

有一件事情我需要坦白下。我写总结总是很费劲。感觉就像我已经给你写了一整篇博客了,你还想要什么?

所以这不是一个总结,不过我觉得应用结构的所有不同组织方案中最有趣的一个方面是人们处理争议的方式。网上有许多评论都可以总结为「我不同意,这样搞让我怒了」。这真遗憾,因为当两个理性的人之间产生了争议,经常意味着发生了一些有趣的事情,等待人们的探索。

由于我的总结已经写得很糟糕,所以不如干脆再推荐部电影?如果你喜欢《第九区》,但还没看过《超能查派》,那么别犹豫,马上去看!

感谢阅读。再见!

题图:Alain Pham

最新文章

评论

正在加载评论