让前端代码库多年保持可维护性的十种架构习惯
阐述了诸如优化可删除性、明确的数据流以及隔离业务逻辑等结构化习惯,这些习惯有助于代码库在多年演变中依然保持可维护性。
任何能够长期存续的前端代码库最终都会分裂为两个不同的区域。
第一个区域可称为危险区。
那里混杂着各种状态管理器、临时拼凑的生命周期钩子、巧妙但难以理解的全局抽象层、尚未完成的实验,以及多年前编写、如今已无人能完全解释其功能的辅助函数。
没人愿意靠近它。
当有新的功能需求出现在该区域时,团队并不会根据实际工作的难度来评估任务量。
他们而是根据修改那些代码所带来的风险程度来决定工作量。
本应只需两天的修改工作往往会变成两周的工程,因为大家都明白大部分时间将用于回归测试而非功能开发。
还有另一个区域。
可以将其称为稳定基础层。
这些是多年前开发的模块,它们在多次框架迁移、界面重设计、产品方向调整以及工程团队领导层更迭中依然稳定运行。
它们几乎从不会导致服务中断。
它们的编写方式并无特别巧妙之处。
当有新人加入团队时,他们只需打开其中一个文件,无需详细讲解就能理解其功能,并在一两天内提交第一个拉取请求。
这才是值得关注的重点。
经久不衰的代码通常并非系统中最先进的代码,而是最为简洁的那些。
经验丰富的工程师都知道,软件所处的环境永远不会与最初开发时相同。
需求会变化,团队会重组,依赖项会被替换,框架会不断更新,公司的战略也会调整。
人们离开公司。
新来的工程师对某些功能为何以特定方式构建一无所知。
因此,值得提出的问题不是:
“当前这个设计看起来有多简洁?”
而是:
“五年后修改它需要付出多大的代价?”
以下是一些有助于实现长期稳定性的结构化习惯。
1. 以可删除性为优化目标,而非可重用性
许多架构指导都侧重于重用。
让组件具备可重用性。
构建通用服务。
添加扩展点。
设计插件架构。
为尚未实现的代码编写抽象层。
重用固然有其价值。
但在一个持续演进的产品中,还有另一项往往更为重要的特性:
删除某项内容的难易程度。
功能并非一成不变。
它们会被替换掉。
它们会被重新构建。
它们会融入其他功能中。
有时企业也会对其失去兴趣。
想象一下一个严格按文件类型组织的 React 项目:
src/
components/
BillingTable.tsx
UserModal.tsx
SubscriptionCard.tsx
hooks/
useBillingData.ts
useUserData.ts
useSubscription.ts services/
billingApi.ts
userApi.ts
subscriptionApi.ts
表面上看,这种方式很整齐。
每种类型的文件都有对应的文件夹。
但假设公司在18个月后决定完全放弃计费功能,那么与计费相关的所有内容到底存放在哪里呢?
你不得不在多个不同的目录中寻找。
你找到并删除了 BillingTable.tsx,接着又发现了 useBillingData.ts。
接着是一些专门为计费功能编写的类型定义。
然后是一个只有计费功能会调用的辅助函数。
接着是一份样式表。
接着是一次API调用。
接着是一个测试用例。
接着还有一个最初作为计费钩子但后来被重命名的钩子函数。
该功能虽已从产品中移除,但其残留部分仍散布在代码库各处。
这就是死代码会随着时间逐渐积累的原因。
按功能进行组织能让边界更加清晰:
src/
features/
billing/
components/
BillingTable.tsx
hooks/
useBillingData.ts
services/
billingApi.ts
types.ts
index.ts
现在计费功能有了明确的归属位置。
如果公司决定取消该功能,第一步操作非常简单:
src/features/billing/
删除对应的文件夹。
TypeScript会随即显示出所有仍依赖该功能的代码。
这种依赖关系要处理起来容易得多。
可删除性也是一种可维护性体现
当能够立即明确某个模块的职责所在时,它的维护就会变得更加容易。
这也是为什么在讨论如何扩展大型 React 代码库时,基于功能的文件夹结构会不断被提及。那些负责开发大型应用程序的团队通常会选择将相关功能放在一起,因为这样能缩小任何变更带来的影响范围。
重点并非追求一个完美组织的目录结构。
关键在于能够快速回答以下问题:
“如果这个功能明天就消失了,我需要删除什么?”
如果这个问题难以回答,那说明你的功能边界可能划分得过于宽泛。
2. 不要将 shared/ 变成垃圾箱
一旦团队转向基于功能的架构,还有一种常见的陷阱会出现。
任何明显不属于某个功能的代码都会被放入 shared/ 目录中。
几个月后,就会出现类似这样的情况:
shared/
utils/
helpers/
common/
services/
components/
hooks/
types/
到那时,shared/ 目录里的代码就会悄悄占到整个代码库的一半。
这又会引发另一种耦合问题。
一个好的指导原则是:
代码应被放入共享文件夹,是因为多个功能确实需要使用相同的概念,而不是因为无法决定该把代码放在其他地方。
通用的按钮组件很适合放在设计系统中。
认证客户端合理地应位于共享的基础设施层中。
日期格式化工具也可以被共享。
但像这样的函数:
calculateEnterpriseRenewalDiscount()
几乎可以肯定属于拥有该特定业务规则的功能。
切勿仅仅为了让目录结构看起来更整洁就急于将文件移入全局文件夹。
共享代码并非免费资源,因为任何涉及它的功能都会成为潜在的依赖项。
shared/目录越大,就越难以确定某项功能的具体负责人是谁。
3. 为外部依赖构建防护适配层
很有可能在某些依赖项被移除后,您的应用程序仍能继续运行。
今天您可能使用Axios,明天就可能会改用原生的fetch函数。
现在您可能使用某家分析服务提供商,几年后公司却可能会更换为另一家。
今天您可能集成某个认证库,但后续新的安全要求又可能会促使您转向其他认证方案。
一种脆弱的构建方式是将这些外部包直接导入到数十个组件中。
import axios from 'axios';
import { trackMixpanelEvent } from 'mixpanel-browser';
export function CheckoutCard() {
const handlePurchase = async () => {
await axios.post('/api/checkout', payload); trackMixpanelEvent('checkout_completed');
};
}
此时,UI层会直接知道正在使用的是哪个HTTP客户端以及哪个分析服务提供商。如果将这种模式应用到45个组件上,更换供应商就不再是一项局部操作——而会影响到整个代码库。
边界层能够实现依赖项的易替换性。例如:
// src/shared/lib/analytics.ts
import mixpanel from 'mixpanel-browser';export const analytics = {
trackCheckoutCompleted(
orderId: string,
amount: number
) {
mixpanel.track('checkout_completed', {
orderId,
amount,
});
},
};
现在,组件与由自身应用程序定义的概念进行交互:
analytics.trackCheckoutCompleted(orderId, amount);
它根本不知道底层使用的是Mixpanel、PostHog、Segment还是其他工具。即便供应商发生变化,应用程序所依赖的接口规范也可以保持完全不变。
但不要过度抽象化
这一点与前一点同样重要。
将依赖项封装在适配器中并不一定是良好的设计。如果你为使用的每一个小型库都创建自定义接口,最终编写的代码量可能会超过该库本身的代码量。
真正需要思考的问题是:
“日后替换这个依赖项会代价高昂,还是任由其在代码库中无限制扩散会有风险?”
如果答案是肯定的,那么使用适配器是值得的。否则,直接调用该依赖项可能更为简单且足够。
资深工程师并不意味着要在接触的每一样东西上都添加抽象层,而是意味着要明确设定边界,避免因省略这些边界而日后付出代价。
4. 明确的数据流优于魔法操作
让代码库变得混乱的最快方法之一,就是隐藏值的实际来源。
全局事件发射器正是典型的例子。
eventBus.emit('USER_UPDATED', {
id: user.id,
});
这一行仅告知有事件被触发,却未说明是谁在监听该事件。
你可能需要搜索整个代码库,才能找到类似这样的内容:
eventBus.on('USER_UPDATED', handler);
但实际上可能存在三个独立的监听器:其中一个可能是两年前添加的,另一个可能在修改全局状态,还有一个可能在发送分析数据。这样一来,最初那个函数所触发的真实行为就分散在整个应用程序中,而不再集中在一处。
现在将其与明确规定的契约进行对比:
interface UserCardProps {
user: User;
onUserRoleChange: (
userId: string,
newRole: Role
) => Promise<void>;
}
在这里,组件会明确声明它支持哪些操作,而父组件则会明确说明当这些操作被触发时会发生什么。数据流在页面上清晰可见。
的确,这种方式比触发匿名事件更为冗长。但这种额外的冗余是值得的,因为它能在代码中留下可追踪的路径。
当不熟悉该组件的人打开文件时,他们应该无需浏览整个代码库就能回答三个问题:
这些数据来自何处?
通常来自属性、路由参数、钩子函数,或是某个明确定义的数据访问层。
什么可以改变这些数据?
可见的函数调用、数据修改、操作触发,或是显式的状态更新。
当用户执行这些操作时会发生什么?
直接调用那些实现过程可被追踪的函数。
隐藏的行为越少,整个系统的理解难度就越低。
5. 将业务逻辑置于框架生命周期之外
框架并非一成不变的固定结构——这是前端开发中较为可靠的假设之一。
仅React就经历了数次重大变革:类组件逐渐被弃用,Hooks重新定义了有状态逻辑的组织方式。在许多项目中,Create React App已被Vite或框架原生的构建工具所取代。服务器端优先渲染以及新的路由方案,也改变了团队对数据获取方式及应用边界定位的思考方式。
你当前使用的框架很可能与五年后的标准框架大相径庭。而你的业务规则无论如何都必须持续正常运行。
以税务计算为例,那种脆弱的实现方式会将实际逻辑隐藏在 React hook 中:
export function useTaxCalculator(
cartItems: CartItem[]
) {
const [tax, setTax] = useState(0);
useEffect(() => {
let calculated = 0; // 60 lines of tax calculation,
// rounding rules,
// country logic,
// exemptions... setTax(calculated);
}, [cartItems]); return tax;
}
这样一来,税务计算就与 React 紧密绑定。进行测试时需要搭建 React 环境;从服务器操作中调用它则显得笨拙;在 Web Worker 中运行也同样不便;要将其移植到其他 UI 框架则是一项成本高昂的工作。
更好的方法是将这两者分开:
export function calculateTax(
cartItems: CartItem[],
countryCode: string
): number {
// Pure business logic
return totalTax;
}
此时 React 层只需简单地调用它即可:
const tax = calculateTax(cartItems, countryCode);
采用这种结构后,真正重要的逻辑完全不受 React 的影响。它可以在任何环境中运行,通过普通的单元测试即可进行验证。服务器进程可以直接重用它,而且在更换 UI 框架时也无需重新编写代码。
框架应置于最外层
用分层图来理解这一点会很有帮助:
┌──────────────────────────────┐
│ UI Layer │
│ React / Next.js │
├──────────────────────────────┤
│ Application Logic │
├──────────────────────────────┤
│ Domain Logic │
│ Pure TypeScript │
├──────────────────────────────┤
│ Infrastructure │
│ APIs / DB / Vendors / SDKs │
└──────────────────────────────┘
代码越靠近中心,就越不应依赖特定的框架或第三方库。
这并不意味着每个 React 项目都必须采用完整的“清洁架构”设计。
这意味着你需要明确区分代码中哪些部分是真正与 React 相关的,哪些部分代表了实际的业务规则。
这是两个截然不同的类别,将它们混为一谈才会引发问题。
6. 避免将 Hook 变成小型应用
这种模式在 React 代码库中屡见不鲜。
它通常是从简单的情况开始的:
function useUser() {
// fetch user
}
随后,需求逐渐增多。
function useUser() {
// fetch user
// loading state // error handling // permissions // analytics // transformations // caching // retry logic // business rules // notifications // feature flags
}
不久之后,原本简单的 Hook 就在看似普通的函数名背后悄然发展成了一个拥有500行代码的应用。
Hook 确实非常有用。
但仅仅因为 Hook 可以方便地访问 React 状态和效应,就不应让它成为所有功能的堆积场。
更好的做法是让 Hook 调用更小、更专注的函数:
function useUser() {
const user = useUserQuery();
const permissions =
calculatePermissions(user.data); return {
user: user.data,
permissions,
isLoading: user.isLoading,
};
}
通过这种结构,Hook 就变成了一个负责协调各部分功能的层。
架构不再被压缩在同一个函数中。
随着时间推移,维持那样的边界要容易得多。
7. 记录架构决策,而非无尽的维基文档
导致架构退化的最常见原因并非混乱的代码。
而是上下文丢失。
通常的情况是:某位开发者做出了一个不那么显而易见的决策。这个决策本身是合理的,当时团队中的每个人也都理解其背后的理由。之后该开发者便离开了。
数月后,另一位工程师偶然发现了这种特殊的实现方式,于是心想:
"为什么我们要用这种方式?肯定有更简洁的解决方案。"
于是他们重新编写代码,却在不知情的情况下再次引发了最初决策试图避免的问题。
以使用 Server-Sent Events 而非 WebSockets 构建的仪表板为例。
如果没有相关背景信息,新开发者很可能会这样认为:
“WebSockets是更现代的标准,我们改用它吧。”
但最初的团队选择SSE可能是因为许多企业客户使用限制严格的代理服务器,而这些服务器无法正确处理WebSocket连接。
仅从代码本身是看不出这一原因的。
这正是架构决策记录所要填补的空白。
例如:
# ADR 003: Use Server-Sent Events for Dashboard Feeds
## ContextOur dashboard requires real-time metric updates.We evaluated WebSockets and Server-Sent Events.## DecisionWe chose Server-Sent Events because:1. Communication is strictly server-to-client.
2. SSE uses standard HTTP infrastructure.
3. Browser reconnection is supported natively.
4. The solution works reliably within our enterprise network environment.## ConsequencesIf we later require client-to-server
bi-directional streaming, we should
re-evaluate this decision.
有了这样的记录,后续的工程师就无需从头重新分析决策依据。
他们可以立即了解其中的原因。
这非常简单
/docs/adr/
仓库中的文件夹能够保存多年的机构知识,否则这些知识会随着团队成员的离开而流失。
记录决策,而非所有内容
无需维护庞大到上百页的维基文档。
在大多数情况下,代码本身就应该足够清晰,能够说明其功能。
文档应记录的是代码本身无法表达的推理内容:
- 为何选择某种特定技术
- 为何放弃更显而易见的替代方案
- 为何存在某种不寻常的限制
- 为何看似不必要的变通方法实际上仍然必要
只有当文档能够记录那些否则会随相关人员一同消失的背景信息时,它才有存在的价值。
8. 为后续加入的工程师设计
这或许是检验架构能否持久使用的最简单标准。
设想这样一种情况:从明天开始,所有了解系统内部运作机制的人同时离开公司。
新来的团队还能继续维持系统的运行吗?
如果你的答案是否定的,那并不一定意味着开发人员能力不足,而是说明系统存在对特定人员知识的隐性依赖。
一个经久耐用的系统应当能够自动展现其关键行为。
新成员应该能够打开代码库,逐步找到以下问题的答案:
- 该功能在代码库中的位置在哪里?
- 哪个模块负责实现这一行为?
- 这些数据实际上来自何处?
这正是明确边界如此重要的原因。
新手不应为了理解代码的功能而不得不学习公司的全部历史。
代码库本身就需要承载足够的这些历史信息。
9. 将变更的影响范围控制在较小范围内
评估一种架构的一个好方法,就是统计一次常规变更需要修改的文件数量。
想象一个简单的功能需求:有人希望添加一个按钮,让用户能够将账单报告导出为CSV文件。
在紧密耦合的系统中,实现这一需求可能意味着要修改分散在各处的文件:
components/
hooks/
services/
utils/
types/
global state/
shared helpers/
为了实现一个按钮的功能,工程师可能不得不修改十几份文件。
与之相比,合理的功能结构应该是:
features/
billing/
components/
hooks/
services/
utils/
在这种情况下,同样的修改几乎可以完全在计费功能对应的文件夹内完成。
这正是人们所说的缩小代码变更的影响范围。
较小的影响范围能带来:
- 较少的功能退化问题
- 更简单的代码审查流程
- 更快的交付速度
- 较少的合并冲突
- 更便捷的测试
- 更安全的代码重构
实现这一目标并不需要复杂的架构设计,只需要设定与产品实际发展情况相契合的边界即可。
10. 停止为架构图而优化
即便有精美的架构图,也可能掩盖着极其难以使用的代码库。
你可以勾选所有这些选项:
- 遵循整洁架构的设计方式
- 应用SOLID设计原则
- 正确实现依赖倒置
- 用仓库模式封装数据访问
- 通过工厂模式生成对象
- 利用事件将各组件连接起来
- 在之上叠加多层抽象层
即便如此,仍可能把一个简单的功能变成耗时数天的苦差事。
架构的作用应是降低复杂性,而非增加它。如果架构层引入的概念比实际产品本身还多,那就说明出了问题。
通常情况下,最优秀的架构往往是那些没人愿意提及的,因为工程师可以直接阅读代码并理解其逻辑。在实践中,它可能表现为:
features/
billing/
checkout/
accounts/
再搭配一些简洁的实现:
shared/
ui/
lib/
再加上几个纯粹的业务逻辑函数。
这些听起来都不怎么了不起。但如果四年后它依然稳定且合理,那就正好实现了架构应有的作用。
高级工程师真正优化的是什么
高级工程师并不一定写出更复杂的代码,不同之处在于他们在编写代码之前会思考的问题。
经验较少的工程师可能会问:
“我要如何让这个代码可复用?”
而高级工程师则会问:
“它真的需要可复用吗?”
经验较少的工程师可能会问:
“我应该如何对这部分代码进行抽象?”
高级工程师则会问:
“这种抽象化设计究竟要解决什么具体问题?”
经验较少的工程师可能会问:
“这个工具函数应该放在哪里?”
而资深工程师则会问:
“实际上谁才是负责这段逻辑的人?”
经验较少的工程师可能会问:
“我们该如何为后续可能出现的需求做好准备?”
资深工程师则会问:
“哪一种未来的变化足以成为现在增加这种复杂性的理由?”
或许最能说明问题的是这样一个问题:
“编写这段代码的人离开后,它还会保持现在的样子吗?”
正是这个问题,标志着工程领域长期思维的起点。
总结:经久耐用的代码往往看起来平平无奇
那些在多年迭代后依然能良好运行的代码,很少是建立在最新框架、最巧妙的设计模式或最优雅的抽象之上的。它们通常只是那些结构清晰、决策务实且不追求花哨效果的代码——这样的代码让其他工程师无需追溯原始作者,只需打开代码库就能理解其运作方式。
其基本原则很简单:
- 按功能与责任归属进行组织。 功能应当易于定位,且在需要时也能轻松移除。
- 优先考虑可删除性而非最大化复用性。 并非每段逻辑都值得被提炼为共享的抽象概念。
代码库所能获得的最高赞誉并非:
"这个架构非常巧妙。"
而是:
"我理解它。"
因为五年后,最初的工程师很可能已经离开。框架可能会改变,设计也会随之调整,产品会不断进化,甚至业务本身也可能与现在大相径庭。
但只要边界清晰、逻辑简单,并且关键决策的依据被记录下来,代码就能与其他一切一起持续发展。
这才是真正经久耐用的软件应有的模样。
相关阅读
- 三种能优化 React 应用架构的 TypeScript 模式 — 了解 Repository、Observer 和 Builder 模式如何利用 TypeScript 的类型系统来打造更简洁、更易维护的 React 和 Next.js 代码库。
- 十种会悄悄损害代码库的常见 JavaScript 习惯 — 阐述了从松散相等性到状态修改等十种常见的 JavaScript 和 TypeScript 陷阱,并展示了替代每种陷阱的安全模式。