CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-174 · Angular 高级教程

Angular 22+ 教程 07:组件与模板——Template Binding Syntax

模板是 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.enterkeydown.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 / effectngOnChanges / 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模板引用变量—(引用)#boxbox.value

#ref 严格说不是绑定,而是声明引用,第 9、10 篇会与 @letng-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 类型化检查。
  • 看到信号相关类型报错,先怀疑漏了调用括号。

系列导航

← 算法 007:冒泡排序(Bubble Sort) 目录 开源合规 007:信息安全等级保护认证 →
← 返回文章列表