Angular 18+ 高级教程 – Component 组件 の Angular Component vs Custom Elements

前言

在上一篇 Angular Component vs Web Component 中,我们整体对比了 Angular Component 和 Web Component 的区别。

这一篇我们将针对 Custom Elements 的部分继续对比学习。

同样的,请先看我以前写的 DOM – Web Components の Custom Elements

 

Attribute、Property、Custom Event

对于一个封装好的 Custom Element,外部想与它交互有 2 个方法。 

1. 修改 property 或 attribute

2. 监听 custom event

对于 Angular Component 也是如此,只是在 Component 内部,Angular 替我们封装了繁琐的实现代码。

 

Step by Step

Cookie Acknowledge Component Final Look

我们做一个 cookie acknowledge 组件,长这样:

 

调用 Cookie Acknowledge Component

<app-cookie-acknowledge websiteName="兴杰 Blog" (acknowledge)="alert($event)"></app-cookie-acknowledge>

websiteName 是组件的 attribute/property。它是组件中 paragraph Welcome to "兴杰 Blog" 的一部分。

下面这句是 Angular 的 binding syntax

(acknowledge)="alert($event)"

后面的章节会详细介绍,这里只要知道它相等于 addEventListener 就可以了。

document.querySelector('app-cookie-acknowledge')!.addEventListener('acknowledge', (event: string) => alert(event))

注:Angular 没有强制使用 CustomEvent,dispatch 的 event 可以是单纯一个 string value,不一定要是 event 对象。

 

Create Cookie Acknowledge Component

ng g c cookie-acknowledge

@Input and @Output

cookie-acknowledge-component.ts

export class CookieAcknowledgeComponent {
  @Input()
  websiteName!: string;

  @Output('acknowledge')
  acknowledgeEmitter = new EventEmitter<string>();
}

@Input decorator 用于表示这个 attribute/property 是对外(HTML)开放的。使用者可以通过 websiteName="兴杰 Blog" 把 value 传进来。

Input 是输入,那 @Output 自然就是输出了。输出指的就是 dispatch event。它是一个 EventEmitter 对象,顾名思义就是用来 emit (AKA dispatch) event 的。

cookie-acknowledge-component.html

<h1>Cookie Acknowledge</h1>
<p>Welcome to {{ websiteName }}, to allow we track you, please press acknowledge button.</p>
<button (click)="acknowledgeEmitter.emit('Yes, track me!')">Acknowledge</button>

{{ websiteName }} 是 binding syntax,这里先不关注这个,后面章节会介绍,简单说就是把 websiteName 属性值 textContent 填入 paragraph 中。

(click) 我们上面提到过了,它就是 addEventListening。

acknowledgeEmitter.emit('Yes, track me!'),这句就是 dispatch event。

CustomEvent passing value 是通过 event.detail 属性,而 Angular 没有这个要求,我们可以直接 dispatch 任何类型的 value,当然若想 dispatch 一个 CustomEvent 也是可以。

小结

Angular 组件和 Custom Elements 一样,都是通过 component attribute/property 和 listen event dispatch 与组件交互。

Angular 用 decorator @Input 和 @Output 声明对外(HTML)开放的属性和可监听的事件,并通过 EventEmitter 对象 dispatch event。

Angular 的 event 没有要求必须是 Event 对象,我们可以 dispatch 任何类型的 value 作为 event。

 

@Input (required、transform)

首先,再重复一次,@Input 负责的是从 html 的 attribute 到组件对象 property 的关系处理。

我们知道 Attribute 和 Property 是两个不同的东西。不清楚的可以参考这篇 Attribute 和 Property 的区别

比如一个原生 HTML Input Element

<input id="my-input" readonly>
<input id="my-input" readonly="whatever">
<input id="my-input" readonly="">
<input id="my-input" readonly="false">

虽然 4 个 input 里,readonly attribute 的值都不一样,但是最终它们的 readOnly property 都是 true。

这中间就是 Input element 搞的鬼了。对它来说,无论 readonly 这个 attribute 的值是什么,只要有这个 attribute,那么 property 的值就是 true。

另一个场景是类型转换,由于 attribute 的值一定是 string,而 property 值却可以是任何类型,所以中间经常需要一个 transform 的工作,它也是由 Custom Element / Component 负责。

Angular 替我们分担了很多这类的工作,来看例子吧。

export class TestInputOutputComponent {
  @Input()
  stringValue!: string;
}

组件需要一个 string property

<app-test-input-output stringValue="value"></app-test-input-output>

外部传 string 进来,这个例子是最简单的,我们啥也不用做。

transform

@Input()
boolValue!: boolean;

把 property 换成 boolean

<app-test-input-output boolValue="true"></app-test-input-output>
<app-test-input-output boolValue="1"></app-test-input-output>

这时不管我们写什么 string 都没用,它一定报错

从前最常见的解决方法是使用模板语法

<app-test-input-output [boolValue]="true"></app-test-input-output>

attribute 加上方括弧后,value 就变成了 JS。true 就不是 string 而是 boolean 类型了。 

虽然这招可用,但是它有点间接,因为模板语法功能很强大,特地拿来做区区的类型转换有点大材小用了。所以 Angular 才特意设计一个新方法 transform。

@Input({ transform: booleanAttribute })
boolValue!: boolean;

在 @Input decorator 加上一个 transform 就可以把 string 转换成 boolean 类型了。

这个 booleanAttribute 是 Angular build-in 的方法。

import { Component, Input, OnInit, booleanAttribute } from '@angular/core';

源码长这样

export function booleanAttribute(value: unknown): boolean {
  return typeof value === 'boolean' ? value : (value != null && value !== 'false');
}

只是一个简单的 coercion 函数。可以看到只有当 value 是 null or undefined 或者 string = 'false' 的时候才会被转成 false,其余情况都是 true。

所以,以下所有都是 true,除了最后一条。

<app-test-input-output boolValue=""></app-test-input-output>
<app-test-input-output boolValue></app-test-input-output>
<app-test-input-output boolValue="0"></app-test-input-output>
<app-test-input-output boolValue="1"></app-test-input-output>
<app-test-input-output boolValue="abc"></app-test-input-output>
<app-test-input-output boolValue="123"></app-test-input-output>
<app-test-input-output boolValue="false"></app-test-input-output>

除了 boolean,Angular 还有一个 build-in 的 transform 叫 numberAttribute。源码长这样:

export function numberAttribute(value: unknown, fallbackValue = NaN): number {
  const isNumberValue = !isNaN(parseFloat(value as any)) && !isNaN(Number(value));
  return isNumberValue ? Number(value) : fallbackValue;
}

关键就是 parseFloat 咯,把 string 转成 number。

required

Input 可以声明 required。

@Input({ required: true })
boolValue!: boolean;

声明 required 后,如果没有放 attribute 就会报错。

 

@Attribute

@Input 是用来拿 property 的,@Attribute 是用来拿 attribute 的。

看例子:

<app-item name="iPhone 14" />

有一个 Item 组件,它有一个 attrbute name,value 是 'iPhone 14'。

在 AppItem 组件 constructor 参数使用 @Attribute decorator

export class ItemComponent {
  constructor(
    // 1. 在 constructor 使用 @Attribute decorator 获取 name attribute
    @Attribute('name') name: string,
  ) {
    console.log(name); // 'iPhone 14'
  }
}

这样就可以拿到 attribute value 了。

这里有 2 个点要注意:

  1. @Attribute 是 apply 在 constructor 参数,而不是像 @Input 那样 appy 在 property。

  2. @Attribute 不可以和 @Input 撞,两者只能有一个存在。

  3. @Attribute 没有 binding 概念,它一定是 static string value。

@Attribute 相对 @Input 来说是非常冷门的,组件一般上很少会用 @Attribute,指令 + 原生 DOM 才可能会用到 @Attribute。(指令后面章节才会教)

 

@Input 和 @Output Decorator 正在被放弃

Decorator 目前普遍不受待见,两大原因。 

1. ECMA 把 Decorator 拆成了两个版本,而且第二个还没有定稿。

2. 函数式的天下,Decorator 自然也变成小众了。

所以,Angular 从 v14 开始就有了弃暗投明的想法。一步一步靠拢 react、vue、solid、svelte 等等前端技术。

当然所谓的靠拢只是在开发体验上,写法上不同而已,概念是靠拢不了的。

metadata 写法

metadata inputs 写法

@Component({
  inputs: [
    { name: 'boolValue', alias: 'value', required: true, transform: booleanAttribute }
  ]
})

取代了原本的 @Input,接口都一样,只是搬家而已。

我个人是觉得没有必要这么写啦,逻辑分开有时候也很乱,建议大家还是等 Signal-based Component 吧。

Signal-based 写法

export class CardComponent {
  title = input<string>();
}

上面这个就是 Signal-based Component Input 的写法。title 是属性,input 是全局函数。

这个写法和 DI 的 inject 函数非常相识。

Angular v17.1.0 正式推出了 Signal-based Input,想学可以看这篇 Signals # Signal-based Input

 

Angular Component Lifecycle vs Custom Elements Lifecycle

Custom Elements Lifecycle

Custom Elements 有 3 个 Lifecycle Hook.

1. connectedCallback 当被 append to document

2. disconnectedCallback 当被 remove from document

3. attributeChangedCallback 当监听的 attributes add, remove, change value 的时候触发

Angular Component Lifecycle

Angular 有好多 Lifecycle Hook...我先介绍 5 个基本的,后面的章节还会介绍其它的。

首先,我们添加一些交互

一个 change attribute 和一个 remove element

app.component.ts

export class AppComponent {
  alert = alert;
  websiteName = '兴杰 Blog';
  showCookieAcknowledge = true;
}
View Code

app.component.html

<div class="container">
  <div class="action">
    <button (click)="websiteName = 'Derrick\'s Blog'">Change Website Name</button>
    <button (click)="showCookieAcknowledge = false">Delete Cookie Acknowledge</button>
  </div>
  @if (showCookieAcknowledge) {
    <app-cookie-acknowledge [websiteName]="websiteName" (acknowledge)=" alert($event)"></app-cookie-acknowledge>
  }
</div>
View Code

不要在意 @if 和 [websiteName],后面章节会教,我们 focus Lifecycle Hook 就好了。

添加 5 个 Lifecycle Hook 到 Cookie Acknowledge Component

cookie-acknowledge.component.ts

export class CookieAcknowledgeComponent
  implements OnInit, OnChanges, OnDestroy, AfterViewInit
{
  @Input({ required: true })
  websiteName!: string;

  @Output('acknowledge')
  acknowledgeEmitter = new EventEmitter<string>();

  constructor() {
    console.log('constructor', '@Input value not ready yet');
    console.log(
      'constructor, this.websiteName === undefined',
      this.websiteName === undefined
    );
  }

  ngOnInit(): void {
    console.log('OnInit', '@Input value ready');
    console.log(
      'OnInit, this.websiteName !== undefined',
      this.websiteName !== undefined
    );
  }

  ngOnChanges(changes: SimpleChanges): void {
    if ('websiteName' in changes) {
      const change = changes['websiteName'];
      if (change.firstChange) {
        console.log(
          'OnChanges first change',
          `prev: ${change.previousValue}, curr: ${change.currentValue}`
        );
        console.log(
          'OnChanges first change, paragraph appended',
          document.querySelector('.paragraph') !== null
        );
        console.log(
          'OnChanges first change, paragraph data binding no complete yet',
          document.querySelector('.paragraph')!.textContent === ''
        );
      } else {
        console.log(
          'OnChanges second change',
          `previous value : ${change.previousValue}`
        );
        console.log(
          'OnChanges second change',
          `current value : ${change.currentValue}`
        );
      }
    }
  }

  ngAfterViewInit(): void {
    console.log(
      'AfterViewInit, paragraph data binding completed',
      document.querySelector('.paragraph')!.textContent !== ''
    );
  }

  ngOnDestroy(): void {
    console.log('OnDestroy', `element has been removed`);
    console.log(
      'OnDestroy, query app-cookie-acknowledge === null',
      document.querySelector('app-cookie-acknowledge') === null
    );
  }
}
View Code

不需要看 code, 下面我们看 runtime console 就可以了。

1. constructor

组件是 class,第一个被 call 的自然是 constructor。在这个阶段 @Input 的是还没有输入值的,它是 undefined。

template 也还没有 append 到 document 里。

2. ngOnChanges (first time)

ngOnChanges 对应 Custom Elements 的 attributeChangedCallback。每当 attribute 变化的时候就会 call。

websiteName 从开始的 undefined 变成 '兴杰 blog' 后就会触发 first time onchanges。

注:Custom Elements 的 attributeChangedCallback 是没有 first time call 的,它只有后续改变才会 call。

另外,template 在这个阶段已经 append to document 了,但如果有 binding data 的部分则还没有完成。

比如这句

<p class="paragraph">Welcome to {{ websiteName }}, to allow we track you, please press acknowledge button.</p>

假如这个阶段我们 document.query .paragraph 会获得 element,但是 element.textContent 将会是 empty string。

3. ngOnInit

ngOnInit 对应 Custom Elements connectedCallback。在这个阶段 @Input 的 value 已经有值了。

通常我们会在这个阶段发 ajax 取 data 什么的。

这个阶段 binding data 依旧还没开始,paragraph.textContent 依然是 emtpty string。

4. ngAfterViewInit

这个阶段 binding data 就完成了。paragraph.textContent 已经有包括 websiteName '兴杰 blog' 在内的 text 了。

在这个阶段我们不应该再去修改 view model 了,如果修改它会报错的。

如果真的有需要修改的话,那么就用 setTimeout 让它开启下一个循环。

5. ngOnChanges(second time)

当 @Input 值被修改后,又会触发 ngOnChanges。记得,这个阶段 data binding 是还没有完成的哦。

如果想监听到 data binding 完成,可以使用 ngAfterViewChecked,但这个比较冷门,我不想在这里展开,以后的章节会详解介绍。

6. ngOnDestroy

ngOnDestroy 对应 Custom Elements 的 disconnectedCallback,这个阶段 element 已经从 document 移除了。

我们通常会在这里做一些释放资源的动作。

console 结果

 

Future (Signal-based Components)

参考: Github – Sub-RFC 3: Signal-based Components

这篇提到的 @Input @Output 还有 Lifecycle Hook 写法,在未来(一年后)会有很大的变化。

因为 Angular 正在向 React 学习,希望透过改变开放体验来吸引一些新用户。 

感受一下: 

@Input

Angular v17.1.0 正式推出了 Input Signal,想学可以看这篇 Signals

@Output

Lifecycle Hook

改变的方向是尽可能移除 Decorator 和增加函数式特性,同时减少面向对象特性。

虽然写法上区别很大,但是底层思路改变的不多,而且 Angular 依然会保留目前的写法很长一度时间(maybe 2 more years)。

所以短期内大家还是可以学习和安心使用的。

 

目录

上一篇 Angular 18+ 高级教程 – Component 组件 の Angular Component vs Web Component

下一篇 Angular 18+ 高级教程 – Component 组件 の Angular Component vs Shadow DOM (CSS Isolation & slot)

想查看目录,请移步 Angular 18+ 高级教程 – 目录

喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻

 

posted @ 2023-04-06 21:20  兴杰  阅读(629)  评论(0编辑  收藏  举报