首页 / 文章 / 在长期使用的 React 代码库中,细微决策如何逐渐产生累积效应

在长期使用的 React 代码库中,细微决策如何逐渐产生累积效应

让 React 应用长期稳定运行的15项维护习惯:可读性强的代码、专注的组件设计、作用域化的状态管理、精简的依赖关系、测试以及监控机制。

2206 词

创建一个 React 项目其实很简单。真正的考验是在数月或数年后,当应用拥有更多用户、更多功能、更多贡献者以及更多依赖项时,那些原本“临时”的解决方案却悄悄承担起了核心功能。到那时,React 本身很少成为问题;问题在于如何在周围的一切都在不断变化的情况下仍保持代码库的可理解性。本指南介绍了十五种习惯,这些习惯按照维护成本实际累积的领域进行分类,帮助你在问题恶化成没人愿意触碰的代码库之前及时发现并纠正错误决策。

为未来的读者编写的代码

优先选择清晰易懂的代码而非巧妙的代码

那些紧凑的一行代码、复杂的抽象概念以及为众多假设情况设计的辅助函数,在编写时似乎能提升效率。但六个月后,它们往往会变成谜题,尤其是对最初编写它们的开发者而言,他们甚至记不起为何如此设计。

另一种选择是刻意使用简洁的代码。可以考虑设计一种能够显示加载中、失败或已准备就绪状态的界面。通过提前返回值,可以逐个明确每种状态对应的条件:

if (isLoading) {
  return <LoadingState />;
}

错误处理分支的结构与之相同,而正常流程则放在最后:

if (error) {
  return <ErrorState />;
}
return <Dashboard />;

这里没有任何令人惊叹之处,而这正是目的:任何人都能在几秒钟内看出哪个组件会在何时被渲染。在大型应用中,下一位开发者理解代码的速度远比让当前开发者感到惊艳重要得多。

名称就是永不过时的文档

在编写代码时,命名似乎只是个微不足道的细节,但半年后进行调试时却变得极为重要。试比较一下在代码库中搜索以下内容的情况:

handleData()

与搜索以下内容相比:

calculateMonthlyRevenue()

在打开文件之前,第二个名称就能让你知道该函数的功能。庞大的代码库本质上是开发者之间的沟通渠道;精确的名称能明确功能意图,而模糊的名称则会让每个阅读者都变成侦探。

要警惕在截止日期压力下出现的那些泛泛的名称:

data
item
temp
helper
value
thing

没错,thing确实会出现在真实的代码库中。一个实用的经验法则是:如果某个名称能同样适用于项目中的任何文件,那它很可能无法充分体现当前文件的功能。

组件与状态边界

过大的组件会增加开发成本

几乎每个长期运行的 React 项目最终都会出现代码行数高达四位数的组件。这种情况很少是一开始就如此的——最初大概是 150 行左右,之后又增加了模态框、过滤功能、数据获取逻辑、权限检查,接着又是第二个模态框。最终当你打开该文件时,会发现类似这样的代码:

Dashboard.tsx
1,247 lines

如此庞大的组件不仅难以理解、测试、复用和调试,而且修改时也存在风险,因为任何改动都可能影响到文件中其他部分所依赖的状态。应在觉得必要时尽早拆分代码。与其使用单个文件:

Dashboard.tsx

不如将界面拆分成多个各自承担单一功能的模块:

DashboardHeader.tsx
DashboardStats.tsx
DashboardFilters.tsx
RecentActivity.tsx
DashboardTable.tsx

这样每个文件就只负责一项功能,其影响范围也会大大缩小。一个实用的判断标准是:如果无法用一句话描述某个组件而不使用多个“和”字,那就应该将其拆分。

重用你已经见过的模式,而非凭想象创造的

可复用的组件是个不错的思路,但很容易被过度使用。常见的错误做法是让一个按钮试图承担产品中所有按钮的功能:

<UniversalButton
  type="primary"
  variant="rounded"
  size="medium"
  iconPosition="left"
  loadingStyle="spinner"
/>

每个新的属性看起来都无害,但合在一起就会形成大量组合变体,没人能全面测试这些变体,结果这个“可复用”的组件反而比三个功能明确的小按钮更难使用。重用本身很有价值,但过早的泛化则没有意义。

只有当真正看到某种模式重复出现时,才提取共享的抽象层,而不是因为未来某个页面可能需要它。如果真有这样的需求,你应该根据实际案例来设计抽象层,而非凭猜测。

按状态所属位置进行分类

状态管理往往会在不知不觉中恶化。一个小型应用最初只有组件状态:

useState()

然后通过上下文添加共享状态:

useContext()

最终项目中同时存在 Redux、多个上下文、本地状态、URL 状态和服务器状态,却无法明确哪种状态控制侧边栏。每种工具在添加时都是合理的;缺失的是关于各状态应存放位置的规则。

恢复秩序的简单方法是根据状态的性质对其进行分类。本地 UI 状态涵盖以下内容:

modal open
dropdown selected
input value

它应保留在使用它的组件内部。服务器状态则是存储在后端、仅在浏览器中缓存的数据:

users
products
analytics

对于这类场景,应使用专为获取、缓存和重新验证远程数据而设计的库,而非手动将响应复制到全局存储中。如果您的团队正在考虑这种转变,我们关于React Query与Redux在处理服务器状态方面的对比文章中已详细阐述了其中的利弊。最后,真正意义上的全局状态其实只有寥寥几种:

theme
authenticated user
app-wide preferences

尽量减少最后那类全局状态:任何组件都可以读取或修改全局状态,因此全局状态越少,那些莫名其妙出现的错误也就越少。筛选条件、标签页和分页功能通常应该放在URL中,这样在页面重新加载时这些信息依然存在且可以共享。

结构与依赖关系

让文件夹布局具有可预测性

想象一下加入一个项目后,在根目录下发现这样的结构:

src/
components/
shared/
common/
helpers/
utils/
services/
core/
misc/
new/
new2/

新的用户资料组件应该放在哪里?没人能给出答案,因此每个开发者都自行决定,导致代码结构不断混乱。诸如sharedcommonhelpersutils这类重叠的目录类别表明,根本没人明确过它们的含义。

基于功能的布局通过按照代码所服务的产品功能模块对代码进行分类,消除了大部分歧义:

features/
  auth/
  dashboard/
  users/
  billing/

在每个功能模块中,一组小型且重复出现的子文件夹有助于保持代码结构的清晰性:

components/
hooks/
services/
types/

这样一来,负责账单处理的人就能知道账单相关代码的位置,修改某个功能时只需改动一个目录而非十个。其他布局方式也可行(可参阅我们关于React文件夹结构的对比文章);关键在于规则要有可预测性并且被明确记录下来。

将每个依赖项视为未来的维护工作

添加一个包只需一条命令:

npm install something-cool

问题会在日后显现:这些包可能被废弃,带来破坏性变更,暴露安全漏洞并增加代码包体积,而你的团队还得不断升级那些没人读过的代码。

因此在安装之前,先问问自己是否真的需要它。那些能节省大量精力或解决真正棘手问题的工具,比如具备时区意识的日期处理功能,才值得安装;而那些仅用于字符串格式转换的工具则没有必要。

随应用一同发展的安全保障

测试那些出错后果最严重的流程

对于小型应用,在发布前逐页测试似乎就足够了。但对于大型应用,修改一个函数就可能因为某些难以解释的原因导致计费系统出错,这时自动化测试就显得非常重要:它们能让您放心地修改那些并非自己编写的代码。

无需覆盖所有的实现细节,只需关注那些出错后果严重的用户流程:

  • 登录功能
  • 结账流程
  • 提交重要表单
  • 权限检查
  • 业务依赖的计算过程

测试无法证明该应用毫无瑕疵;它们能在用户发现之前捕捉到明显的功能退化问题。针对行为层面的测试而非内部实现,也能在代码重构后更好地保持稳定性。

注意性能的缓慢累积性下降

大型 React 应用很少会在一次发布后就变得缓慢。性能问题是一点一点由不合理的设计累积而成的:过重的依赖、巨大的图片文件、本可避免的重新渲染、单个页面上发起的数十次请求。这些因素加在一起可能导致仪表板加载时间达到五秒,因此需持续关注以下情况:

  • 组件所显示的内容并未变化却仍会重新渲染
  • 每次发布后代码包大小逐渐增大
  • 单个页面上同一请求被多次发送
  • 某些组件渲染速度过慢
  • 未使用虚拟化技术渲染的长列表
  • 过大的图片文件

小小的成功累积起来效果显著,而在问题出现时立即发现并处理,远比日后再去解决要便宜得多。

刻意设计出异常路径

大多数精力都投入在“正常路径”上,即所有请求都能成功处理。但在实际运行环境中,连接会中断、API会出错、权限会变更,后端返回的数据格式也可能出乎意料。此时用户应看到类似“我们无法加载您的数据,请再试一次”的清晰且可恢复的提示,而非如下这样的原始运行时错误:

TypeError: Cannot read properties of undefined

用React的术语来说,这意味着需要为数据获取设置明确的错误状态、使用错误边界防止某个出错的组件导致整个页面空白,以及在合适的情况下添加重试机制。

将生产环境监控视为开发流程的一部分

产品发布并非工作的终点。生产环境会暴露出本地开发环境中永远不会出现的问题,因此你需要能够监控:

  • 运行时错误及其发生位置
  • 响应速度慢于预期的请求
  • 失败的API调用
  • 页面及交互的整体性能
  • 用户实际使用产品的方式

如果没有这些机制,故障报告就只是“有用户称昨天系统出问题了”这样的描述。而错误追踪与上下文日志则能将其转化为可供处理的堆栈跟踪信息及时间戳。

让团队保持一致的习惯

为团队现有成员编写文档

文档往往被视为一种交接时的繁琐任务,但实际上它对团队中的每个人都有帮助,尤其是在处理复杂的权限设置、特殊的架构设计、第三方集成以及重要的业务规则时。

无需冗长的手册。关于认证机制、数据流向、主要功能所在位置以及关键决策依据的简短说明就能节省大量时间。一旦原始开发者离开,就再也无法获得他们的帮助,而在长期项目中他们总会离开。

重构是常规维护,而非失败的表现

重构并不能证明原始代码质量差。需求、团队和产品都会发生变化,一年前适用的代码可能已不再能解决相应问题。

危险在于有人认为“现有代码已经可以正常使用”。三脚椅子在没人坐上去之前也看似没问题。通过测试支持的小规模、定期重构能让技术债务保持在可控范围内。

一致性比个人喜好更重要

有的开发者这样编写标识符:

camelCase

而另一些人则更喜欢这样写:

snake_case

还有第三种人,每隔几周就会发明一种新的组件模式。如果每个文件都遵循其创建者的个人风格,大型团队就无法有效协作,因此应通过以下方式实现一致性:

  • 具有统一规则的代码检查工具
  • 自动格式化工具
  • 有文档规范的命名规则
  • 针对常见任务而制定的、经过审核的共享模式

代码库应当看起来像是一个完整的项目,而非由十几名开发者各自随意设计文件结构的结果。

选择团队真正能够落地的架构

关于架构的讨论向来是人们喜爱的话题:微服务、单仓库架构、清洁架构、领域驱动设计,总有人准备好了相应的图表。但最复杂的选项并不一定就是最好的。一个好的选择需要满足三个标准:

  • 它能解决你们实际面临的问题
  • 整个团队都能理解它
  • 团队能够持续维护并改进它。
  • 每次,始终如一地运用简单的设计,总比只让设计者自己理解的精妙设计更有效。

    为何这些并不令人兴奋,却又行之有效

    长期维护改变了“良好开发”的含义:编码速度的重要性低于可读性、未来修改的便捷性、其他开发人员的需求以及实际运行表现。以下是一份实用检查清单:

    • 组件保持小巧且功能单一
    • 全局状态为简短、有明确目的的列表
    • 名称能体现设计意图
    • 每个新包的添加都有合理依据
    • 文件夹结构遵循既定的规则
    • 持续进行代码重构
    • 关键用户流程都有测试
    • 生产环境中的错误一目了然
    • 在选项相同的情况下,选择更简单的方案

    这些规则并不引人注目,但这或许正是它们能够长久有效的原因。关于架构层面的实践方法,请参阅让前端代码库多年保持可维护性的十种架构习惯

    核心要点

    • 大型 React 应用出现问题往往并非因为 React 本身,而是因为各种小捷径的累积:一个庞大的组件、一个难以理解的工具函数、一个不必要的包,以及本应临时使用的变通方法。
    • 可维护性并非事后再添加的要素,而是由众多小决策逐个积累而成的结果。
    • 养成这些习惯的最佳时机是在问题出现之前,那时拆分组件或拒绝使用某个依赖项只需几分钟时间,而非数周。
  • 当您被某种巧妙的解决方案所吸引时,请记住,六个月后负责维护它的人——很可能是您自己——将需要在没有当前上下文的情况下理解其运作原理。
  • 相关阅读