Angular 在生产环境》系列第 4 部分

在处理大型 Angular 应用时,我注意到一个现象:组件很少会在一夜之间变得难以维护,而是逐渐演变的。当你添加一个功能,另一个功能,以及新的弹窗、新的 API 调用和新的权限检查时,没有人会刻意创建一个千行组件。它只是因为每次添加一点逻辑都比创建另一个抽象更容易。起初,这似乎不是问题,应用仍然可以工作。但最终,当你打开文件时,你会意识到你看到的不再是一个 UI 组件,而是整个功能打包在一个类中。这通常就是每一次新改动都开始比预期花费更长时间的转折点。


组件大小是症状,而非问题

我过去常犯的一个错误是用代码行数衡量组件。800 行的组件不一定就不好,同样,150 行的组件也不一定就好。重要的是职责。我见过一些相对较小的组件却负责:

  • 加载数据
  • 转换数据
  • 验证表单
  • 处理权限
  • 打开对话框
  • 管理应用状态
  • 格式化值
  • 响应路由变化
  • 调用多个 API

从技术上看,它们并不大,但从架构角度,它们承担了应用中多个不同部分的工作。
如今每当我打开一个组件时,我通常会问自己:这个组件是否有明确的职责?如果答案不明显,通常意味着组件已经开始朝着错误的方向增长。

业务逻辑逐渐接管

大多数超大的组件并不是一开始就包含业务逻辑。它是一点一点添加的。像这样的代码一开始看起来完全合理:

loadUsers() {
    this.userService.getUsers().subscribe(users=> {
        this.users=users;
    });
}

进入全屏模式 退出全屏模式

几周后,这个方法已经演变为:

loadUsers(){
    this.userService.getUsers().subscribe(users=> {
        this.users=users.filter(user=> user.active);
        this.totalRevenue=users.reduce(
            (sum, user) => sum + user.revenue,
            0
        );
        this.canExport=
            users.length < this.subscription.maxUsers;
        this.chartData = this.buildChart(users);
    });
}

进入全屏模式 退出全屏模式

这里的每一步并不一定有错。每个新需求在添加时都有其合理性。问题在于,组件已经悄然不再只负责展示用户。它现在负责过滤数据、计算业务指标、检查订阅限制以及准备图表数据。
随着项目增长,这些方法通常会继续膨胀。最终,修改一处逻辑意味着需要理解周围发生的一切。如今,每当我注意到业务规则在组件中堆积时,我会尝试将它们移到更能体现其用途的地方。
与其这样:

loadUsers() {
    this.userService.getUsers().subscribe(users => {
        this.users = users.filter(user => user.active);
        this.totalRevenue = users.reduce(
            (sum, user) => sum+user.revenue,
            0
        );
    });
}

进入全屏模式 退出全屏模式

我更倾向于类似这样的写法:

loadUsers() {
    this.dashboardService
        .getDashboardData()
        .subscribe(data => {
            this.user = data.users;
            this.totalRevenue = data.totalRevenue;
        });
}

进入全屏模式 退出全屏模式

组件变得更简单,并不是因为行数减少了,而是因为它不再拥有业务决策权。它的职责仅仅是展示信息。


组件应协调,而非实现一切

随着时间的推移,我开始将 Angular 组件更多地视为协调者。它们的职责是连接应用中的不同部分,而不是自己实现每一部分。

例如,一个仪表盘组件可能需要请求数据、将数据传递给子组件、对用户交互做出响应以及触发导航。这已经足够了。它不需要知道如何计算发票、如何评估权限、如何生成报告或如何格式化导出。

每当我看到类似这样的方法共存于同一个组件中:

calculateInvoice()
validatePermissions()
buildChartData()
generateStatistics()
formatExport()
sendNotification()

进入全屏模式 退出全屏模式

我会立即思考这些职责是否属于其他地方。

目标并不是仅仅因为“这样更干净”而把代码移到服务中。目标是让每个文件负责解决一类问题。这通常会让未来的改动变得更容易,因为你不再担心会破坏无关的功能。


单个屏幕不等于单个组件

我早期的一个误解是认为每个页面都应该对应一个大型组件。毕竟,它只是一个屏幕。为什么要拆分?后来,这些页面开始膨胀。想象一个仪表盘包含:

├─ 用户摘要
├─ 销售图表
├─ 最近订单
├─ 通知
├─ 活动提要
├─ 快捷操作
└─ 报表

进入全屏模式 退出全屏模式

从用户的角度看,这是一个页面。但从开发角度看,它是多个独立的功能。每个功能最终都可能需要

  • 自己的 API 请求
  • 自己的加载状态
  • 自己的权限
  • 自己的交互
  • 自己的测试。

仅仅因为它们出现在同一个屏幕上就把所有内容放在一个组件中,往往会造成不必要的耦合。将功能拆分为专用组件不仅可以减少文件大小,还能让职责更清晰。
负责通知的人不应需要了解报表是如何生成的。同样,修改销售图表不应有破坏最近订单的风险。随着应用增长,这些边界变得越来越有价值。


大型模板通常隐藏同样的问题

类似的情况也发生在模板中。起初,HTML 很容易理解。然后出现了一些条件判断、几个循环、一些权限检查,以及根据用户角色显示不同布局。最终,你会发现自己需要滚动数百行才能理解页面某个部分的渲染方式。
一个简化的例子可能如下所示:

        </app-admin-panel>
        <app-basic-panel
            *ngIf="dashboardMode === 'basic'">
        </app-basic-panel>
    </div>
    <app-user-panel
        *ngIf="!user.permissions.includes('admin')">
    </app-user-panel>
</div>

进入全屏模式 退出全屏模式

这些条件单独看都没有错。问题在于,每一个新需求都会在模板中增加另一个分支。最终,理解页面显示的内容变得几乎和理解组件本身一样困难。每当一个部分开始拥有自己的逻辑时,我通常会停止问 “我可以继续在这里添加条件吗?”,而开始问 “这是否应该成为自己的组件?” 答案往往是肯定的。


输入和输出可以揭示设计问题

在组件之间传递数据是完全正常的。这正是 Angular 组件的用途。但随着时间推移,我注意到一个有趣的现象:当一个组件开始接收过多输入时,通常是因为它承担了太多职责。例如:

@Component({ 
    selector: 'app-dashboard-card'
})

进入全屏模式 退出全屏模式

[user]="user"
[permissions]="permissions"
[settings]="settings"
[statistics]="statistics"
[reports]="reports"
[loading]="loading"
[theme]="theme"
[subscription]="subscription"
(refresh)="refresh()"
(delete)="deleteUser()"
(export)="exportDat()">
</app-dashboard-card>

进入全屏模式 退出全屏模式

并没有一个神奇的数字来界定何时出错。但每当我发现自己几乎把整个页面状态都传递给一个子组件时,我通常会停下来思考。有时子组件已经不再是一个组件,而是另一个值得进一步拆分的功能。输出也是如此。如果一个子组件发出五六个不同的事件,值得思考它是否在尝试做太多不同的事情。


表单的增长速度往往快于其他一切

如果说有一个地方最容易出现超大组件,那就是表单。它们从一小部分字段和一个提交按钮开始,然后业务需求陆续到来。条件字段、动态验证、基于角色的可见性、自动保存、附件、外部查询……很快,负责表单的组件就包含了大部分应用的业务规则。我已经学会不与此抗争。大型表单天生复杂。有帮助的做法是围绕它们分离职责。例如:

  • 一个组件负责渲染个人信息部分
  • 另一个处理地址
  • 另一个管理附件
  • 共享验证逻辑放在专用服务中
  • 可复用控件成为独立组件

表单对用户来说仍是一个统一的体验,但代码库会变得更容易导航。


拆分组件的最佳时机比你想象的要早

我花了一段时间才学到的一个教训是,组件提取不应是最后的手段。很长一段时间里,我会等到组件变得难以维护时才拆分它。现在我尝试更早地做这件事。不是因为我喜欢创建更多文件,而是因为更小、更专注的组件往往可以独立演进。这意味着更少的合并冲突、更简单的审查、更清晰的归属,以及在修改现有功能时更少的恐惧。讽刺的是,提前拆分组件往往最终会写更少的代码。因为减少了重复、分支和无关联逻辑意外连接的地方。


结论

大型 Angular 组件变得难以维护,并不是因为 Angular 鼓励不良实践,而是因为功能不断堆积在同一个地方。每一个单独的改动都感觉合理,但综合起来,它们慢慢将一个 UI 组件转变为负责整个功能的东西。如今,我不会根据组件的大小来决定是否拆分。我会在注意到它解决了多个不同问题时进行拆分。这通常是未来改动会变得比必要更难的第一个信号。保持职责小巧不仅能提升可读性,还能让测试、调试和添加新功能变得不那么紧张。


本系列下一篇文章

第 5 部分:仅在生产环境中出现的 Angular 订阅问题

本系列之前的文章

第 1 部分:为什么 Angular 应用会随着增长而变慢
第 2 部分:Angular 包大小只是性能的一部分

第 3 部分:让大型应用感觉缓慢的 Angular 变更检测错误



感谢阅读!如果您的 Angular 应用随着时间的推移积累了性能或可维护性问题,这正是我帮助团队的工作内容:调试现有代码库、提升性能以及逐步重构。您可以在我的 Dev 个人资料中找到我的 Upwork 链接。