你的应用中不需要 BaseComponent。
在本文中,我将尽力解释为什么我认为BaseComponent在 Angular 项目中使用 .NET Framework 是代码共享的一个糟糕选择。而且,随着项目的发展,你会越发后悔。
虽然本文中的一些想法可能也适用于其他现代 SPA 框架,但它主要与 Angular 项目相关,并使用 Angular API 进行操作。
我们还将探讨框架中提供的几种更好的替代方案,以保持代码的 DRY 特性。
BaseComponent 存在的意义何在?
BaseComponent在探讨某个类造成的问题之前,让我们先分析一下为什么在不同的代码库中发现同名类并不罕见。
Angular 本身就是多种软件编程范式的混合体,面向对象编程 (OOP) 就是其中之一。因此,人们可能会倾向于将重复的逻辑部分放在一个名为 `Component` 的类中,BaseComponent以便为所有继承它的子组件提供所需的共享功能。请看以下示例:
export abstract class BaseComponent implements OnDestroy {
protected destroy$ = new Subject<void>;
protected abstract form: FormGroup;
constructor(private analyticsService: AnalyticsService,
private router: Router) {}
public trackPageVisit() {
const currentUrl = this.router.url;
this.analyticsService.pageViewed({url: currentUrl})
}
get isFormValid(): boolean {
return this.form.valid;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
// ... many other shared methods added over time
}
功能共享是开发者几乎在每个软件项目中都会遇到的需求之一。继承是实现这一目标的一种方法。但在 Angular 中使用继承来实现功能共享却并非明智之举,这不仅是因为它会带来诸多问题,还因为该框架提供了其他更强大的技术来实现相同的目标。
我们先从问题部分开始。
BaseComponent 有什么问题?
它破坏了继承的意义。
很难否认,面向对象编程(OOP)是商业开发领域迄今为止应用最广泛的编程范式。但这并不意味着它是解决所有问题的万能方案。继承作为OOP的支柱之一,其目的在于Object A建立一种子类型的Object B关系。然而,在BaseComponent实际应用中,它需要的Object A并非简单的关系,而是另一种关系。Object BFunction C
✅
FullTimeEmployee extends Employee这HourlyRateEmployee extends Employee是继承的一个很好的例子。❌
FullTimeEmployee extends WithLogger这HourlyRateEmployee extends WithLogger是继承的一个糟糕例子。
我认为,这种对面向对象编程(OOP)良好实践的轻率理解,或许可以解释为工程师有时会对领域模型层和视图层的代码应用不同的规则。然而,这种做法是错误的,而且并不能改变它是一种反模式的事实。
💡 在不应该使用继承的地方使用继承是错误的,无论这种情况发生在应用程序的哪一层。
你无法选择继承什么。
如果您BaseComponent的应用程序中存在类似上述示例的问题,您可能会很快发现您的应用程序处于以下状态:
takeUntil(this.destroy$)组件类中用于订阅的不同组件,将扩展BaseComponent其共享的取消订阅逻辑。- 只有部分子组件需要页面跟踪方法。
- 这些子组件中只有一部分包含表单,因此需要
ifFormValidgetter 方法。 destroy$最后,并非所有实际跟踪页面访问量或包含表单的组件都需要手动订阅。其中许多组件可能根本不需要这种逻辑。
扩展会BaseComponent污染组件类,使其包含基类的所有方法和属性。这没有任何积极作用,反而会带来以下负面影响:
- 应用程序会为每个继承父类所有方法的组件支付额外的费用,无论这些方法是否需要。
- 由于组件中充斥着不相关的属性和方法,调试体验变得越来越差。
重写生命周期钩子很容易让你搬起石头砸自己的脚。
我在几乎所有参与过的Angular项目中都见过这个错误:
export class MyComponent extends BaseComponent implements OnInit, OnDestroy {
// ...
ngOnInit(): void {
this.subscription$ = this.service.getSubscription$().pipe(
takeUntil(this.destroyed$)
).subscribe();
}
ngOnDestroy(): void {
this.cleanupMyComponentStuff();
}
}
你发现问题所在了吗?这takeUntil(this.destroyed$)永远不会奏效,因为destroyed$.next()是在扩展BaseComponent的钩子函数中调用的,而我们忘记在函数体中ngOnDestroy调用它了。super.ngOnDestroy()MyComponentngOnDestroy
我们团队拥有如此丰富的面向对象编程经验,怎么会犯这么低级的错误?代码检查规则怎么没发现?我们本可以更谨慎,也可以事先制定一条eslint规则来应对这种情况。但事实是:不管你信不信,这种事就是会发生。而且会导致内存泄漏。
增强应用程序中的耦合性
随着应用程序的演进,您可能会发现应用程序中的某些页面需要被移到一个单独的包中。通常,导致这种情况的原因有很多,例如模块的大小以及它与应用程序其他部分的独立性。如果遵循微前端架构原则,这些模块可以由专门的团队开发,甚至可以独立部署。它也可以是一个可发布的库,导出属于某个业务领域的模块或组件;或者是一个位于单体仓库中的库,它不发布到任何仓库,但仍然清晰地定义了其公共接口,并且不应依赖于任何其他领域库或主应用程序的导入。
发现即将从单体应用中分离出来的 Angular 组件BaseComponent并非易事。你不太可能将它们迁移到共享库中。而且,共享库也并非此类组件的理想归宿BaseComponent,因为随着时间的推移,它们往往会变成一把瑞士军刀,为应用的不同部分提供各种共享功能。这可能会成为阻碍,拖慢你的重构进程。
每次都传递构造函数参数
当你扩展一个类时BaseComponent,你也需要付出代价,那就是调用super()父类构造函数时必须传入所有父类构造函数所期望的参数:
@Component({...})
export class OrderHistoryComponent extends BaseComponent {
constructor(private router: Router,
private cd: ChangeDetectorRef,
@Inject(LOCALE_ID) private localeId: string,
private userService: UserService,
private featureFlagService, FeatureFlagService,
private orderHistoryService: OrderHistoryService) {
super(router, cd, localeId, userService, featureFlagService);
}
在这个服务中,我们真正需要的唯一提供商是[此处应填写提供商名称] OrderHistoryService。其余部分都是支付给[BaseComponent此处应填写提供商名称]的税款,我们可能出于一些小原因(例如destroyed$从[此处应填写提供商名称]获得管辖权)而将其扩展。而且,您被迫在每个子类(即每个扩展的组件)中重复支付此税款BaseComponent。这既繁琐又令人恼火。
Angular 14 引入了一种新的注入提供者的方式——inject现在可以在组件的构造阶段调用该函数:
private router = inject(Router);
有很多理由喜欢它。解决上述问题便是其中之一。但即便它最终会成为组件中注入提供程序的标准方法,也还需要一段时间。
替代方案
我真心相信你认为以上理由足以让你放弃某个功能BaseComponent。但是,即使我们决定放弃某个功能,我们共享代码的需求也不会消失。此外,仅仅建议不要做某件事,却不提供相应的替代方案,这样的建议意义不大。
让我们来探讨一下在多个组件之间共享应用程序逻辑的几种常用方法。根据您的需求,您可能需要其中一种或几种,但它们的共同之处在于,每种方法都比其他方法更优。BaseComponent.这些方法也体现了一个非常著名的面向对象编程(OOP)原则。
我们不会深入探讨每一种技术,因为它们值得单独成篇,而且已经有很多文章对此进行了阐述。我们会对每一种技术做一个简要介绍,并在其中一些技术旁附上链接,以便读者更详细地了解相关内容。
查看提供商
我真心认为Angular社区严重低估了视图提供程序(view providers)这项技术。或者说,是在装饰器元数据providers属性中定义的提供程序。@Component
假设你不想Router在组件的初始化阶段重复注入和获取页面设置,并且需要一种方法在许多需要的地方轻松重用此逻辑。
@Component({
...
providers: [PageSettings]
})
private pageSettings = inject(PageSettings); // or provided the old good way in the component's constructor
这项技术非常强大:
- 您可以像在宿主组件中一样,完全访问依赖注入容器。
- 这种提供程序的生命周期与其所连接的宿主组件的生命周期绑定。甚至
ngOnDestroy在宿主组件销毁时,提供程序类中也会调用一个钩子(但其他组件生命周期钩子则不会)。 - 它允许将这些依赖项隐藏在注入令牌之后,从而大大提高了可测试性。在你的代码中,
TestBed你可以将这些依赖项替换为实现了注入令牌接口的模拟对象。
同时,您可以创建具有共享逻辑的通用组件级提供程序,并根据特定组件的需求对其进行微调。这可以通过使用提供程序工厂来实现。请看以下示例:
{
provide: PAGE_SETTINGS,
deps: [SettingsService, ActivatedRoute],
useFactory: pageSettingsFactory(SettingsStorage.LOCAL)
}
工厂会从路由参数中获取必要的页面数据,并将其映射到页面设置。该pageSettingsFactory参数会告知工厂应从本地存储读取设置。在其他使用此通用提供程序的地方,您可能希望通过传递参数从服务器读取设置SettingsStorage.REMOTE。您可以在这篇文章中了解更多信息。
NgRx 库中的 ComponentStore是这种技术的另一个很好的例子。它体积小巧,可以满足组件大部分的本地状态管理需求。
不同类型的指令
当你需要共享的逻辑可以在模板中应用时,指令是你的最佳选择。当需要有条件地创建或销毁 DOM 元素时,使用结构指令;当只需要操作宿主元素的属性时,属性指令就足够了。
<div *appRole="'ADMIN'; else: defaultTemplate">Content shown to admins only</div>
<div copyOnClick>This will be copied to the clipboard when clicked on</div>
最新的主机指令 API为 Angular 开发者开启了全新的机遇之门。从简单的属性指令组合到更复杂的场景,您可以自由选择主机指令 API 的哪些部分保持私有,哪些部分可以通过自定义名称公开。请参考官方文档中的以下示例:
@Component({
selector: 'admin-menu',
template: 'admin-menu.html',
hostDirectives: [{
directive: MenuBehavior,
inputs: ['menuId: id'],
outputs: ['menuClosed: closed'],
}],
})
export class AdminMenu { }
<admin-menu menuId="top-menu" (menuClosed)="logMenuClosed()">
组件之间可以相互注入hostDirectives,反之亦然。这使得它们之间的交互变得无缝。
管道
管道是模板中共享数据转换的绝佳选择。所谓的pure管道被广泛用于此目的。如果简单的数据转换无法满足您的需求,就像在指令中一样,管道也支持依赖注入 (DI),这赋予您强大的功能(同时也伴随着巨大的责任)。
如果你使用过 Angular,你可能已经熟悉这些概念了,所以我只想补充一点关于如何创建像这样的通用数据转换管道的小技巧:
<div *ngFor="let item of items | map : pickFirstN : 4">{{item}}</div>
此外,pickFirstN您的组件可以根据自身需求提供符合预期函数签名的任何其他功能。
不过,不要过度使用管道。管道是一个很棒的工具,但并非所有数据操作任务的最佳选择。请遵循 Angular 文档中的指导原则:
💡 使用管道转换字符串、货币金额、日期和其他数据以进行显示。
装饰师
最常见的问题之一BaseComponent是退订逻辑。我们已经在前面强调了使用扩展组件的钩子来实现退订的缺点ngOnDestroy。接下来,让我们看看如何借助 TypeScript 装饰器来解决同样的问题:
@UntilDestroy({ checkProperties: true })
export class MyComponent {
subscription$ = interval(2000).pipe(
tap(() => { /* work done here */ })
.subscribe();
}
从用户角度来看,这就是全部内容。subscription$当对象被销毁时,订阅将自动取消MyComponent。无需takeUntil仅仅为了取消订阅而使用 `@Unsubscribe`,也不会ngOnDestroy像上面的示例那样因为没有调用 `@Unsubscribe` 而导致内存泄漏的风险。
你可以通过这个链接UntilDestroy找到装饰器的代码。它的作用就是给组件添加取消订阅逻辑,并在最后调用原始方法。ngOnDestroy
不过,我得提醒你,不要对装修工人抱有过高的期望。原因有二:
- TypeScript 对装饰器的实现与目前处于第三阶段的 ECMAScript 标准装饰器提案有很大不同。
- 装饰器不如其他共享技术那样明确。这可能只是个人偏好问题,也取决于你对元编程的熟悉程度。但总的来说,代码的明确性是件好事,我们不希望业务逻辑代码中充斥着太多“魔法”。
💡 对于你希望框架为你完成的常规工作,使用装饰器(就像框架对 @Component、@Pipe 等所做的那样)是一个很好的经验法则。
解析器
并非所有组件都适用。但是,当需要共享的逻辑是为应用程序中绑定到某个路由的组件加载数据时,解析器就派上用场了:
{
path: 'page/:id',
component: PageComponent,
resolve: { settings: PageSettingsResolver }
}
export const pageSettingsResolver: ResolveFn<PageSettings> = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
return inject(PageSettingsService).getPageSettings(route);
};
您可以从组件中提供者data的属性获取已解析的数据。ActivatedRoute
您可以使用 TypeScript 和 Javascript。
值得一提的是,当不需要与依赖注入树的其他部分集成时,通常使用原生的 JS/TS 代码共享技术就足够了。除了从文件中导出的简单函数之外,你还可以使用静态方法来将逻辑上相关的操作分组。
如果出于某种原因,您需要自定义 ` maxand` 函数的实现min,那么实际上没有必要创建多个不使用调用它们的类上下文的函数副本。同时,将这类函数作为同一个 `Math` 类的静态方法分组,可以使相关代码保持集中,从而改善代码组织:
export class CustomMath {
public static customMin(a: number, b: number): number {
...
}
public static customMax(a: number, b: number): number {
...
}
}
还有一些使用频率较低但功能强大的技术,例如 mixin 和 proxys。
当然,还有一系列经过时间考验且可在 TypeScript 中实现的设计模式。
总结
希望您觉得针对BaseComponent合理方案的反驳论点以及提到的替代方案有所帮助。正如您所见,Angular 提供了许多强大的工具来使您的应用程序符合 DRY(Don't Repeat Yourself,不要重复自己)原则。
感谢阅读,我们下次再见!
文章来源:https://dev.to/this-is-angular/you-dont-want-a-basecomponent-in-your-app-23hn