我从大型前端代码库的开发工作中学到的一些东西
截至目前(2024年5月),我曾参与过三个大型前端(React+TypeScript)项目:WorkWave RouteManager、Hasura Console 和Preply.com。前两个项目的代码量约为25万行,而 Preply.com 接近100万行,这三个项目的开发体验截然不同。本文将重点介绍我在开发过程中遇到的几个重要问题。这些问题在小型项目上通常无关紧要,但当应用规模扩大后,就会成为巨大的瓶颈。
照片由Sander Crombach拍摄,来自Unsplash
更新日志
- 2024年5月
- 添加非直接的 CI 脚本
- 添加接受以下参数的组件
className - 添加未跟踪架构决策
- 添加传播外部依赖项和实现细节
- 添加隐藏商店实现细节
- 添加消费瑞士军刀
- 添加主要产品变更和重构
- 从未更新NPM 依赖项
- 2023年6月
- 第一篇文章发表
我的直接经验
首先,让我描述一下这两个项目的主要特点:
-
WorkWave RouteManager:由于后端的一些限制,前端不得不承担更大的复杂性,因此该产品非常复杂。不过,得益于优秀的前端架构师(顺便一提,他就是Matteo Ronchi )的鼎力支持,代码库堪称前端的完美典范。代码库完全是全新的(从 2020 年到 2022 年完全重写),并且我们以极高的频率尝试和使用最新的工具(例如:我们比其他人更早开始使用 Recoil,我们在 2021 年将代码库从 Webpack 迁移到 Vite等等),并且始终遵循规范的编码模式。团队由四位前端工程师组成,包括架构师和我。我当时是前端团队的负责人。
-
Hasura Console:这个项目本身的复杂度并不算高,但初创公司的需求(尽快推出新功能)以及平台的后端特性,最终导致了巨大的技术债务和反模式,给前端开发人员带来了极大的困扰。团队最初由12名前端工程师组成,后来公司决定放弃前端项目,将其规模缩小50倍,只保留后端/CLI项目。我当时以高级前端工程师的身份加入,之后成为了平台团队的技术负责人。
-
Preply.com:鉴于其B2C业务的性质以及每天数百万用户在其平台上学习,Preply采取了非常注重实验和数据驱动的策略来实现规模化发展。这种天然的商业导向导致了严重的前端依赖性过时以及难以维护的前端项目。与此同时,Preply致力于打造强大的品牌,拓展B2B市场,并始终不懈地致力于内部文化建设和员工满意度,这促使公司高度重视内部技术卓越性,在平台团队内部创建了DevEx团队,并主导了一些示范性技术项目。公司拥有约40名前端工程师,其中一些专注于React Native。我最初以高级前端工程师的身份加入平台团队,之后调到了设计系统团队。
以下是我观察到的一些特征/活动/问题的示例列表(并非详尽无遗),按类别分组。
目录
通用方法
处理的案件数量超过了实际需要的数量
这种看似无害的做法会导致很多问题,并且浪费大量时间,因为你不得不重构大量代码来维护现有功能。例如:
-
带有可选属性/参数和备用默认值的组件/函数:当需要重构组件时,你需要了解默认值的间接使用者……但如果默认值的使用是由网络响应驱动的呢?你需要理解并模拟所有极端情况!如果发现默认值根本没有被使用呢?我曾经亲眼目睹一位同事在重构过程中,因为一个未使用的默认值而浪费了四个小时……
-
有些类型被定义为泛型
string或通用类型,record<string, any>而实际上其可能的值是预先已知的。这导致大量代码用于管理泛型字符串和对象,而管理实际有限的情况则要容易得多。再次强调,当你需要重构管理“泛型”值的代码时,你会浪费大量时间。
我在《如何让下一个开发者更容易阅读我的代码》这篇文章中谈到了这些主题。
留下死代码
你重构一个模块,移除一个外部模块的导入,一切正常。但如果该模块是外部模块的最后一个使用者呢?外部模块会变成死代码,不会被嵌入到应用程序中(这很好),但这会让所有在代码库中寻找解决方案/工具/模式的人感到困惑,也会让未来的重构者感到困惑,他们会责怪任何留下这个未使用模块的人!
显然,这是一个瀑布式结构……外部模块可以导入其他未使用的模块,而这些模块又可能依赖于可以从 package.json 中删除的外部 NPM 依赖项等等。
内部代码依赖关系和边界
如果产品特性/库/工具之间没有严格的边界约束(例如通过 ESLint 规则或合理的单体仓库结构),即使是看似无害的改动也可能导致意想不到的破坏。例如,特性 A 导入了特性 B 的内部模块,而特性 B 又导入了特性 A 和特性 C 的内部模块,以此类推,那么仅仅因为修改了特性 A 组件中的一个简单属性,就可能导致 50% 的产品功能失效。此外,如果大量 JavaScript 模块从未转换为 TypeScript,那么理解各个特性之间的依赖关系树也会变得异常困难……
我强烈建议阅读《React 项目结构以实现规模化:分解、层和层次结构》。
隐式依赖
它们是最难处理的事情。举几个例子?
- 全局样式会以意想不到的方式影响您的用户界面外观和感觉。
- 一个全局监听器,监听某些 HTML 属性,在开发者不知情的情况下执行某些操作。
- 这是一个通用的 MSW 模拟服务器,所有测试都使用它,但无法知道哪些测试使用了哪些处理程序。
再说一遍,负责处理这些问题的重构人员真是太可怜了。相反,显式导入、使用 HTML 属性、控制反转等等,可以让你轻松识别谁在调用什么。
传播外部依赖项和实现细节
如果您编写自定义函数,则外部依赖项应该被隐藏并由受控代码使用,addOneDayToDate与到处传播相比dateFns.addDays(currentDate, 1)更好,因为该函数依赖于 DateFns,而 DateFns 是集中式的,易于测试和更改。
大型模块
这又是一个非常主观的话题:相比冗长的模块,我更喜欢使用许多小型、功能单一的模块。我知道很多人恰恰相反,所以这主要取决于尊重团队的优先事项。
代码可读性
我非常喜欢《可读代码的艺术》这本书,在花了两年半的时间维护一个庞大而复杂的代码库,而且代码库中竟然没有任何测试(!!!),我深知代码可读性有多么重要。
这其实也取决于有多少开发人员在代码库上工作,但我认为值得投资一些共享的编码模式,这些模式必须在 PR 中强制执行(如果可以通过 Prettier 或类似工具实现自动化就更好了)。
我在这篇共7篇文章的系列文章《RouteManager UI编码模式》中公开分享了我们在WorkWave中使用的编码模式。我们内部的规则是“模式必须在代码中可识别,但作者不能识别”。
这里没有万全之策,在我看来,最重要的是每个人在编写代码时都要牢记代码的可读性和可重构性。
均匀性胜于完美性
如果你正准备重构一个模块,但又没有时间重构与其耦合的两个模块……不妨考虑不进行重构,使这三个模块保持一致(一致性意味着可预测性和更少的歧义)。
工作流程
未跟踪架构决策
架构决策和变更对于理解项目的设计初衷及其随时间演变的过程至关重要。通常,这些决策并不能完全体现在代码库中,因为大型代码库总是需要逐步迭代开发。
跟踪这些决策非常重要,这样可以避免处理部分应用的方法、部分完成的重构等等,而对这些决策的时间线以及它们试图解决的问题却没有确切的了解。
通常情况下,当那些记得这些决策的工程师离开公司时,这个问题就会爆发,而新来的工程师注定会永远对此一无所知。
从小处着手,这也指为了完成某件事而做出的那些笨拙的改变和/或耍花招。看看乔什·W·科莫的这个精彩例子。
没有公关稿描述,但公关稿很大
这是一个非常重要的话题,我为此写了四篇文章。先从最重要的一篇开始:用详细的 Pull Request 描述来支持审阅者。
如果你感兴趣,可以深入了解一下我在这里记录的一些真实案例。
- 案例分析:Hasura Console 的代码审查流程
- https://dev.to/noriste/re-building-a-branch-and-telling-a-story-to-ease-the-code-review-485o
- 改进 Hasura 的内部公关审查流程
在代码审查过程中提出重大变更和方法建议
PR 并非提出重大变更或彻底改变方法的最佳途径,因为这会间接阻碍新功能或修复程序的发布。虽然有时这样做至关重要,但或许最初的分析和评估步骤、结对编程等方式更有助于完善方法和代码。
何时解决技术债务?
这是一个很好的问题,没有万全之策……我只能分享我目前的经验。
- 在 WorkWave,我们早已习惯每天处理技术债务。修复技术债务是工程师日常工作的一部分。为了深入了解上下文并保持代码库的良好状态,这可能会减慢功能开发的步伐。这就好比明知自己放慢了今天的开发速度,却仍然为了保证明天的开发能够保持目前的进度而做出的牺牲。
- 在 Hasura,由于需要交付新功能,我们无法处理技术债务。这导致许多前端开发人员的开发速度远低于他们的预期,有时还会引入 bug,并为客户提供不完美的 UX。显然,这种情况是在多年之后才出现的。
- 在 Preply,工程师可以将 20% 的时间投入到技术卓越计划中,其中一些计划由公司推动,另一些计划则由团队自己提出。
您可以在我的前端平台用例文章“启用功能并隐藏分发问题”中阅读更多关于 Hasura 问题的典型案例。此外,您还可以点击此处了解在我们面临所有技术债务问题后,我们的端到端测试发生了什么变化。
主要产品变更和重构
重大产品变更(例如 WorkWave RouteManager 的完全重写、Preply 的全面品牌重塑等)也是引入重构或清除长期存在的技术债务的绝佳时机。原因在于,过去几年积累的所有知识让我们对需求和需要清除的技术债务有了更全面的认识,从而使新产品比最初版本更加出色(这相当于在现有产品内部进行一个全新的项目)。
没有面向前端的后端 API
所谓“非前端导向”,指的是API的设计并未充分考虑最终用户的用户体验,并且为了保持后端开发的精简,大量复杂性被推到了前端(例如,将大量数据库查询嵌入前端,避免从后端暴露新的API)。这种方法在产品初期发展阶段是合理的,但随着产品规模的扩大,前端会变得越来越复杂。
从未更新 NPM 依赖项
同样,这是基于我自身的经验:
- 在 WorkWave 中,我过去每周都会更新外部依赖项。通常需要 30 分钟,有时需要 4 个小时。
- 在 Hasura 中,我们习惯于不更新依赖项,结果发现
legacy-peer-deps默认启用依赖项、利用 NPMoverrides却无法更新任何与 GraphQL 相关的依赖项。除此之外,还有很多 PR 因为引入新的依赖项而彻底破坏了构建。 - 在 Preply 中,过时的 TypeScript 版本导致无法启用某些功能
exactOptionalPropertyTypes,并noUncheckedIndexedAccess引发了多起生产事故。与此同时,为了符合 SOC2 标准(这是拓展 B2B 市场的必要条件),我们不得不将依赖项的维护放在首位(为此,我们耗时数月完成了所有依赖项的更新工作)。
由于维护依赖项是有成本的,因此您应该仔细考虑是否真的需要一个永久依赖项。它是否有人维护?它是否能解决我更倾向于委托给外部组件的复杂问题?
作为一种替代方法(仅适用于非常非常小的项目),您还可以考虑将一些依赖项的代码复制/粘贴到“vendor”目录中,链接到原始项目并跟踪代码属于哪个版本(代价是无法更新它,并且其他人必须知道他们不应该安装相同的依赖项)。
TypeScript
不良实践:通用 TypeScript 类型和可选属性
这种类型的人很常见。
type Order = {
status: string
name: string
description?: string
at?: Location
expectedDelivery?: Date
deliveredOn?: Date
}
这应该由像这样的受歧视工会来代表。
type Order = {
name: string
description?: string
at: Location
} & ({
status: 'ready'
} | {
status: 'inProgress'
expectedDelivery: Date
} | {
status: 'complete'
expectedDelivery: Date
deliveredOn: Date
})
虽然这种写法比较冗长,但它可以作为纯粹的领域文档,消除大量歧义,并允许编写更好、更清晰的代码。
这个话题非常重要,而且有很多优点,所以我专门写了一篇文章来探讨这个话题:如何让下一个开发者更容易阅读我的代码。
类型断言(as)
类型断言是一种告诉 TypeScript“闭嘴,我知道自己在做什么”的方法,但现实是,你几乎不知道自己在做什么,尤其是在考虑你所做的事情的后果时……
这种情况在测试中非常常见,当大型对象通过类型断言进行“类型化”时……会导致对象相对于原始类型过时……但只有当测试失败时你才会意识到这一点,而这也会给以后的测试失败留下很多疑虑……
解决方法:正确输入所有内容,并尽可能@ts-expect-error对预期的错误进行解释。
阅读《为什么你应该避免在 TypeScript 中使用类型断言》以了解更多相关内容(并请记住,JSON.parse那里显示的示例也可以使用Zod 解析器进行类型断言)。
@ts-ignore而不是@ts-expect-error和广泛的范围
@ts-expect-error这些问题将来可能会自动修复,而现在却不行@ts-ignore(这是另一种让 TypeScript 闭嘴的方法)。
此外,@ts-expect-error应尽可能缩小 TS 接受意外错误的范围。
// ❌ don't
// @ts-expect-error TS 4.5.2 does not infer correctly the type of typedChildren.
return React.cloneElement(typedChildren, htmlAttributes); // <-- the whole line is impacted by @ts-expect-error
// ✅ do
return React.cloneElement(
// @ts-expect-error TS 4.5.2 does not infer correctly the type of typedChildren.
typedChildren, // <-- only typedChildren is impacted by @ts-expect-error
htmlAttributes
);
any而不是unknown
TypeScript 的 `--runtime` 属性any赋予你对变量的自由(这通常是不好的),可以随心所欲地操作变量,而 `--runtime`unknown属性则强制你在使用变量之前必须严格保证其运行时值。`--runtime` 属性any就像关闭 TypeScript,而 `--runtime` 属性则unknown就像开启了 TypeScript 的所有警告信息。
ESLint 规则保留为警告
ESLint 警告毫无用处,只会增加很多背景噪音,而且会被完全忽略。规则应该只启用或禁用,警告绝对不应该存在。
验证外部数据
在软件领域,“永远不要信任前端发送到后端的数据”这条规则至关重要,但我认为,对于使用 TypeScript 类型的前端应用程序来说,你不应该信任任何类型的外部数据。服务器响应、查询字符串、本地存储、JSON.parse 等,如果没有通过类型守卫(请阅读我的《保持 TypeScript 类型守卫的安全性和更新》一文)或更好的选择——Zod 解析器进行验证,都可能导致运行时问题。
React
使用 HTML 模板代替清晰的 JSX
包含大量条件语句、循环语句、三元运算符等的 JSX 代码难以阅读,有时甚至不可预测。我称之为“HTML 模板”。相比之下,使用功能清晰、职责分离的小型组件才是编写清晰且可预测的 JSX 的更好方法。
我曾在《如何让下一个开发者更容易阅读我的代码》一文中再次谈到过这个话题。
组件代码中包含大量 React hooks 和逻辑。
我非常喜欢将 React 组件的逻辑隐藏到自定义 hook 中,这些 hook 的名称应该清晰地表明 hook 的作用域以及它在内部的用途。原因始终如一:JSX 之前的长代码会使 JSX 更难阅读。
接受组件className
组件的设计目的是封装和隐藏某些逻辑,并将这些逻辑的结果呈现给外部世界。它们的 UI 是封装的 API 的一部分,用户不应该能够更改它们。通常,组件也允许className用户自定义组件 UI 的一小部分(这是最初的目标)。然而,结果却是一个不受控制且难以预测的后门,可以在几秒钟内篡改组件及其子组件的所有 UI 细节。
与所有 JavaScript 细节一样,样式细节也应该封装并隐藏,只向使用者公开一些通用配置。这些配置明确地标记了组件提供的功能以及使用者想要获取的内容(例如,`<div>` variants、 `<span>`、` type<span> mode` 等)。
作为重构者,当你看到一个组件接受一个参数时className,你就知道自己的工作有多么艰难了。
隐藏商店实现细节
我在 WorkWave RouteManager 上发现一个非常有效的方法,就是将 store 隐藏在导出纯 React、独立于 store 的 API 的模块中。我们很早就开始使用 Recoil,后来迁移到了 Valtio,因为它更好地满足了我们的需求。迁移过程非常顺利,因为 Recoil 只是导出纯 React API 的模块的一个实现细节。useSelection.
食用瑞士军刀
由于某些 React 组件需要处理大量情况(例如表格、日期选择器、模态框等),因此它们在设计上就具有通用性。这使得追踪用户需要这些通用组件提供的上百种功能中的哪些功能变得困难。因此,重构这些组件或其用户都变得很困难。我的建议是创建中间组件和垂直组件,作为更复杂组件的代理。
垂直组件的名称和描述可以让读者了解它们的功能和需求,而无需深入了解原始复杂组件的使用细节(例如:UserList仅使用排序选项Table比深入了解Tutors、Students和Managers页面如何使用更清晰Table)。
测试
糟糕的测试
作为一名测试爱好者和讲师(我在私营公司和会议上教授前端测试),我可以说,糟糕的测试是由于缺乏这方面的经验造成的,唯一的解决办法是帮助、指导、帮助、指导、帮助、指导等等。
总之,测试带来的虚假自信是每个代码库中都存在的大问题。
我建议阅读我的两篇文章:
到处都有端到端测试
E2E 测试由于需要真实数据、真实后端等,因此扩展性不佳。
从这个角度来看,Preply 是一个很好的例子(也是我在工作生涯中见过的唯一一个成功的例子),它说明了当领导层将用户体验视为至关重要时,你能取得怎样的成就:端到端测试是强制性的,强大的持续交付方法使得端到端测试套件的稳定性达到 98% 以上,从而确保了许多正常流程始终有效。
另外,我建议您阅读我的一些文章:
开发者体验
已弃用的 API
当代码被标记为删除线时@deprecated,IDE 会将其显示为删除线并显示文档,帮助开发人员意识到他们不应该使用它。
举例来说:
/**
* @deprecated Please use the new toast API /new-components/Toasts/hasuraToast.tsx
*/
export const showNotification = () => { /* ... */ }
注意浏览器日志
控制台警告(来自 ESLint、TypeScript、React、Storybook 等)会造成大量背景噪音,与重要的日志混淆。请务必注意并移除这些警告,以免开发人员因噪音过多而忽略您发出的重要警报。
开发者警报:意外情况
运行时环境(例如服务器响应)可能与前端类型不一致。如果您不想因抛出错误而中断用户流程,至少应该使用一些能够发出警报的工具(例如 Sentry 或其他工具)来跟踪错误,以便在错误发生后尽快修复它。
仅限 React 的 API
如果您正在创建内部库,建议仅公开 React API。这样做最大的优势在于您可以依赖 React 的响应式系统,并且将来处理动态/响应式场景会更加轻松,因为您可以确保 React API 的使用者能够免费获得重新渲染的页面,并始终使用最新的数据。
不简单的 CI 脚本
CI 流水线应该只运行 package.json 文件中已有的脚本,无需添加额外的逻辑,以免增加认知负担,并使本地或其他环境中的错误复现变得更加困难。试想一下,为了在本地复现问题并找出根本原因,你需要费尽心思去理解 CI 步骤的具体操作,这该是多么痛苦的过程。CI 可能使用了你不熟悉的工具,可能使用了特定的配置,而所有这些都意味着你需要依赖负责 CI 所有工作的同事/团队。
CI 流水线只需负责使用正确的 Node.js 版本(由维护代码库的前端开发人员设置)配置所有内容,并启动一些 CI 专用脚本(例如:ci:lint`$(' ci:buildnode_modules', ci:ts-check'node_modules', 'node_modules', 'node_modules ci:test:unit', ci:test:e2e'node_modules' 等)。这样可以将 CI 中启动的脚本与更了解 JS 生态系统的人员解耦,从而简化整个流程。
该表扬的就表扬。
非常感谢Ronchi 先生和Beaussart 先生在过去几年里教会我这么多重要的东西❤️本文中的很多内容都源于我与他们日常的合作❤️
文章来源:https://dev.to/noriste/some-things-i-learnt-from-working-on-big-frontend-codebases-1e0a