在2020年, 创建一个小型React项目或者从头开发一个web项目是非常容易的, 因为有着诸如create-react-app等脚手架工具, 大多数项目都只有极少的依赖(比如lodash, axios, i18n等设置更少), 以及一个只有components文件夹的src文件夹, 这是如今大多数react项目开始的方式. 但是, 随着项目的逐渐增大, 依赖项(内部的或者外部的, 尤其是那个node_modules), 组件, reducers(store)和其他共享公共组件会越来越不受控制. 那么, 当不清楚为什么需要某些依赖项和它如何使用, 或者在公共组件库越来越多的时候(在实际项目中, 一个小小的需求变化就可能产生几乎相同的两个组件, 或者一个组件是从另一个组件继承而来) 如何找到你真正需要的组件, 而不是靠去问之前的开发人员或者是从陈旧的开发文档或者注释中去寻找.
这只是在我们重构项目中遇到的一些问题的例子, 我们知道依赖和组件的数量最终会得不到控制. 这意味这我们需要一个项目规划, 可以跟得上未来的发展. 该规划为我们的文件和文件夹结构定义约定, 代码质量和体系结构. 最重要的是, 新加入的开发人员能够简单的掌握项目信息.
在写这篇文章的时候, 我们有大概1200个js文件其中300个左右有这80%覆盖率单元测试. 因为我们现在项目的体系任然保持良好, 所以现在分享出来我们如何设置项目, 同时还有一些教训.
如何组织文件和文件夹
在经历了原始jq模式: 和后端代码放在同一个仓库, 然后因为后端框架的强制文件夹结构让这选项不可取. 然后我们决定从此以后为每个项目单独创建了一个仓库, 这非常正常. 但是问题马上出现了. 当开始一个新的项目的时候, 我们要把之前的所有公共组件库copy或者引入一份到新项目里(基础物料例如表单等), 当我们开发人员因为一个需求去更改公共组件库的时候, 另外的项目并不会马上感知到, 这在后面的迭代中带来了灾难性的后果. 我们选择将它们再次合并到一个仓库中. 只不过这次使用了monorepo.
我们选择了monorepo,是因为我们想在组件库和前端应用程序之间建立一个隔离. 我们的monorepo与其他版本之间的区别在于,我们确实不需要在内部发布包。 对于我们而言,这些package仅用作模块化和关注点分离的手段. 为每个不同的应用程序使用不同的包特别有用,因为我们可以为每个应用程序指定不同的依赖关系和脚本.
我们在root文件夹的package.json中使用yarn workspaces并使用以下配置来设置我们的monorepo:
"workspaces": [
"app/*",
"lib/*",
"tool/*"
]
现在,有些人可能想知道为什么我们不像其他monorepo那样简单地使用packages文件夹。 主要是因为我们想在应用程序和组件库之间建立隔离。 除此之外,我们还知道我们需要创建自己的工具。 因此,我们提出了您在上面看到的文件夹,这是每个文件夹的说明:
- app: 这个文件夹中的所有软件包都指的是前端应用,比如我们的主项目前端、一些内部前端
- lib: 此文件夹中的所有packages都将共享共用组件公开给我们的前端应用,并且与应用无关。 这些软件包基本上构成了我们的组件库。 例如,我们的版式,媒体和脚手架
- tool: 此文件夹中的所有软件包都是基于Node.js的,或者我们自己创建的公开工具,或者是我们依赖的工具的配置和实用程序。 比如webpack实用程序,linter配置和文件系统linter
无论我们将它们放置在何处,我们所有的程序包始终都具有src文件夹,还可以选择具有bin文件夹。 我们的应用程序和lib软件包的src文件夹可能包含以下一些文件夹
- actions: 包含action creators函数,其返回值可以从redux或useReducer传递到dispatch函数
- components: 包含组件文件夹及其各自的定义,翻译(不同开发语言),单元测试,快照
- constants: 拥有在不同环境和不同实用程序中可重用的常量值
- fetch: 保留来自我们的API的负载(res)的类型定义以及用于检索它们的相应的异步操作
- helpers: 拥有不属于任何其他类别的实用程序
- reducers: 包含要在我们的Redux stores或useReducer中使用的reducer
- routes: 保留要在react路由器组件或history函数中使用的路由定义
- selectors: 包含可从我们的redux状态或API负载读取或转换数据的辅助函数
这种文件夹结构使我们可以编写真正的模块化代码,因为它在依赖项定义的不同概念之间建立了清晰的关注点分离。 这有助于在仓库中查找变量,函数或组件,而无需我们知道它们是否存在。 此外,它还有助于将这些文件夹的内容减到最少,从而使它们更易于处理。
这种新的文件夹结构面临的挑战是确保我们坚持下去. 试图在不同的程序包中创建不同的文件夹并在它们之间以不同的方式组织文件是很诱人的. 虽然这可能并不总是一个坏主意,但如果我们不能始终如一地做下去,我们将陷入混乱。 为了解决这个问题,我们创建了一个文件系统linter,我将在下一节中对其进行详细描述。
如何执行风格指南?
以我们想要具有一致的文件和文件夹结构的方式,我们还希望代码中具有尽可能的一致性。 这是我们在jQuery前端中已经做得很好的事情,但是可以改进,尤其是在CSS方面。 因此,我们尝试从一开始就定义样式指南,并使用linter实施它。 代码审阅期间,将强制执行lint指定的规则。
在monorepo中设置一个linter与在其他任何存储库中设置都是一样的,这很棒,因为您可以运行一次以验证整个存储库。 如果您不熟悉任何ESlint,建议您看看我们使用的ESLint和Stylelint 实践,使用JavaScript Linter对于以下使用情形特别有用:
- 在HTML对应版本上强制使用可访问性 感知组件:在设计期间,我们为跳转锚,按钮,图像和图标定义了多个可访问性准则。 然后,在代码中,我们希望实施这些准则,并确保将来不会忘记它们。 我们使用eslint-plugin-react中的 react/forbid-elements规则进行了此操作
'react/forbid-elements': [
'error',
{
forbid: [
{
element: 'img',
message: 'Use "<Image>" instead. This is important for accessibility reasons.',
},
],
},
],
- 禁止从库包内部导入应用程序包,并禁止在其他应用程序内部导入应用程序包:主要是为了避免monorepo中包之间的循环依赖,并确保我们坚持创建的关注点分离。 我们使用eslint-plugin-import中的 import/no-restricted-paths
除了JavaScript和CSS linting外,我们还拥有自己的文件系统linter。 这就是我们确保坚持使用文件夹结构的方式。 由于这是我们自己的eslint,如果我们决定要更改结构,则可以随时更改它。 以下是我们拥有的规则的一些示例:
-
验证组件文件夹的结构:确保始终存在一个index.ts和一个与该文件夹同名的.tsx文件。
-
验证我们的package.json文件:确保每个包始终有一个,并且将其设置为private以避免我们意外发布包
使用什么类型的系统?
如今,对于每个人来说,上述问题的答案可能更加清晰。 只需使用TypeScript! 无论项目的大小如何,在某些情况下它都可能使开发变慢,但是,我们认为,它为您的代码增加的质量和更好的严格性使它是值得的。
不幸的是,当我们开始这个项目时,使用prop-types仍然很普遍。 刚开始足够,但是随着项目的发展,我们真的开始错过了为不仅仅是组件定义类型的可能性。 我们可以看到它们将添加到的值判断,例如,reduce和selector函数。 但是,添加不同类型的系统将需要大量重构,以使整个代码库具有types定义。
最后,我们还是这样做了,但是我们犯了一个错误,那就是先尝试Flow,因为这样看起来更容易集成到我们的代码库中。 虽然这是真的,但是由于Flow没有与我们的IDE很好地集成,所以Flow的使用也变得很麻烦,它随机地没有发现一些类型错误,创建泛型类型是一场噩梦,因此使用它也变得很痛苦。 由于这些原因,我们最终将所有内容移至TypeScript。 如果我们知道了现在的困境,我们将一开始就使用TypeScript
过去几年TypeScript的发展方向使这种过渡变得更加容易, 不推荐使用TSLint而不是ESLint,这对我们特别友好.
使用哪种测试方法?
当我们开始的时候,我们并不是很清楚要使用什么测试工具。现在,我想说 jest 和cypress 是单元测试和集成测试的最佳选择. 他们的配置有详尽的文档,并不复杂。 遗憾的是,Cypress不支持fetch API,API也不支持async/await. 一开始,我们花了一些时间才弄明白这一点。 希望这种情况在不久的将来会有所改变.
一开始,找出如何最好地为我们的应用程序编写单元测试是相当困难的。 随着时间的推移,我们尝试了snapshot testing,、 test renderer、 shallow renderer和 testing library.等功能。 最后,我们决定坚持使用shallow renderer来验证组件的输出,并使用test renderer来测试组件的内部逻辑。
在我们看来,测试库对于小项目来说是很棒的,但是它依赖dom渲染器的事实对测试的性能有很大的影响。此外,我们认为,当您有非常深的组件层时,反对使用浅层呈现进行snapshot testing的论点是没有意义的。对我们来说,这些snapshot 对于验证组件的所有可能输出非常有用。不过,保持它们的可读性很重要。这可以通过保持较小组件,并为与snapshot 无关的对象道具定义toJSON方法来实现。
为了保持我们编写单元测试的积极性,并且不会忘记它们,我们还定义了覆盖阈值。 在使用jest时,这非常容易配置。 你不需要考虑太多。 只需从一个全局门槛开始,然后随着时间的推移进行改进。 我们从60%开始,随着时间的推移,随着覆盖率的增加,我们将门槛提高到了80%。 对我们来说,这似乎是一个足够好的门槛,因为我们也认为100%的目标是不现实的,也不是必要的。
如何引导应用程序?
通常,启动React应用程序非常简单,只需编写ReactDOM.Render(<App/>,document ent.getElementById(‘#root’));但是,当您还希望支持SSR(服务器端呈现)时,这会变得更加复杂。另外,如果您除了React之外还需要其他依赖项,则它们可能需要分别针对客户端和服务器端进行配置。例如,我们使用react-intl进行国际化,使用react-redux进行全局状态管理,react-router用于路由,redux-sagas用于管理异步操作。这些依赖项需要一些设置,这很容易变得复杂。
我们对这个问题的解决方案是基于策略和抽象工厂设计模式的。我们基本上创建了两个不同的classes/strategies:一个用于客户端配置,另一个用于服务器端配置。他们两个都接收正在引导的应用程序的配置,包括其名称,logo,reducers,路由,默认语言,sagas等。reducers、routes和sagas可以来自我们的monorepo中不同的packages。然后,此配置用于创建redux存储,创建sagas中间件,创建router history object,获取翻译并最终呈现应用程序。举个例子,下面是我们两种策略的签名的简化版本:
type BootstrapConfiguration = {
logo: string,
name: string,
reducers: ReducersMapObject,
routes: Route[],
sagas: Saga[],
};
class AbstractBootstrap {
configuration: BootstrapConfiguration;
intl: IntlShape;
store: Store;
rootSaga: Task;
abstract public run(): void;
abstract public render<T>(): T;
abstract protected createIntl(): IntlShape;
abstract protected createRootSaga(): Task;
abstract protected createStore(): Store;
}
// Strategy for client-side
class WebBootstrap extends AbstractBootstrap {
constructor(config: BootstrapConfiguration);
public render<ReactNode>(): ReactNode;
}
// Strategy for server-side
class ServerBootstrap extends AbstractBootstrap {
constructor(config: BootstrapConfiguration);
public render<string>(): string;
}
我们发现这种分隔很有用,因为根据环境的不同,store,sagas,国际化对象和history object的设置方式存在一些差异。例如,客户端上的redux存储是使用服务器和 redux devtools enhancer的预加载状态创建的,而在服务器端,它不需要任何这些。另一个例子是我们的内部化对象,它在客户端从Navigator.languages获取当前语言,而在服务器端从Accept-Language HTTP头获取当前语言。
重要的是要注意,我们不久前已经提出了这个解决方案。当时,在Reaction应用程序中使用类仍然很常见,而且没有简单的SSR解决方案。随着时间的推移,Reaction向功能更强大的风格转变,出现了像Next.js这样的解决方案。关于这一点,如果您正在寻找解决同一问题的方法,我们建议您也研究当今可能的方法,因为您可能会找到一种更简单,更实用的方法。
如何保持代码质量?
Linters、tests和types对于提高代码质量很有用,但是,在将一些更改合并到主分支中之前,很容易忘记检查它们是否都有效。 最好的解决方案是自动执行此操作。 有些人喜欢在每次提交时使用git钩子来阻止您提交,除非一切都通过。 但是,我们认为这太麻烦了,因为您可能要在一个分支上工作几天,直到它准备就绪。 因此,我们使用CI(持续集成)管道验证提交,该管道仅在分支与合并请求相关联时运行。 通过这种方式,我们可以避免运行预期会失败的管道,因为大多数情况下,我们只在认为代码准备就绪时才创建合并请求。
我们的管道从安装所有依赖项开始,然后验证类型、运行linters、运行单元测试、构建应用程序并运行Cypress测试。 几乎所有这一切都是并行进行的。 如果这些步骤中的任何一个失败,那么管道就会失败,分支就不能合并。 以下是正在运行的管道的示例。
设置这条管道最困难的部分过去是,现在仍然是,保持它的速度。 我们经历了很多优化阶段,目前它稳定运行在20分钟左右。 我们也许可以通过并行运行一些cypress 测试来改善这一点,但是,就目前而言,这是可以接受的。
结尾
设置React大规模应用并非易事。 您必须做出许多选择,并且必须配置许多工具。 如何做到这一点不仅只有一个正确的答案。
我们仍然对我们的设置感到满意,并希望它能激发其他努力设置自己的应用程序的人。 但是在您遵循我们的示例之前,请尝试确保这是适合您或您的公司的正确设置。 最重要的是,仅添加所需的依赖项。 不要过于复杂.