第116课:Angular 组件与模板语法——数据绑定、*ngIf、*ngFor、管道、样式封装
1 | Angular 组件由三部分组成:一个 TypeScript 类(逻辑)、一个 HTML 模板(视图)和一个可选的 CSS 样式文件。模板是 Angular 组件中最具特色的部分——它不仅是静态 HTML,还通过一套**声明式绑定语法**将组件类的数据与 DOM 元素连接起来。Angular 提供了四种数据绑定方式:**插值**(`{{ }}`)、**属性绑定**(`[ ]`)、**事件绑定**(`( )`)和**双向绑定**(`[( )]`)。同时,Angular 内置了一套**结构型指令**(`*ngIf`、`*ngFor`、`*ngSwitch`)用于控制 DOM 结构的增删和重复渲染,以及**管道**(Pipe)用于在模板中格式化数据。本节课将从零开始,逐一拆解这些模板语法的原理和使用方式,并通过一个完整的任务管理组件将所学知识串联。 |
1. 组件的基本结构:装饰器、选择器、模板与样式
在 Angular 中,一个组件是由 @Component 装饰器标注的 TypeScript 类。@Component 接收一个元数据对象,用于描述组件的模板、样式、选择器(CSS 选择器形式的标签名)以及它所属的模块。
1 | // task-list.component.ts |
**templateUrl 与 template**:推荐使用 templateUrl 引用外部 HTML 文件。对于极简组件,也可以使用 template 属性直接写内联 HTML 字符串。
styleUrls 与 styles:同理,styleUrls 引用外部 CSS/SCSS 文件,styles 写内联样式数组。Angular 默认使用样式封装(ViewEncapsulation.Emulated),为每个组件的 CSS 生成独特的属性选择器,确保组件样式不会泄漏到外部,也不会被外部样式意外覆盖。
1.1 组件类的生命周期
组件类中可以定义属性和方法,这些属性和方法就是模板的数据源和事件处理器。Angular 在创建组件实例后,会按照既定的生命周期调用钩子方法(如 ngOnInit、ngOnChanges、ngOnDestroy 等),这些将在后续课程详述。本节课聚焦于模板如何访问组件类中的成员——任何 public 的属性和方法都可以在模板中直接使用。
2. 数据绑定:将组件类与模板连接
Angular 模板中的数据绑定是一种声明式机制:你不需要手动调用 DOM API 来更新元素内容或属性,只需在模板中用特殊语法绑定组件类的成员,Angular 会在变更检测时自动同步 DOM。
2.1 插值(Interpolation)
1 | 最基础的绑定方式,用于将组件属性的值渲染为元素的文本内容。`{{ }}` 内部可以写任何合法的 TypeScript 表达式(但不能有副作用——如赋值、`new`、`++`、`--` 等)。 |
1 | export class AppComponent { |
1 | <h1>{{ title }}</h1> |
1 | **安全机制**:Angular 自动对插值结果进行 HTML 转义,防止 XSS 攻击。如果组件中有 `htmlSnippet = '<script>alert("XSS")</script>'`,使用 `{{ htmlSnippet }}` 会将其显示为纯文本,而非执行脚本。若确实需要渲染原始 HTML(谨慎使用),需使用 `[innerHTML]` 属性绑定并配合 Angular 的 `DomSanitizer` 服务。 |
2.2 属性绑定(Property Binding):[property]="expression"
用于将组件数据单向地流向 DOM 元素的属性(Property,而非 HTML Attribute)。属性绑定使用方括号 [ ] 语法。
1 | <!-- 将 imgUrl 属性绑定到 <img> 的 src 属性 --> |
Property vs Attribute:DOM 元素的 Property 是 JavaScript 对象的属性,反映元素的当前状态(如 input.value 是用户实时输入的值)。HTML Attribute 是写在标签上的静态字符串,仅用于初始化 Property。Angular 的属性绑定绑定的是 DOM Property,而非 HTML Attribute。例如 [disabled]="isDisabled" 绑定的是 HTMLButtonElement.disabled Property,当 isDisabled 为 true 时按钮不可用;而 attr.disabled 绑定的是 HTML 标签上的 disabled 属性字符串。
2.3 事件绑定(Event Binding):(event)="handler()"
用于从 DOM 元素单向地向组件传递用户操作。事件绑定使用圆括号 ( ) 语法。
1 | export class ButtonComponent { |
1 | <button (click)="handleClick()">点击我 ({{ count }})</button> |
$event 对象:$event 是 Angular 模板中的特殊变量,代表原生 DOM 事件对象。在事件处理函数中可以通过 $event.target.value 获取输入框的值。不过更好的方式是使用双向绑定(见下文)或模板引用变量(#inputRef)配合 (input) 事件。
2.4 双向绑定(Two-way Binding):[(ngModel)]
将属性绑定和事件绑定组合为一个语法糖。[(ngModel)] 是 Angular 内置的双向绑定指令,需要导入 FormsModule 才能使用。
1 | // app.module.ts |
1 | <input [(ngModel)]="username" placeholder="用户名" /> |
[(ngModel)]="username" 等价于 [ngModel]="username" (ngModelChange)="username = $event"。它既将 username 的值绑定到输入框的 value 属性,又监听输入框的 input 事件,将新值写回 username。**ngModel 是 FormsModule 提供的指令,不是 Angular 核心的一部分**,因此使用前必须导入。
2.5 四种绑定的记忆法则
| 绑定方向 | 语法 | 记忆口诀 |
|---|---|---|
| 组件 → 模板(文本) | {{ expression }} |
双花括号 |
| 组件 → 模板(属性) | [property]="value" |
方括号 |
| 模板 → 组件(事件) | (event)="handler()" |
圆括号 |
| 双向 | [(ngModel)]="value" |
香蕉箱(方括号套圆括号) |
3. 结构型指令:*ngIf、*ngFor、*ngSwitch
结构型指令通过添加或移除 DOM 元素来改变页面结构。它们以星号(*)为前缀,这是 Angular 的语法糖——实际上每个 * 指令都被展开为一个 <ng-template> 包裹的结构。
3.1 *ngIf:条件渲染
当表达式为 truthy 时,渲染宿主元素及其子节点;为 falsy 时,将其从 DOM 中完全移除(而非隐藏)。
1 | <div *ngIf="isLoggedIn; else loggedOut"> |
与 [hidden] 的区别:[hidden] 仅设置 CSS display: none,元素仍在 DOM 中,占用内存并可能被屏幕阅读器读取。*ngIf 完全销毁或创建元素,性能更优(对于不常切换的区块),但频繁切换时创建/销毁开销更大。选择原则:内容敏感或需要销毁资源时用 *ngIf;纯视觉切换且需要保留状态时用 [hidden] 或 CSS。
as 语法:在 *ngIf 中将条件结果赋值给局部变量,供后续使用。
1 | <div *ngIf="user$ | async as user"> |
3.2 *ngFor:循环渲染
为数组中的每个元素重复渲染宿主元素。语法为 *ngFor="let item of items; index as i; trackBy: trackByFn"。
1 | <ul> |
trackBy 的作用:默认情况下,当 tasks 数组的引用发生变化时,Angular 会销毁所有旧的 DOM 元素并重建新的。如果数组中只是修改了某个元素的属性(而非整体替换),大量的 DOM 操作是不必要的。trackBy 函数接收 index 和 item,返回一个唯一标识(如 item.id),Angular 根据该标识来跟踪元素,仅更新发生变化的部分,大幅提升列表渲染性能。
1 | trackById(index: number, item: Task): number { |
局部变量:*ngFor 提供了多个可用的局部变量:
| 变量 | 含义 |
|---|---|
index |
当前元素的索引(从 0 开始) |
count |
数组的总长度 |
first |
是否为第一个元素(boolean) |
last |
是否为最后一个元素(boolean) |
even |
索引是否为偶数(boolean) |
odd |
索引是否为奇数(boolean) |
1 | <li *ngFor="let item of items; index as i; first as isFirst" |
3.3 *ngSwitch:多分支条件渲染
相当于 JavaScript 的 switch 语句,用于根据一个表达式的值从多个可能的模板中选择一个渲染。
1 | <div [ngSwitch]="userRole"> |
[ngSwitch] 是属性绑定,*ngSwitchCase 和 *ngSwitchDefault 是结构型指令。与 *ngIf 类似,不匹配的分支会被完全从 DOM 中移除。
4. 管道(Pipe):数据格式化
管道是 Angular 模板中用于转换和格式化数据的工具。你可以在插值和属性绑定中使用管道,通过 | 符号连接。
4.1 内置管道
| 管道 | 用途 | 示例 |
|---|---|---|
uppercase |
将字符串转为全大写 | {{ 'hello' | uppercase }} → HELLO |
lowercase |
将字符串转为全小写 | {{ 'HELLO' | lowercase }} → hello |
date |
格式化日期对象 | {{ today | date:'yyyy-MM-dd' }} |
currency |
格式化货币值 | {{ price | currency:'CNY' }} → ¥100.00 |
number / decimal |
格式化数字 | {{ pi | number:'1.2-3' }} → 3.14 |
percent |
格式化为百分比 | {{ 0.85 | percent }} → 85% |
json |
将对象序列化为 JSON 字符串(调试用) | {{ user | json }} |
async |
订阅 Promise 或 Observable,自动取值 | {{ data$ | async }} |
slice |
截取数组或字符串的部分 | {{ items | slice:0:5 }} |
4.2 管道链与管道参数
管道可以串联使用(前一个管道的输出作为后一个管道的输入),部分管道可以接收参数(用 : 分隔)。
1 | <p>生日:{{ birthday | date:'yyyy年MM月dd日' | uppercase }}</p> |
4.3 自定义管道
使用 @Pipe 装饰器并实现 PipeTransform 接口。管道类必须提供一个 transform 方法。
1 | // truncate.pipe.ts |
在模块的 declarations 中注册后即可在模板中使用:
1 | <p>{{ longText | truncate:50 }}</p> |
纯管道与不纯管道:默认情况下,管道是纯的——仅当输入值的引用发生变化时,Angular 才会重新执行 transform。如果输入是一个对象,修改对象内部的属性不会触发管道重新计算(因为引用未变)。如果需要检测对象内部的变化,可以将管道设为 pure: false(不纯管道),但会带来性能开销,因为每次变更检测都会执行。大多数场景下,应使用纯管道,并在数据变化时替换对象引用(不可变更新)。
5. 样式封装(View Encapsulation)
Angular 默认使用 ViewEncapsulation.Emulated 模式。它会为每个组件的 HTML 元素生成一个唯一的属性(如 _ngcontent-xxx),并将组件的 CSS 选择器自动附加该属性选择器,从而将样式的作用域限制在组件内部。
1 | /* 组件写的 CSS */ |
三种封装模式:
| 模式 | 行为 |
|---|---|
Emulated(默认) |
样式被限定在组件内部,通过属性选择器模拟 Shadow DOM。 |
ShadowDom |
使用浏览器原生 Shadow DOM 封装样式,完全隔离。 |
None |
样式全局生效,不添加任何隔离机制。适用于需要全局样式穿透的场景。 |
在 @Component 中显式指定封装模式:
1 | import { Component, ViewEncapsulation } from '@angular/core'; |
::ng-deep(已弃用):在 Emulated 模式下,如果需要穿透组件样式作用于子组件,可以使用 ::ng-deep 伪类选择器(但已官方弃用,推荐使用全局样式文件或 ShadowDom 模式替代)。
6. 模板引用变量(Template Reference Variable)
使用 #variableName 在模板中声明一个局部变量,指向一个 DOM 元素、组件实例或指令。可以在当前模板的任何地方使用该变量。
1 | <!-- 指向 input 元素 --> |
模板引用变量的作用域是当前模板,在组件类中不能直接访问(除非通过 @ViewChild 装饰器)。它常用于表单输入、获取子组件引用和与 *ngFor 配合。
7. 综合实战:任务管理组件
以下示例将数据绑定、结构型指令、管道和模板引用变量整合为一个功能完整的任务管理组件。
1 | // task.model.ts |
1 | <!-- task-manager.component.html --> |
1 | /* task-manager.component.scss */ |
技术要点:
[(ngModel)]实现输入框的双向绑定,配合模板引用变量#taskInput在回车时获取值。*ngFor渲染任务列表,trackBy优化渲染性能。*ngIf条件显示“暂无任务”的空状态。[class.xxx]属性绑定动态设置 CSS 类名,实现优先级颜色和完成状态样式。date管道格式化任务创建日期。[ngSwitch]虽未在本例使用,但同等场景下可用[ngSwitch]替代*ngIf的多条件分支。- 组件类中的
get filteredTasks()是一个 TypeScript getter,在模板中被视为属性使用,每次变更检测时重新计算。
课后练习
一、概念自测(选择题 / 填空题)
(单选) Angular 中,
[hidden]="condition"与*ngIf="condition"的核心区别是什么?
A.*ngIf仅隐藏元素,[hidden]从 DOM 中移除元素。
B.[hidden]仅隐藏元素,*ngIf从 DOM 中完全移除或创建元素。
C. 两者行为完全相同。
D.[hidden]是 Angular 指令,*ngIf是 HTML 原生属性。(单选) 在
*ngFor中使用trackBy的主要目的是?
A. 提高数组遍历的速度。
B. 避免不必要的变化检测。
C. 通过唯一标识跟踪元素,减少不必要的 DOM 重建,提升渲染性能。
D. 动态修改数组的长度。(填空) Angular 模板中,使用
|符号对数据进行格式化转换的工具称为______。(多选) 以下哪些是 Angular 模板中合法的数据绑定语法?
A.{{ title }}
B.[src]="imageUrl"
C.(click)="handleClick()"
D.[[ngModel]]="name"
二、AI 编程任务:编写面向 AI 的提示词
场景:你需要实现一个 Angular 组件 ProductListComponent,用于展示商品列表并支持筛选和排序。要求如下:
- 使用
*ngFor渲染商品列表,每个商品包含名称、价格、分类和库存状态。 - 使用
*ngIf条件显示“暂无商品”的空状态。 - 通过属性绑定
[class.out-of-stock]标记库存为 0 的商品。 - 提供一个下拉框,使用
[(ngModel)]双向绑定分类筛选条件,点击筛选按钮过滤商品。 - 使用自定义管道
sortByPrice对商品列表按价格排序(升序/降序由组件属性控制)。 - 使用
date管道格式化商品的发布日期。
任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的完整 Angular 组件代码(含 TypeScript 类、HTML 模板、SCSS 样式和自定义管道)。提示词中需明确指定组件选择器、数据模型、指令和管道的使用方式。
三、Agent 模式下的提示词示例
你是一个资深前端开发 Agent。请创建一个 Angular 商品列表组件和对应的自定义管道。需要创建以下文件:
src/app/product/product.model.ts:导出Product接口,包含id: number、name: string、price: number、category: 'electronics' | 'clothing' | 'food'、stock: number、createdAt: Date。src/app/product/product-list/product-list.component.ts:
- 选择器
app-product-list。- 属性:
products: Product[](静态数据,至少 5 条不同分类和库存的商品)、selectedCategory: string = 'all'、sortOrder: 'asc' | 'desc' = 'asc'。- 计算属性
filteredProducts:先按selectedCategory过滤('all'则不过滤),再按price排序(使用sortOrder)。- 方法
setSort(order: 'asc' | 'desc')切换排序。- 模板:使用
[(ngModel)]绑定下拉框到selectedCategory;*ngFor遍历filteredProducts,trackBy使用product.id;[class.out-of-stock]="product.stock === 0";*ngIf判断filteredProducts.length === 0显示空状态;date管道格式化createdAt;点击排序按钮调用setSort。src/app/product/pipes/sort-by-price.pipe.ts:
- 自定义管道
sortByPrice(纯管道)。transform(products: Product[], order: 'asc' | 'desc' = 'asc'): Product[]:返回排序后的新数组(不修改原数组)。- 在模板中使用管道:
*ngFor="let p of filteredProducts | sortByPrice:sortOrder"(如果已在 getter 中排序则无需管道,为演示目的可任选一种方式;Agent 应选择在 getter 中排序,管道仅作为演示备用或单独导出)。
- Agent 可决定将管道用于单独示例,或采用 getter 方式。为了展示管道用法,可在模板中对另一个数组使用。
- 添加 JSDoc 注释和适当的 CSS 样式(
.out-of-stock { opacity: 0.5; }等)。完成后列出所有文件内容。
四、面试真题与参考答案
题目(腾讯前端面试题):
请解释 Angular 中
*ngIf和[hidden]的区别,以及各自适合的使用场景。另外,为什么在*ngFor中需要使用trackBy函数?如果不使用trackBy,当列表数据发生变化时 Angular 会如何处理 DOM?请结合变更检测机制说明。
参考答案:
*ngIf 是结构型指令,当条件为 false 时,它会将宿主元素及其子节点完全从 DOM 中移除,同时销毁组件实例(如果是组件),释放相关资源。当条件变回 true 时,Angular 会重新创建 DOM 元素和组件实例。[hidden] 是属性绑定,它仅设置元素的 hidden 属性(等价于 CSS display: none),元素仍在 DOM 中,组件实例未被销毁,事件监听和订阅也依然存在。
*ngIf 适合内容不常切换但资源占用较大的场景(如模态框、大型数据面板),切换时彻底释放资源。[hidden] 适合频繁切换且需要保留状态的场景(如标签页中已填写内容的表单——使用 *ngIf 切换会丢失用户的输入),或切换开销极小(仅视觉隐藏)。
*ngFor 中的 trackBy 用于优化列表渲染性能。默认情况下,当 *ngFor 的表达式数组引用发生变化时(如 this.items = newItems),Angular 会销毁所有旧的 DOM 元素并从头创建新元素。如果数组中大部分数据并未变化(如仅修改了某一项的属性),这种全量重建是完全不必要的。trackBy 函数返回一个唯一标识(如 item.id),Angular 根据该标识来跟踪每个元素的身份:当新数组中存在与旧数组相同标识的项时,Angular 会复用对应的 DOM 元素,仅更新其中变化的部分,而非销毁重建。
不使用时,对于大数据列表的频繁更新,DOM 操作的开销和变更检测的负担会显著增加。trackBy 是 Angular 性能优化的关键手段之一。
课后练习答案
一、概念自测答案
B
- 解析:
[hidden]仅隐藏元素(CSSdisplay: none),元素仍在 DOM 中;*ngIf在条件为false时从 DOM 中完全移除元素。
- 解析:
C
- 解析:
trackBy通过唯一标识跟踪元素,避免不必要的 DOM 销毁与重建,提升列表渲染性能。它不直接提高遍历速度,也不修改数组长度。
- 解析:
管道(或 Pipe)
- 解析:管道使用
|符号对数据进行格式化,如{{ birthday | date }}。
- 解析:管道使用
A、B、C
- 解析:D 错误,双向绑定的语法是
[(ngModel)]而非[[ngModel]]。A(插值)、B(属性绑定)、C(事件绑定)均为合法语法。
- 解析:D 错误,双向绑定的语法是
二、AI 编程任务参考答案(提示词示例)
示例提示词:
“请用 Angular 实现一个商品列表组件和自定义排序管道。要求:
- Product 接口:id, name, price, category, stock, createdAt。
- 组件:使用
*ngFor渲染列表,*ngIf处理空状态,[(ngModel)]绑定筛选下拉框,[class.out-of-stock]标记库存为 0 的商品,date管道格式化日期。- 自定义管道
sortByPrice:纯管道,按价格排序,参数为 ‘asc’ 或 ‘desc’。- 输出组件 TS、HTML、SCSS 和管道文件的完整代码。”