模板是 Angular 组件的另一半:类负责状态与逻辑,模板负责”状态如何映射到 DOM、DOM 事件如何回到状态”。绑定语法就是这份契约的书写格式。v22 的模板绑定有两个底色:信号优先(状态是 signal / input / model)、strictTemplates 默认(绑定表达式全程受类型检查)。本文把所有绑定形态一次讲清。
绑定全景图
数据在父子组件与 DOM 之间的流动方向:
flowchart LR
P["父组件"] -->|"[title] 属性绑定"| C["子组件"]
C -->|"(changed) 事件绑定"| P
P <-.->|"[(size)] 双向绑定"| C
C -->|"[class]/[style] 绑定"| D["DOM 元素"]
D -->|"(click) 等事件"| C
记住方向感:方括号是”数据流向 DOM/子组件”,圆括号是”事件流回组件”,两者合体就是双向。
插值:{{ }}
<p>你好,{{ user().name }}!</p>
<p>合计:{{ total() | number: '1.2-2' }}</p>
- 双花括号内是表达式,支持属性访问、三元、方法调用、管道。
- 信号在插值中照常调用:
{{ count() }},值变化自动更新。 - 表达式有边界:不能赋值、不能用
new、不能自增——插值是”求值展示”,不是”执行语句”。
属性绑定:[ ]
<img [src]="hero().avatar" [alt]="hero().name" />
<button type="button" [disabled]="isSubmitting()">提交</button>
<span [attr.data-id]="item().id">...</span>
要点是区分 DOM property 与 HTML attribute:
| 场景 | 用法 | 说明 |
|---|---|---|
| DOM property(绝大多数) | [src] [disabled] [value] | 绑定到 DOM 节点的属性,反映当前值 |
| attribute | [attr.colspan] [attr.aria-label] | 只在”没有对应 property”时用(colspan、aria-*、SVG 属性等) |
| CSS class | [class.active] | 单类开关 |
| CSS style | [style.width.px] | 单条样式,可带单位后缀 |
[disabled]="false" 之所以能真正启用按钮,正因为它写的是 property;若用字符串插值 disabled="{{ false }}",写入的是 attribute,存在与否才是语义——这是经典新手坑。
事件绑定:( )
<button type="button" (click)="save()">保存</button>
<input (input)="onInput($event)" (keyup.enter)="submit()" />
- 圆括号内是事件名,等号右侧是语句(可以赋值、可以链式调用)。
$event是事件对象:DOM 事件是原生Event;自定义output()事件就是发射的值。- Angular 支持键盘伪事件(
keyup.enter、keydown.ctrl.z),省去手工判断按键。 - 事件处理函数的类型签名会被 strictTemplates 检查:
(input)="onInput($event)"要求onInput(event: Event)。
在处理函数里读取输入值:
onInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
this.query.set(value);
}
双向绑定:[( )]
香蕉盒语法 [(x)] 是语法糖,展开为:
<app-pager [(page)]="page" />
<!-- 等价于 -->
<app-pager [page]="page" (pageChange)="page = $event" />
v22 中子组件实现双向绑定的标准方式是 model()(见下节)。表单场景的经典 [(ngModel)] 依然可用,但新项目更推荐 v22 已稳定的 Signal Forms(本系列表单篇展开)。
[class] 与 [style] 的完整形态
| 形态 | 示例 | 说明 |
|---|---|---|
| 单类开关 | [class.done]="todo.done" | 布尔控制 |
| 单类固定 | class="card highlight" | 普通写法,静态 |
| 类映射 | [class]="{ active: isActive(), disabled: isDisabled() }" | 对象整体替换 class |
| 单条样式 | [style.width.px]="width()" | 带单位 |
| 单条样式(值内联单位) | [style.width]="width() + 'px'" | 字符串拼接 |
| 样式映射 | [style]="{ color: color(), fontWeight: bold() ? 'bold' : 'normal' }" | 对象整体 |
注意 [class] 与 [style] 的对象形态是整体替换,与静态 class="..." 并存时遵循合并规则;需要”保留基础类 + 动态增删”时,优先用 [class.xxx] 单类开关。
信号 API:input() / output() / model()
v22 里组件对外契约的标准写法:
import { Component, computed, input, model, output } from '@angular/core';
@Component({
selector: 'app-user-card',
template: `
<article>
<h3>{{ user().name }}</h3>
<button type="button" (click)="followed.emit(user().id)">关注</button>
<button type="button" (click)="expanded.set(!expanded())">
{{ expanded() ? '收起' : '展开' }}
</button>
@if (expanded()) {
<p>{{ user().bio }}</p>
}
</article>
`,
})
export class UserCardComponent {
// 输入:父 → 子,只读信号
user = input.required<User>();
compact = input(false, { alias: 'dense' });
// 输出:子 → 父,类型化的发射器
followed = output<number>();
// 双向:可写信号,父组件用 [(expanded)] 接
expanded = model(false);
isCompact = computed(() => this.compact() && !this.expanded());
}
与旧 API 的对照:
| 能力 | v22 写法 | 旧写法(仍可用) |
|---|---|---|
| 输入 | name = input.required<T>() | @Input() name!: T |
| 可选输入默认值 | input(defaultValue) | @Input() name = default |
| 输入别名 | input(false, { alias: 'dense' }) | @Input('dense') compact = false |
| 输出 | changed = output<T>() | @Output() changed = new EventEmitter<T>() |
| 双向 | size = model(16) | @Input() size + @Output() sizeChange |
| 响应输入变化 | computed / effect | ngOnChanges / setter |
关键区别在响应模型:input() / model() 是信号,派生值交给 computed,副作用交给 effect,不再需要生命周期钩子来”盯着输入”。
绑定目标速查表
| 语法 | 绑定到 | 方向 | 示例 |
|---|---|---|---|
{{ expr }} | 文本节点 | 状态 → 视图 | {{ count() }} |
[prop] | DOM property | 状态 → 视图 | [src]="url()" |
[attr.name] | HTML attribute | 状态 → 视图 | [attr.aria-label]="label()" |
[class.foo] | class | 状态 → 视图 | [class.active]="isActive()" |
[style.x] | style | 状态 → 视图 | [style.height.px]="h()" |
(event) | 事件 | 视图 → 状态 | (click)="save()" |
[(x)] | 双向 | 双向 | [(page)]="page" |
#ref | 模板引用变量 | —(引用) | #box 后 box.value |
#ref 严格说不是绑定,而是声明引用,第 9、10 篇会与 @let、ng-template 一起展开。
strictTemplates 下的常见报错
| 报错 | 原因 | 修法 |
|---|---|---|
Type 'x' is not assignable to type 'y' | 绑定值类型不匹配 | 修表达式或调整目标类型 |
Property 'nam' does not exist | 拼错字段名 | 改名(重构会自动同步) |
Expected 1 arguments, but got 0 | 模板里方法调用参数不全 | 补参数或改默认值 |
| 信号忘记调用 | [count] 传的是 Signal 对象 | 写成 [count]="count()" |
最后一个最常见:信号本身是函数对象,绑定到目标几乎必然报类型错误——看到 Signal 类型相关的报错,先检查括号。
实战:可分页的数据表格头部
把本篇语法组合成一个贴近日常的片段:
<section>
<div class="toolbar">
<input
#searchBox
placeholder="搜索"
(input)="query.set(searchBox.value)"
[class.invalid]="searchBox.value.length > 20"
/>
<select [value]="pageSize()" (change)="onPageSize($event)">
@for (size of pageSizes; track size) {
<option [value]="size">{{ size }} / 页</option>
}
</select>
</div>
<app-pager
[(page)]="page"
[pageSize]="pageSize()"
(pageChange)="logPageChange($event)"
/>
</section>
import { Component, inject, model, signal } from '@angular/core';
import { PagerComponent } from './pager.component';
@Component({
imports: [PagerComponent],
selector: 'app-user-table',
templateUrl: './user-table.component.html',
})
export class UserTableComponent {
query = signal('');
pageSize = signal(20);
page = model(1);
pageSizes = [10, 20, 50];
onPageSize(event: Event) {
this.pageSize.set(Number((event.target as HTMLSelectElement).value));
this.page.set(1);
}
logPageChange(page: number) {
console.log('page changed:', page);
}
}
插值、属性绑定、事件、[class]、model() 双向、控制流,一张模板全用上了。
小结
- 方向感是第一原则:
[ ]数据出、( )事件回、[( )]双向。 - property 与 attribute 分清楚,只有无对应 property 时才用
[attr.*]。 input()/output()/model()是 v22 标准契约,信号模型让派生与副作用回归computed/effect。$event在 DOM 事件与自定义 output 中含义不同,但都被 strictTemplates 类型化检查。- 看到信号相关类型报错,先怀疑漏了调用括号。