前端债务作为变更成本:诊断日益扩大的 React 代码库问题
学会识别那些导致大型 React、Redux、TypeScript 和 Next.js 应用修改成本极高的累积性决策,以及如何逐步偿还这些技术债务。
来源: Frontend Technical Debt: The Problems Nobody Notices Until the React App Becomes Huge
大型 React 应用程序几乎不会因为某一次灾难性的代码提交而变得难以维护。它们的性能下降是源于一系列看似合理但实则有害的细微决策:多出一个 useEffect,某个本不必全局化的 Redux 状态片段,一个“临时”的 500 行组件,一个不断接收更多属性的共享组件,一个收纳所有无明确归属内容的 utils 文件,将 API 类型复制到三个不同的模块中,以及一套没人敢再修改的样式规范。这些决策在做出时看起来都没有问题。真正的麻烦在于当数百这样的决策累积起来并开始相互影响时。
这种累积现象就是我们所说的前端技术债务,而本指南的核心思想很简单:最严重的前端问题往往并非由某段糟糕的代码导致,而是由一系列微小的决策累积而成的。下面您将看到这种债务在 React、Redux、TypeScript 和 Next.js 代码库中最常见的表现形式,每种形式为何会带来高昂代价,以及一种无需暂停产品开发即可减少技术债务的实用方法。
整洁的代码库如何逐渐变得混乱
一个新项目通常会以一种任何人都能一目了然的结构开始:
src/
components/
pages/
hooks/
services/
store/
即使从未看过该代码库的开发者也能猜出新的钩子或服务应该放在哪里。随后,产品功能扩展、团队规模扩大,功能列表也不断增加。几年后,同样的代码结构往往就会变成这样:
src/
components/
Button/
Modal/
Table/
UserTable/
UserTableNew/
CommonTable/
GenericTable/
SharedTable/
...
hooks/
useUser.ts
useUsers.ts
useUserData.ts
useFetchUser.ts
useUserDetails.ts
...
utils/
helpers.ts
common.ts
misc.ts
formatter.ts
newHelpers.ts
oldHelpers.ts
store/
user/
users/
userData/
userProfile/
...
注意这种模式:多个功能重叠的表格,五个以略有不同方式获取“用户”信息的钩子函数,仅通过“new”和“old”来区分的辅助文件,以及为本应属于同一领域的内容准备的四个存储文件夹。这一切并非有人刻意设计,而是通过一次次拉取请求逐渐形成的。这正是此类技术债务如此危险的原因——从来都没有一个明显的时刻表明必须立即进行修复。
悄然变成永久性的捷径
最常见的技术债务来源就是延迟清理。某个功能今天就必须发布;而规范的实现需要两天时间,快速的实现则只需三小时。选择三小时的方案并不一定意味着工程质量差,有时这恰恰是正确的商业决策。
当快捷方案的存在理由消失后,风险便会出现。临时代码极容易变成正式的生产环境基础设施,而当初做出这些决策时,往往看起来并无危害:
“我们就把它放到现有的组件里吧。”“等版本发布后再重构它。”“目前先让它留在Redux中。”“以后可以将其通用化。”“反正不会有其他功能需要用到它。”
半年过后,当初的背景早已不复存在。没人记得为何要那样编写代码,编写代码的人可能已经调到了其他团队,该代码原本满足的需求也发生了变化,而且现在还有另外三个功能依赖于它的行为。要撤销一个耗时三小时的临时方案,往往需要三周时间。
当一个组件掌控整个功能时
“臃肿的组件”是最容易识别的症状之一。它通常始于在一个函数中列出看似合理的任务列表:
function Checkout() {
// fetch products
// validate coupon
// calculate totals
// manage address
// handle payment
// track analytics
// show modals
// handle errors
// render UI
}
每项新需求都会被放入同一个地方,因为相关代码已经存在于那里。最终该组件会负责处理:
- 与 API 的交互
- 业务规则与验证
- 状态管理
- 分析功能与权限检查
- 导航与错误处理
- 样式设计与渲染
React 并非真正的罪魁祸首。实际问题在于过多的职责溢出了组件边界。组件本应是渲染内容以及处理该界面相关 UI 状态的合适场所,而不应成为功能涉及的所有问题的容器。定价规则、优惠券验证和数据分析等功能可以放在独立的模块或自定义钩子中,这样无需进行渲染即可进行测试。
可复用组件带来的成本高于其节省的价值
在职业生涯初期,可复用性似乎无疑是一种优点。但随着经验积累,人们会意识到每种抽象方式都有其代价。以一个被设计为“可配置”的表格为例:
<DataTable
data={data}
columns={columns}
loading={loading}
selectable
searchable
sortable
pagination
serverPagination
expandable
virtualized
stickyHeader
renderEmptyState={...}
renderLoadingState={...}
onRowClick={...}
/>
从技术层面来看,它是可复用的。不过它是否易于维护则是另一个问题,而诚实的答案是“视情况而定”。每增加一个布尔标志,组件就需要支持的组合数就会翻倍;而每个需要稍作修改的新使用者,也只会增加一个新的属性,而非另一个组件。
比“能否复用?”更有意义的测试标准是这种抽象是否能让下一个功能的开发更加容易。一个包含三十个属性的组件往往根本算不上可复用的架构,它只不过是几个独立的组件被封装在同一个API背后而已。正如通过组合与插槽解决React属性过载问题一文所述,让使用者组合更小的组件往往是更好的解决方案。
utils文件夹如何隐藏你的业务领域
几乎所有规模较大的前端项目都会生成一个utils目录,起初这并无大碍。但随着时间推移,它往往会变成这样:
utils/
formatDate.ts
formatCurrency.ts
userHelpers.ts
permissions.ts
productHelpers.ts
validation.ts
apiHelpers.ts
stringUtils.ts
common.ts
helpers.ts
一旦目录出现这种状况,就没人能判断新逻辑该放在何处,于是下一个辅助函数就被放入common.ts中,之后的函数也是如此。代码虽按文件类型进行组织,但业务领域却隐而不现。权限检查、产品规则以及API相关代码都与字符串格式化功能混杂在一起。
更好的问题不是“这个功能该放在哪个工具文件里?”,而是“这条逻辑属于哪项业务能力?”以功能为导向的架构能让答案一目了然:
features/
checkout/
components/
hooks/
services/
types/
utils/
products/
components/
hooks/
services/
types/
payments/
components/
hooks/
services/
types/
这并非要求在所有地方都使用功能文件夹。核心原则比目录名称更为重要:代码应按照所有权和职责进行分组,而非仅仅依据文件类型。
将 Redux 用作通用状态存储
Redux 确实是很有用的工具,但将所有内容都放入全局状态中是造成前端技术债务的最快方式之一。通常情况下,状态存储会从那些确实适合作为全局状态的模块开始:
auth
user
permissions
cart
products
checkout
notifications
随后它又会吸收那些实际上只与某个界面或某次交互相关的内容:
modal
filters
selectedTab
searchText
formState
temporaryUIState
最终,这个存储会变成一个适用于整个应用的全局数据库,用于存储两个组件可能需要的任何数据。从技术上讲,几乎任何值都可以放入 Redux 中,因此“可以吗?”并不是正确的问题。正确的疑问应该是状态是否真的需要是全局的。将状态分为三类有助于解决这个问题。
应保持为本地状态的 UI 状态
这类值通常应该存储在使用它们的组件中,通过 useState 或 useReducer 来管理:
isModalOpen
activeTab
inputValue
hoveredItem
expandedRow
将它们移到全局存储中会导致每个打开的模态框或悬停的行都成为整个应用需要处理的内容,还会为简单的交互增加不必要的间接操作。
需要缓存策略的服务器状态
后端拥有的数据有不同的处理需求:
products
orders
users
invoices
这里的难题包括缓存、去重、重新获取数据以及状态失效处理。这些问题需要采用有规划的数据获取方式(专门的服务器状态库或类似模式),而非那些将响应内容直接复制到存储中的手动编写的还原函数。更多关于权衡取舍的内容可参阅使用 React Query 和 Redux 重新思考大型应用中的服务器状态。
真正适用于整个应用程序的状态
有些问题确实会影响到整个产品:
authentication
permissions
application-wide preferences
cross-feature workflows
这些都是适合作为全局存储的候选对象。关键点并非“避免使用 Redux”,而是仅凭可用性并不足以成为将状态设为全局的理由。
TypeScript 的隐患:失去意义的类型
TypeScript 是处理大型前端项目时最宝贵的工具之一,但它并不会为你设计代码结构。完全可以在整个应用程序中运用这种模式,同时仍称该项目为类型化项目:
const data: any = response.data;
每当出现 any 时,就在数据进入应用程序的那一刻停止了类型检查,因此当 API 结构发生变化时编译器无法发出警告。另一个常见的问题是在多个文件中独立声明了相同的域类型:
type User = {
id: string;
name: string;
}
如果多个团队都采用这种方式,就会出现大量几乎重复的代码:
User
UserType
UserModel
UserResponse
UserDTO
UserData
开发者们往往花费更多精力去决定该导入哪种类型,而非真正从类型系统中获益。追求“所有事物都有类型”的目标其实偏离了重点。真正的目标是让系统中的重要契约,如 API 响应、领域实体和组件接口,都能被明确地定义且仅定义一次。优秀的 TypeScript 能让无效状态难以体现;而劣质的 TypeScript 只会生成更多文件。一个有用的区分方式是:为 API 返回的数据使用传输类型,为 UI 操作处理的数据使用领域类型,并在单一位置进行转换,而非创建六个彼此关联不紧密的变体。
当 useEffect 成为默认的逃避手段时
效应函数是另一类容易导致问题逐渐累积的常见来源。它始于一个对某个值做出反应的单个效应函数:
useEffect(() => {
// do something
}, [something]);
接着,第二个效应函数则负责保持两个值的一致性:
useEffect(() => {
// synchronize something
}, [a, b]);
第三个存在主要是为了修正前面那个的运行行为:
useEffect(() => {
// fix previous effect
}, [data]);
该组件会变成一系列触发其他效应的链式结构,这不仅难以理解,还容易导致额外的渲染以及顺序错误。在这个阶段,掌握 React 的能力更依赖于对数据如何在组件中流动的清晰理解,而非 API 知识。
一个典型的例子是将派生数据存储在状态中并通过效应进行同步:
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
这样会先渲染一次显示过时的空字符串,然后运行效应,之后再重新渲染。其实可以直接在渲染过程中计算该值:
const fullName = `${firstName} ${lastName}`;
这看似只是微不足道的差异,但在整个应用中这些小选择会累积起来。effects用于与React之外的系统同步,比如订阅、计时器或DOM,而非用一个状态值推导出另一个状态值。更深入的讲解可见于停止使用useEffect同步状态。
Next.js中的服务器/客户端边界
现代的Next.js为架构决策增加了新的维度。除了决定由哪个组件来渲染内容外,还必须确定代码应在何处运行:服务器端、客户端,还是两者皆有。这一边界本身也是一种架构选择。
一个常见的错误是,因为某个小元素需要交互功能,就将其所在的部分组件树都转换为客户端组件。相关指令应放在页面文件的顶部:
"use client";
现在整个页面会以客户端 JavaScript 的形式发送到浏览器:
export default function ProductPage() {
// entire page becomes client-side
}
这一切都是因为一个按钮需要一些本地状态:
const [isOpen, setIsOpen] = useState(false);
这虽然方便,但这种便利性会在其他方面带来代价:更多 JavaScript 会被发送到浏览器,原本可以在服务器端完成的数据获取现在必须在客户端进行。在 App Router 中,从 "use client" 文件导入的所有内容都会成为客户端代码包的一部分,因此该指令的位置所决定的影响远不止你希望使其具备交互功能的那个组件。
采用分层思维会有所帮助,尽量将交互功能下放到代码结构的更深层次:
Server
↓
Data fetching
↓
Feature boundary
↓
Interactive client component
↓
Small local state
而非使用扁平化的简化方式:
Everything → Client Component
实际上,这通常意味着将交互按钮提取为独立的客户端组件,而将页面、数据加载以及大部分标记内容保留在服务器端。
样式债务依然是债务
团队往往将技术债务视为 JavaScript 和 TypeScript 的问题。样式同样会不断积累债务,尤其是在那些同时使用多种技术的方法的长寿命产品中:JS 中的 CSS、styled-components、CSS 模块、实用类、旧的全局样式表,以及多个设计系统。迟早,组件库中会出现类似这样的情况:
Button
ButtonV2
PrimaryButton
NewButton
StyledButton
CommonButton
LegacyButton
此时就没人能判断哪个按钮是正确的了。样式技术很少是根本原因,真正的问题在于缺乏对设计系统的统一管理以及清晰的组件边界。成熟的前端架构能够为这类问题提供解决方案:
- 哪些组件属于公共共享集?
- 哪些组件仅存在于某个特定功能中?
- 每个组件由哪个团队负责?
- 添加变体的标准流程是什么?
- 如何推出会破坏现有功能的变更?
- 哪些内容符合纳入设计系统的标准?
- 哪些内容必须保持本地化,绝不能升级为共享组件?
何时重复优于抽象
对于经验丰富的开发者来说,这一观点往往最难接受。假设目前有两个外观上相似度约为90%的组件:
ComponentA
ComponentB
人们的本能反应是将它们合并为一个:
<UniversalComponent />
不过,有时复制代码是更经济的选择,因为你还不知道这两个模块是否会继续朝同一方向发展。如果它们出现分歧,合并后的组件就会充斥着各种条件判断,而对其中一个调用方的任何修改都可能破坏另一个调用方。适度的复制是有益的;而过早进行抽象则可能会造成长期存在的耦合关系。
一个实用的默认做法是:先写两遍代码,分析其中的模式,只有当出现第三种使用场景且其结构得到验证时才进行抽象。这虽非绝对法则,却是一条合理的起始准则。
已不再必要的依赖关系
大型前端项目会迅速积累大量依赖。一个已有五年历史的代码库很可能包含以下方面的库:
- 状态管理、表单处理及验证功能
- 日期处理、图表展示及动画效果
- UI组件库及样式设计工具
- 数据获取功能
- 测试相关工具
有些几乎不再被使用,而另一些则已被浏览器功能或框架内置的功能所取代。移除它们似乎存在风险,因此这些代码依然保留,最终导致清单逐渐变成一份没人愿意阅读的架构文档:
package.json
依赖管理并不仅仅是应用安全补丁那么简单。它意味着要定期评估,考虑到维护、升级以及打包所带来的成本,每个依赖项是否仍然有存在的必要。
真正的代价:更慢、风险更高的变更
代码债务之所以成问题,并非因为工程师觉得杂乱的代码令人不适,而是因为它改变了软件发布的经济成本结构。在健康的应用程序中,请求会经过简短的流程处理:
Feature request
↓
Small change
↓
Tests
↓
Deploy
而在债务严重的应用程序中,同样的请求却会变成一项复杂的调查任务:
Feature request
↓
Find where this behavior lives
↓
Understand 5-year-old code
↓
Check Redux dependencies
↓
Check shared component
↓
Check side effects
↓
Check styling overrides
↓
Change code
↓
Something else breaks
↓
Fix regression
↓
Add another workaround
第二条处理流程中的每一步都在耗费时间用于理解而非实现功能,而最后两步尤为关键:一次改动会破坏其他无关的部分,修复措施又会带来新的变通方案。这就是代码库不断累积“利息”的过程,也就是技术债务的真正含义。
为何“仍然能运行”是个具有误导性的信号
一个应用程序即便背负着巨大的技术债务,依然可以完美运行。页面能够加载,用户可以登录,订单可以处理,控制面板也能正常显示,持续集成测试结果也为绿色,因此大家都会认为代码状况良好。但当新功能出现时,一个看似简单的改动却需要两周时间才能完成。就在那一刻,技术债务的问题便显现出来了。
“该应用程序能正常运行吗?”其实是一种效果不佳的健康检查方式。尤其当系统规模不断扩大时,一个更有价值的问题是:安全地修改这个应用程序需要付出多大的代价?
无需重写即可逐步解决问题
面对混乱的代码库,人们往往会倾向于彻底重建。但六个月后的典型结果往往是可预见的:
- 旧应用程序仍在生产环境中运行
- 替代方案仅完成了一部分
- 现在需要维护两个系统
- 团队成员精疲力尽
- 产品开发工作陷入停滞
逐步进行架构优化几乎总是更有效的办法。首先需要找出问题真正出在何处。
确定成本较高的部分
不要急于对所有代码进行重构。相反,应找出开发人员反复遇到困难的模块,例如:
Checkout
User Management
Search
Dashboard
Reporting
针对每个候选模块,留意以下迹象:
- 在相同区域不断出现的漏洞
- 人们不敢轻易修改的文件
- 几乎出现在每个拉取请求中的组件
- 新员工需要最长时间才能掌握的产品部分
- 其修改会波及到无关模块的功能
那些反复出现问题的区域就是代码债务的高发点。
衡量当前进行修改的成本
无需复杂的工具,只需跟踪少量指标即可:
Average time to implement feature
Average PR size
Number of files touched
Regression frequency
Build time
Test execution time
Deployment frequency
将债务与这样的工程成果挂钩,能大大简化优先级排序的工作,因为你可以证明某个特定领域拖慢了交付进度,而无需争论其代码质量不佳。
在选择架构模式之前先定义依赖规则
在讨论整洁架构或功能切片设计之前,先解决一个更基础的问题:什么可以依赖什么?简单的层次结构方向可能如下所示:
app
↓
features
↓
shared
在此框架下,某个功能可以依赖共享的构建模块:
features/product
↓
shared/ui
shared/lib
但共享代码绝不能反向影响某个功能:
shared/ui
X
features/product
具体的架构会随着时间而变化;正是依赖关系的方向维持了其一致性。一旦共享的UI开始从某个产品功能中导入代码,它就不再属于共享资源,此时每个使用该UI的组件都会隐式地依赖那个功能。通过针对导入路径设置代码检查规则,可以轻松实现这些规则的自动执行,从而将架构设计意图转化为CI在每次拉取请求时都会进行的检查内容。
React与Next.js的面向功能的布局方式
并没有一种结构适用于所有项目,但按功能进行组织能够为代码划分出自然的边界:
src/
app/
routes/
providers/
layouts/
features/
checkout/
components/
hooks/
services/
state/
types/
products/
components/
hooks/
services/
state/
types/
users/
components/
hooks/
services/
state/
types/
shared/
ui/
hooks/
lib/
types/
复制这些文件夹名称并非重点。关键在于明确各部分的归属:app负责将路由、提供者及布局连接起来,每个功能模块拥有自己的组件、钩子、服务、状态和类型,而shared则仅存放真正通用的内容。如需了解其他方案的对比,可参阅React文件夹结构模式的实用对比。
大型前端项目中应避免的习惯
在参与或维护庞大的React代码库时,需注意以下几点:
- 默认全局化状态。大多数值根本无需离开其所在组件。
- 过早抽象化。应先理解各种差异,再进行抽象处理。
utils.ts 文件。找出应负责处理该逻辑的模块。从编写更优秀的代码到构建可灵活调整的系统
随着经验积累带来的最显著变化是目标的转变,而非新技术的出现。初期,人们自然会思考如何把某段代码写得更好;而后期,则会考虑如何让后续修改代码的人能更轻松地操作系统。这种思维方式的转变会改变你处理组件与抽象概念、使用 Redux 进行状态管理、运用 TypeScript 和 Next.js、进行样式设计、编写测试以及规划整体架构和依赖关系的方式。你不再专注于优化单个文件,而是开始提升系统自身的演化能力。
技术债务是工具,而非敌人
你无法彻底消除技术债务,试图这么做是错误的。每个产品都需在各种需求间做权衡,而很多情况下这些权衡本身就会导致债务的产生:快速发布产品、同时满足多项业务需求、确保旧版浏览器正常运行、调整产品方向,或是整合收购的应用程序。即便是一个出色的架构决策,一旦周边系统发生变化,也可能会变成债务。
因此,“零债务”并非正确的目标。现实的目标应是让团队能够理解这些债务、将其控制在一定范围内,并在它们开始影响产品交付时逐步偿还。
究竟是什么让大型应用难以维护?
明确其根本原因非常重要,因为这些原因往往并非人们通常指责的对象:
- 问题不在于众多组件本身,而在于它们之间那些无人能理清的关联关系。
简而言之,技术债务并非指那些外观难看的代码,而是那些让修改变得不必要地昂贵的代码。最好的前端架构并非那种从一开始就完美无缺的,而是那种即便五年后仍能让团队高效工作的架构。
功能开发前的检查清单
在向 React 代码库添加新功能之前,先思考以下问题:
- 让这个状态变为全局状态真的有必要吗?
- 这些数据是由后端管理的,还是仅仅属于界面状态?
- 该组件是否承担了额外的职责?
- 新的抽象层真的解决了问题,还是只是让代码看起来更整洁?
- 这条业务规则由哪个功能模块负责?
- 这段代码能否改在 Next.js 的服务器上运行?
- 这里真的需要使用 Effect 吗?
- 这会不会增加另一个需要维护的包?
- 该组件真的在多个功能模块中被使用吗?
- 新手无需询问编写者就能理解这段代码吗?
这些问题往往比另一份 React 最佳实践清单更有价值。
值得进一步探讨的主题
本指南中的几个要点本身就需要更深入的讨论:
- 何时 Redux 才是真正合适的选择,与本地状态、Context、Redux Toolkit、RTK Query、专门的服务器状态处理机制、URL 状态及表单状态进行对比。
- 如何在不影响生产环境的情况下重构 1,000 行代码的组件,通过分步案例进行讲解。
- 可复用组件是否会让应用变得更差,涉及属性爆炸问题、组合模式以及设计系统。
- Next.js 中的服务器端组件与客户端组件,重点探讨决策边界而非
"use client"的功能。 - 大规模使用 TypeScript,包括
any、泛型、DTO、领域模型与 API 类型,以及代码重复与类型推断问题。 - 什么应该取代
utils文件夹作为逻辑代码的存放处。
Change cost
Bug frequency
Dependency coupling
Component size
State complexity
Build time
Testability
Developer onboarding
核心要点
- 前端债务是由诸多小型、合理的决策累积而成的;应关注整体趋势,而非单个代码提交。
- 评估代码库的健康状况应依据变更的成本与风险,而非应用当前是否正常运行。
- 默认情况下将状态保留在本地,把服务器数据视为缓存问题处理,仅对真正具有跨领域特性的内容进行全局化处理。
- 相比文件夹名称或特定方法论,更应注重边界划分与依赖方向。
- 应在经过评估的高风险区域逐步偿还债务,而非寄希望于整体重写。