Angular 18+ 高级教程 – Component 组件 の Template Binding Syntax

前言

这篇介绍一些基本的 Angular 模板语法。

 

参考

Docs – Understanding binding

 

Render、Event Listening and DOM Manipulation

Angular 作为一个 MVVM 框架,有两个任务是一定要处理好的

1. First Render

2. Event Listening & DOM Manipulation

Render Engine

First Render 的工作是把 data 和 template 做 binding and render, 这世上有太多种模板语法了。

EJSmustachehandlebarsJSXLiquidRazor

只能说各花入各眼吧。Angular 也整了一套自己独一无二的语法。不像 JSX 和 Razor 那样直接把 JS / C# 结合 HTML。

Angular 的思想是尽可能不去破坏 Native HTML 的规则,尽可能少的去加入新语法(虽说尽可能少, 但其实并不少...)

Event Listening & DOM Manipulation

除了 first render,用户交互也是避不开的,监听事件、修改 DOM 也是一个 MVVM 框架的任务。

 

常用的 Template Binding Syntax

我先介绍一些简单和常用的。先大概过一遍, 体会一下。下一章节我们再 focus 一些比较复杂的。

常见的 DOM Manipulation 有

const element = document.querySelector<HTMLElement>('.selector')!; // query element
element.textContent = 'value'; // update text
element.title = 'title'; // update property
element.setAttribute('data-value', 'value'); // set attribute (note: attribute and property are not the same thing)
element.style.padding = '16px'; // change style
element.classList.add('new-class'); // add class

const headline = document.createElement('h1'); // create element
headline.textContent = 'Hello World';
element.appendChild(headline); // append a element
element.innerHTML = `<h1>Hello World</h1>`; // write raw HTML

element.addEventListener('click', () => console.log('clicked')); // listen and handle a event

Text Binding

<h1>{{ value }}</h1>

value 来自组件的 property

Property Binding

<h1 [title]="value">Hello World</h1>
<h1 [title]="'Hello World'">Hello World</h1>
<h1 [title]="(100 - 10 === 90) ? 'Hello World' : 'Hello Kitty'">Hello World</h1>

当左边是 [放括弧],右边的输入就变成了 JavaScript expression(不是所有 expression 都支持)

第一行 value 是 binding with component property

第二行是 hardcode 一个 string。注意它有 quote,因为是 JavaScript expression。

第三行是一个复杂的 expression(注: 虽然 Angular 允许写 expression,但请尽量不要把逻辑放到 HTML,这会让 HTML 看上去很乱)

Attribute Binding

<h1 [attr.data-value]="value">Hello World</h1>

和 property 写法一样,只是前面加了 prefix attr。 

attribute 和 property 是不同的东西哦,如果你不知道,请看这篇 Attribute 和 Property 的区别.

Style Binding

<!-- single style + declare unit in property side -->
<h1 [style.padding.px]="value">Hello World</h1>

<!-- declare unit in value side -->
<h1 [style.padding]="'16px'">Hello World</h1>

<!-- multiple style property declare -->
<h1 [style.padding.px]="16" [style.width]="'100px'">Hello World</h1>

<!-- multiple style by passing object -->
<h1 [style]="{ padding : '16px', width: '100px' }"></h1>

<!-- Error: declare unit in property side won't work when using passing object mode -->
<h1 [style]="{ 'padding.px' : 16, width: '100px' }"></h1>

<!-- use value null or undefined to remove the specify style -->
<h1 [style]="{ padding : null, width: '100px' }"></h1>

上面给的例子很多是 hardcode,这个是为了我方便写,全部都可以改成 link with component property 的。

style 写法蛮多的,但常用的是第一种而已。

[style]="object" 这个写法不好, 它不支持 .px suffix 而且修改 property 是不会 re-render 的, 只有给予整个全新的 object 才会 re-render(简单说, 就是需要 immutable object)

所以 best practice 是用 ngStyle 指令来做 multiple style(这个以后会教)。

Class Binding

<!-- if value true, then class will be added -->
<h1 [class.my-class]="true">Hello World</h1>

<!-- use object for multiple -->
<h1 [class]="{ 'my-class' : true, 'second-class': true }">Hello World</h1>

<!-- use array -->
<!-- note: value should not be null or undefined, empty string is ok -->
<h1 [class]="['my-class', 'second-class']">Hello World</h1>

<!-- use string -->
<h1 [class]="'my-class second-class'">Hello World</h1>

和 style binding 一样,[class]="object | array" 同样要求 immutable for re-render

for multiple class,best practice 是使用 ngClass 指令(这个以后会教)。

Event Listening

<button (click)="doSomething($event)">Click Me</button>
<input (keydown)="doSomething($event)">
<!-- 还可以指定只监听某个 key 哦 -->
<input (keydown.space)="doSomething($event)">
<input (keydown.enter)="doSomething($event)">
<input (keydown.shift.a)="doSomething($event)">

<!-- conditional execute -->
<input (keydown)="(100 - 10 === 90) ? doSomething($event) : doSomethingElse($event)">
<input (keydown)="(100 - 10 === 90) && doSomething($event)">

监听事件用的 (元括弧),右边依然是 JavaScript expression

doSomething 是 component method,$event 是一个特殊关键字,它代表了这个事件监听 emit / dispatch 的 event。

for click 的话是 MouseEvent,keydown 则是 KeyboardEvent

export class AppComponent {
  doSomething(event: MouseEvent | KeyboardEvent) {
    console.log(event);
  }
}

效果

preventDefault

handler 如果返回 false,Angular 会执行 preventDefault 方法。

<input (keydown)="doSomething()">

export class AppComponent {
  doSomething() {
    return false;
  }
}

相关源码在 dom_renderer.ts 的 DefaultDomRenderer2.listen 方法

当然我们也可以直接 $event.preventDefault()。

 

Host Binding and Listening

<app-test (click)="handleClick()" [title]="'test only'"></app-test>

上面这样是在组件外的 html 上对组件做 binding 和 listening.

试想, 如果我们想把这些逻辑也封装到组件里. 我该如何在组件内对组件本身 binding 和 listening 呢?

你可能会想, 我们可以在组件内的 html wrap 一个 container element. 然后监听它. 但这样就太间接了,Angular 不喜欢这样 (Thinking in Angular Way)

component metadata host

在组件 metadata 里声明 binding 和 listening

@Component({
  selector: 'app-test',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './test.component.html',
  styleUrl: './test.component.scss',
  // 这里声明对 host 的 binding 和 listening
  host: {
    '(click)': 'handleClick($event)',
    '[title]': 'title',
  },
})
export class TestComponent {
  title = 'Hello World';

  handleClick(event: MouseEvent) {
    console.log(event);
  }
}

HostBinding can't use as @Input

host binding 是用来设置 HTML attribute 的,比如上面的 title。

我们不可以把它用于组件的 @Input

上面这样是不正确的。

@HostBinding 和 @HostListener

另一种方法是通过 decorator

export class TestComponent {
  @HostBinding('title')
  title = 'Hello World';

  @HostListener('click', ['$event'])
  handleClick(event: MouseEvent) {
    console.log(event);
  }
}

2 个方案选其中一个用就可以了哦.

component metadata vs decorator

metadata 比较 readable

decorator 是 Angular 正在远离的方式,所以推荐用 metadata 就好。

 

Two-way Binding 双向绑定 [()]

看例子

App 组件有一个 value

export class AppComponent {
  value = 'Hello World';
}

它被显示在 App,同时传入 Hello World 组件。

<p>outside value: {{ value }}</p>
<app-hello-world [value]="value"></app-hello-world>

<button (click)="value = 'new outside value'">change value from outside</button>

并且 App 有一个 button,点击后可以修改这个 App.value。

HelloWorld 组件 @Input 接收这个 value

export class HelloWorldComponent {
  @Input({ required: true })
  value!: string;
}

然后显示

<p>inside value : {{ value }}</p>

效果

当外部(App)更新值,内部(HelloWorld)也会更新到。

好,我们试试反过来,内部做更新,看看外部是否也会更新。

把 button 移到 HelloWorld 组件内

<p>inside value : {{ value }}</p>

<button (click)="value = 'new inside value'">change value from inside</button>

效果

外部 App 的值没有被更新。那如果我们希望它被更新,那就叫双向绑定。

怎么实现呢?答案是用 @Output

export class HelloWorldComponent {
  @Input({ required: true })
  value!: string;

  @Output()
  valueChange = new EventEmitter<string>();
}

定义一个 @Output valueChange EventEmitter

hello-world.component.html 点击后 emit

<button (click)="valueChange.emit('new value')">change value from inside</button>

app.component.html

监听 valueChange event 然后更新 value

<app-hello-world [value]="value" (valueChange)="value = $event"></app-hello-world>

效果

关键就在 HelloWorld 组件只负责监听修改 value 的事件,正真修改 value 是由 App 负责的。

这样双方读取的始终是同一个源。

Angular 做了一个语法糖给下面这句代码

[value]="value" (valueChange)="value = $event"

加糖后变成

[(value)]="value"

Signal-based Two-way Binding (a.k.a Signal Models)

Angular v16.0.0 发布了 Signals,请先确保你已经掌握 Signals 才继续看。

Angular v17.1.0 发布了 Signal-based Input,请先确保你已经掌握 Signal-based Input 才继续看。

Angular v17.2.0 发布了 Signal-based Two-way Binding,它便是用于双向绑定的。

同样上面的例子,我们把 HelloWorld 组件的 @Input 和 @Output 改成 Signal-based Two-way Binding。

export class HelloWorldComponent {
  // before
  // @Input({ required: true })
  // value!: string;

  // @Output()
  // valueChange = new EventEmitter<string>();

  // after
  value = model.required<string>(); // 这就是 Signal-based Two-way Binding 写法,和 Signal-based Input 如出一辙
}

HelloWorld Template

<!-- before -->
<!--
<p>inside value : {{ value }}</p>
<button (click)="valueChange.emit('new value')">change value from inside</button>
-->

<!-- after -->
<p>inside value : {{ value() }}</p>
<button (click)="value.set('new value')">change value from inside</button>

把 valueChange.emit 换成 value.set,把 {{ value }} 换成 {{ value() }}

App 组件

export class AppComponent {
  // before
  // value = 'Hello World';

  // after
  value = signal('Hello World');
}

一样使用 signal

App Template

<!-- before -->
<!-- <p>outside value: {{ value }}</p> -->
<!-- <app-hello-world [value]="value" (valueChange)="value = $event"></app-hello-world> -->

<!-- after -->
<p>outside value: {{ value() }}</p>
<app-hello-world [(value)]="value"></app-hello-world>

把 {{ value }} 换成 {{ value() }},把 [value]="value" (valueChange)="value = $event" 换成 [(value)]="value" (注:这里没有括弧,binding 的是 Signal 对象而不是它的值)。

新旧搭配使用

上面给的是 all in Signal 的写法。其实也不一定要 all in,它是支持一半一半的,我们继续看例子

App 组件

export class AppComponent {
  readonly person = { name: 'old value' }
}

person 是一个普通对象,不是 Signal。

<app-hello-world [(value)]="person.name" />
<p>outside value: {{ person.name }}</p>

当内部更新 value 时,外部一样会更新。

其原理是

compile 以后,内部更新值时会执行 appInstance.person.name = newValue 这句代码,所以外部就更新了。

另外,想拆开 binding 也是可以的。

<app-hello-world [value]="person.name" />

虽然里面是 model,但外部只使用了它 input 的功能,所以当内部更新时,外部不会更新。

再来

<app-hello-world [value]="person.name" (valueChange)="doSomethingWithNewValue($event)" />

当内部修改 value 时,person.name 不会更新,同时 valueChange 会被调用,我们可以做对新值做任何处理。

再来

<app-hello-world [(value)]="valueSignal" />

<app-hello-world [value]="valueSignal()" (valueChange)="valueSignal.set($event)"  />

这两个写法是等价的

model 不支持 transform

相关 Github Issue – Add "transform" to model()

严格来讲,model !== input + output

它们还是有一点点区别的,比如说 model 不支持 transform,而 input 是支持 transform 的。

我们看个简单例子

Checkbox 组件

export class CheckboxComponent {
  readonly checked = input(false, { transform: booleanAttribute });
  readonly checkedChange = output<boolean>();
}

使用 input + output

Checkbox Template

<input id="my-checkbox-1" type="checkbox" #checkbox [checked]="checked()" (change)="checkedChange.emit(checkbox.checked)">
<label for="my-checkbox-1"><ng-content /></label>

App Template

<app-checkbox checked>check me</app-checkbox>

我们可以直接写一个 checked attribute 来表达 checked,因为内部会 transform string to boolean

效果

假如我们要 two-way bindding with Signal 可以这样写

App 组件

export class AppComponent {
  readonly checked = signal(true);
}

App Template

<app-checkbox [(checked)]="checked">check me</app-checkbox>
<pre>{{ checked() }}</pre>

效果

好,现在我们把它从 input + output 改成 model

Checkbox 组件

export class CheckboxComponent {
  // readonly checked = input(false, { transform: booleanAttribute });
  // readonly checkedChange = output<boolean>();

  readonly checked = model(false); // model 不支持 transform: booleanAttribute
}

Checkbox Template

<input id="my-checkbox-1" type="checkbox" #checkbox [checked]="checked()" (change)="checked.set(checkbox.checked)">

App Template

<app-checkbox [(checked)]="checked">check me</app-checkbox>

到这里都没有问题,但是由于 model 不支持 transform,所以下面这样写就不行了

我们只能老老实实传 boolean 类型进去了。

<app-checkbox [checked]="true">check me</app-checkbox>

Angular 之所以不让 model 支持 transform 是因为它们怕乱,如果使用 model,那动机就是以同步为主,而不是 input + output 这种分开的形式。

 

目录

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

下一篇 Angular 18+ 高级教程 – Component 组件 の Attribute Directives 属性型指令

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

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

 

posted @ 2023-04-06 16:45  兴杰  阅读(643)  评论(0编辑  收藏  举报