编写出色的 Angular 组件的原则

2025-05-27

编写出色的 Angular 组件的原则

介绍

本文最初Giancarlo Buomprisco在Bits and Pieces上发表

Angular 是一个基于组件的框架,因此,编写好的 Angular 组件对于应用程序的整体架构至关重要。

第一波引入自定义元素的前端框架带来了许多令人困惑和误解的模式。鉴于我们现在已经编写组件近十年,这段时间吸取的教训可以帮助我们避免常见的错误,并为应用程序的构建块编写更好的代码。

在本文中,我想介绍一下社区在过去几年中吸取的一些最佳实践和经验教训,以及我作为前端世界顾问所看到的一些错误。

虽然本文专门针对 Angular,但其中的一些要点也适用于一般的 Web 组件。

在我们开始之前 — 使用 NG 组件构建时,最好共享和重用组件,而不是重新编写相同的代码。


Bit ( GitHub ) 让您可以轻松地将组件打包成胶囊,以便它们可以在您的应用程序的任何位置使用和运行。它还能帮助您的团队组织、共享和发现组件,从而更快地进行构建。快来看看吧。


不要隐藏原生元素

我经常看到的第一个错误是编写自定义组件来替换或封装原生元素,结果导致消费者无法访问这些元素。

根据上述陈述,我指的是以下组件:

    <super-form>

        <my-input [model]="model"></my-input>

        <my-button (click)="click()">Submit</my-button>

    </super-form>
Enter fullscreen mode Exit fullscreen mode

这种方法会产生什么问题?

  • 除非原生元素的属性也在自定义组件中定义,否则用户无法自定义这些属性。如果您要传递每个输入属性,则以下是您必须创建的所有属性的列表

  • 无障碍功能!原生组件自带浏览器可识别的免费内置无障碍属性

  • 不熟悉的 API:使用原生组件时,消费者可以重用他们已经知道的 API,而无需查看文档

答案是增强

借助指令增强原生组件可以帮助我们实现与自定义组件完全相同的功能,而无需隐藏原生 DOM 元素。

增强原生组件的示例既内置于框架本身,也遵循了 Angular Material 的模式,这可能是在 Angular 中编写组件的最佳参考。

例如,在 Angular 1.x 中,通常使用指令 ng-form,而新版本 Angular 将使用 [formGroup] 等指令扩充原生表单元素。

在 Angular Material 1.x 中,button、input 等组件是自定义的,而在新版本中,它们是指令 [matInput] 和 [mat-button]。

让我们使用指令重写上面的示例:

    <form superForm>

      <input myInput [ngModel]="model" />

      <button myButton (click)="click()">Submit</button>

    </form>
Enter fullscreen mode Exit fullscreen mode

这是否意味着我们永远不应该替换原生组件?

不,当然不是。

有些类型的组件非常复杂,需要自定义样式,而原生元素无法应用这些样式等等。这完全没问题,尤其是在原生元素本身属性就不多的情况下。

关键在于,每当创建新组件时,都应该问自己:我可以扩充现有组件吗?

周到的组件设计

如果您想深入解释上述概念,我建议您观看Angular Material 团队的这个视频,该视频解释了从第一个 Angular Material 中学到的一些经验教训以及新版本如何进行组件设计。

无障碍设施

编写自定义组件时经常被忽视的部分是确保我们使用可访问性属性装饰标记以描述其行为。

例如,当我们使用按钮元素时,我们不必指定它的作用。它是一个按钮,对吧?

当我们使用其他元素(例如 div 或 span )替代 button时,就会出现这个问题。这种情况我见过无数次,你大概也遇到过。

ARIA 属性

在这种情况下,我们需要用aria 属性来描述这些元素将做什么。

如果用通用元素替换按钮,那么你可能需要添加的最小 aria 属性是 [role="button"]。
仅就元素按钮而言,ARIA 属性列表就相当长了

阅读该列表将使您了解到尽可能使用本机元素的重要性。

国家与通讯

再次,过去犯下的错误在状态管理和组件之间如何通信方面给我们带来了一些教训。

让我们重申一下合理组件设计的一些非常重要的方面。

数据流

您可能已经了解@Input 和@Output,但强调充分利用它们的用途的重要性非常重要。

组件之间通信的正确方式是让父组件将数据传递给子组件,并让子组件在执行操作时通知父组件。

理解 Redux 出现后流行起来的容器和纯组件之间的概念非常重要:

  • 容器检索、处理并将数据传递给其子组件,也称为属于功能​​模块的业务逻辑组件

  • 组件渲染数据并通知父组件。它们通常可复用,当它们特定于某个功能时,可以位于共享模块或功能模块中,并可用于包含多个子组件。

提示:我倾向于将容器和组件放在不同的公司,这样我就能一目了然地知道组件的职责是什么。

不变性

我经常看到的一个错误是当组件改变或重新声明它们的输入时,会导致无法调试甚至有时无法解释的错误。

    @Component({...})
    class MyComponent {
        @Input() items: Item[];

        get sortedItems() {
            return this.items.sort();
        }
    }
Enter fullscreen mode Exit fullscreen mode

你注意到 .sort() 方法了吗?嗯,它不仅会对组件中数组的项目进行排序,还会改变父级中的数组!除了重新分配输入之外,这是一个常见的错误,往往是 bug 的根源。

提示:避免此类错误的方法之一是将数组标记为只读,或将接口定义为 ReadonlyArray。但最重要的是,务必理解组件永远不应修改来自其他地方的数据。严格本地的数据结构的修改是可以的,尽管您可能听到过相反的说法。

单一职责

拒绝“上帝组件”,例如,结合了业务和显示逻辑的巨型组件,以及封装了可以作为其自己单独组件的大量模板。

理想情况下,组件应该很小,并且只做一件事。较小的组件包括:

  • 更容易写

  • 更容易调试

  • 更容易与他人合作

对于太小或太大根本没有定义,但有些方面会提示您正在编写的组件可以分解:

  • 可重用逻辑:可重用的方法可以成为管道,可以从模板中重用,也可以卸载到服务中

  • 常见行为:例如,包含相同逻辑的 ngIf、ngFor、ngSwitch 的重复部分可以提取为单独的组件

组合与逻辑分离

组合是设计组件时应该考虑的最重要的方面之一。

其基本思想是,我们可以构建许多较小的哑组件,并通过组合它们来组成一个更大的组件。如果该组件在更多地方使用,那么这些组件就可以被封装到另一个更大的组件中,以此类推。

提示:独立构建组件可以更容易地考虑其公共 API,从而更容易将其与其他组件组合在一起

分离业务逻辑和显示逻辑

大多数组件在一定程度上都会有类似的行为。例如:

  • 两个组件都包含可排序和可过滤的列表

  • 两种不同类型的选项卡,例如扩展面板和选项卡导航,都将具有选项卡列表和选定的选项卡

如您所见,尽管组件的显示方式不同,但它们具有所有组件都可以重用的共同行为。

这里的想法是,您可以将作为其他组件(CDK)的通用功能的组件与将重用所提供功能的可视化组件分开。

再次,您可以访问 Angular CDK 的源代码,查看从 Angular Material 中提取了多少逻辑,现在可以被任何导入 CDK 的项目重复使用。

当然,这里的要点是,每当您看到重复的逻辑并不严格地与组件的外观相关时,您可能可以通过不同的方式提取和重用它:

  • 创建可以与可视化组件交互的组件、指令或管道

  • 如果你喜欢 OOP,可以创建提供通用方法的基本抽象类,我通常会这样做,但要谨慎使用

将表单组件绑定到 Angular

我们编写的许多组件都是可以在表单中使用的某种输入。

我们在 Angular 应用程序中可能犯的最大错误之一就是没有将这些组件绑定到 Angular 的 Forms 模块,而是让它们改变父级的值。

将组件绑定到 Angular 的表单可以带来很大的优势:

  • 显然可以在表单中使用

  • 某些行为,例如有效性、禁用状态、触摸状态等,将自动与 FormControl 的状态进行交互

为了将组件与 Angular 的表单绑定,该类需要实现接口ControlValueAccessor


    interface ControlValueAccessor {   
      writeValue(obj: any): void;
      registerOnChange(fn: any): void;
      registerOnTouched(fn: any): void;
      setDisabledState(isDisabled: boolean)?: void 
    }
Enter fullscreen mode Exit fullscreen mode

让我们看一个绑定到 Angular 表单模块的非常简单的切换组件示例:

上面是一个简单的切换组件,向您展示使用 Angular 的表单设置自定义组件是多么容易。

有大量的精彩帖子详细解释了如何使用 Angular 制作复杂的自定义表单,所以去看看吧。

查看我根据上面的示例制作的 Stackblitz。

性能和效率

管道

Angular 中的管道默认是纯管道。也就是说,每当它们接收到相同的输入时,它们都会使用缓存的结果,而不是重新计算值。

我们讨论了管道作为重用业务逻辑的一种方式,但这是使用管道而不是组件方法的另一个原因:

  • 可重用性:可以在模板中使用,也可以通过依赖注入使用

  • 性能:内置缓存系统将有助于避免不必要的计算

OnPush 变更检测

我编写的所有组件都默认激活OnPush 变更检测,我建议您也这样做。

这看起来可能适得其反或太麻烦,但让我们看看它的优点:

  • 重大性能改进

  • 强制您使用不可变的数据结构,从而使应用程序更加可预测且不易出错

这是一个双赢的局面。

在角度外运行

有时,你的组件会运行一个或多个异步任务,这些任务不需要立即重新渲染 UI。这意味着我们可能不希望 Angular 为某些任务触发变更检测运行,这样可以显著提高这些任务的性能。

为了做到这一点,我们需要使用 ngZone 的 API,通过 .runOutsideAngular() 从区域外部运行一些任务,然后如果我们想在某种情况下触发变化检测,则使用 .run() 重新进入。

    this.zone.runOutsideAngular(() => {
       promisesChain().then((result) => {
          if (result) {
            this.zone.run(() => {
               this.result = result;
            }
          }
       });
    });
Enter fullscreen mode Exit fullscreen mode

清理

清理组件可以确保我们的应用程序避免内存泄漏。清理过程通常在 ngOnDestroy 生命周期钩子中完成,通常涉及取消订阅可观察对象、DOM 事件监听器等。

清理 Observable 仍然很容易被误解,需要一些思考。我们可以通过两种方式取消订阅 Observable:

  • 在订阅对象上调用方法 .unsubscribe()

  • 向可观察对象添加 takeUntil 操作符

第一种情况是必要的,要求我们将组件中的所有订阅存储在一个数组中,或者我们可以使用Subscription.add,这是首选。

在 ngOnDestroy 钩子中我们可以取消所有订阅:


    private subscriptions: Subscription[];

    ngOnDestroy() {
        this.subscriptions.forEach(subscription => {
             if (subscription.closed === false) {
                 subscription.unsubscribe();
             }
        });
    }
Enter fullscreen mode Exit fullscreen mode

在第二种情况下,我们将在组件中创建一个 subject,它将在 ngOnDestroy 钩子中发出值。每当 destroy$ 发出值时,操作符 takeUntil 都会取消订阅。

    private destroy$ = new Subject();

    ngOnInit() {
        this.form.valueChanges
           .pipe(
               takeUntil(this.destroy$)
            )
           .subscribe((value) => ... );
    }

    ngOnDestroy() {
        this.destroy$.next();
        this.destroy.unsubscribe();
    }
Enter fullscreen mode Exit fullscreen mode

提示:如果我们使用异步管道在模板中使用可观察对象,则我们不需要取消订阅它!

避免使用本机 API 进行 DOM 处理

服务器渲染和安全

使用 Native DOM API 处理 DOM 可能很诱人,因为它简单快捷,但在组件服务器渲染能力以及绕过 Angular 内置实用程序以防止代码注入的安全隐患方面存在一些缺陷。

你可能知道,Angular 的服务端渲染平台无法识别浏览器 API。也就是说,使用 document 之类的对象将无法正常工作。

相反,建议使用 Angular 的 Renderer 来手动操作 DOM 或使用内置服务,例如 TitleService:

    // BAD

    setValue(html: string) {
        this.element.nativeElement.innerHTML = html;
    }

    // GOOD

    setValue(html: string) {
        this.renderer.setElementProperty(
            el.nativeElement, 
            'innerHTML', 
            html
        );
    }

    // BAD

    setTitle(title: string) {
        document.title = title;
    }

    // GOOD

    setTitle(title: string) {
        this.titleService.setTitle(title);
    }
Enter fullscreen mode Exit fullscreen mode

关键要点

  • 尽可能优先使用原生组件

  • 自定义元素应该模仿它们所替换元素的可访问性行为

  • 数据流是单向的,从父级到子级

  • 组件不应该改变其输入

  • 组件应尽可能小

  • 了解何时应将组件分解成更小的部分、与其他组件组合,以及将逻辑转移到其他组件、管道和服务的提示

  • 将业务逻辑与显示逻辑分离

  • 用作表单的组件应该实现 ControlValueAccessor 接口,而不是改变其父级的属性

  • 利用 OnPush 变更检测、纯管道和 ngZone 的 API 来提升性能

  • 当组件被销毁时清理它们以避免内存泄漏

  • 切勿使用原生 API 修改 DOM,请使用渲染器和内置服务。这将使您的组件在所有平台上都能正常工作,并且从安全角度来看是安全的。

资源

如果您需要任何澄清,或者您认为某些内容不清楚或错误,请发表评论!

希望你喜欢这篇文章!如果喜欢,请在MediumTwitter上关注我,获取更多关于前端、Angular、RxJS、Typescript 等的文章!

文章来源:https://dev.to/gc_psk/the-principles-for-writing-awesome-angular-components-2ofi
PREV
作为初级开发人员(第一个月)我学到的 10 件事
NEXT
函数设计:组合器