从零开始学 Angular:100 个示例,涵盖组件、模板、信号、表单、路由、HTTP 与各类属性

从零开始学 Angular:100 个示例,涵盖组件、模板、信号、表单、路由、HTTP 与各类属性

Angular 是 Google 推出的框架,用于用 TypeScript 构建结构清晰的大型 Web 应用。本文从创建项目开始,一直到 100 个可以运行的示例。你将学习组件与模板、控制流语法、管道、输入与输出、组件可以持有的各种属性、生命周期钩子、依赖注入、路由、响应式表单与模板驱动表单、基于 RxJS 的 HTTP、指令、信号、测试和部署。每个示例都是一小段代码。TypeScript 代码已用 TypeScript 5.6 解析器检查语法;模板和构建命令则按照 Angular 19 项目中的写法展示。

需要准备的东西

Node.js 20 或更新版本(含 npm)、Visual Studio Code 等代码编辑器,以及一个浏览器。用 npm install -g @angular/cli 安装 Angular CLI。需要了解 HTML、CSS 和 JavaScript 的基础知识,知道什么是类和函数会对 TypeScript 示例有帮助。

如何使用示例

TypeScript 示例放入独立的 Angular 项目中对应的组件或服务文件。命令示例在项目终端中运行,HTML 示例则是放入组件 HTML 文件的模板。

知识点 1: 项目设置与第一个组件

Angular 应用是一棵组件树。CLI 负责创建项目,每个组件都是一个带装饰器的类,装饰器提供选择器和模板。

创建项目

ng new 会创建独立应用的项目文件,这意味着组件不需要 NgModule。ng serve 启动带热重载的开发服务器,ng generate 则为新的组件、服务或管道创建文件。

启动与配置

main.ts 调用 bootstrapApplication,传入根组件和应用配置。配置在一个地方列出 providers,例如路由器和 HTTP 客户端。

组件、选择器与样式

@Component 装饰器设置选择器(即 HTML 标签)、模板和样式。Angular 会为组件的元素添加唯一的属性,因此样式只作用于该组件,两个组件都使用名为 card 的类也互不影响。

示意图

Angular 组件显示在页面上的过程:模板与类一起编译,Angular 保存组件的状态,信号与变更检测只更新视图中发生变化的部分
Angular 组件显示在页面上的过程:模板与类一起编译,Angular 保存组件的状态,信号与变更检测只更新视图中发生变化的部分

示例 1: 用 CLI 创建项目

安装 Angular CLI,并生成一个独立组件的应用。

npm install -g @angular/cli
ng new blog --standalone --routing --style=css
cd blog
ng serve

示例 2: 生成组件

CLI 为新组件创建文件。

ng generate component hello
# short form
ng g c hello --standalone

示例 3: 启动应用

用应用配置启动根组件。

import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

bootstrapApplication(AppComponent, appConfig)
  .catch((err) => console.error(err));

示例 4: 独立组件

不需要 NgModule 的组件。

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  standalone: true,
  template: '<h1>Hello, Angular!</h1>',
})
export class AppComponent {}

示例 5: 多行内联模板

用反引号编写跨多行的模板。

import { Component } from '@angular/core';

@Component({
  selector: 'app-card',
  standalone: true,
  template: `
    <section>
      <h2>Welcome</h2>
      <p>This template spans several lines.</p>
    </section>
  `,
})
export class CardComponent {}

示例 6: 组件样式

只作用于本组件的样式。

import { Component } from '@angular/core';

@Component({
  selector: 'app-badge',
  standalone: true,
  template: '<span class="badge">New</span>',
  styles: [`.badge { background: #3366ff; color: white; padding: 4px 8px; border-radius: 6px; }`],
})
export class BadgeComponent {}

示例 7: 带 providers 的应用配置

在一个地方注册路由器和其他服务。

import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes)],
};

示例 8: index.html 中的根元素

app-root 是 Angular 渲染应用的位置。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Blog</title>
  <base href="/">
</head>
<body>
  <app-root></app-root>
</body>
</html>

知识点 2: 模板与数据绑定

模板是带有额外语法的 HTML。它显示组件中的值,把用户操作传回组件,并保持输入与值同步。

插值与表达式

双花括号在文字中显示一个值,表达式可以进行计算、使用三元运算符,或使用安全导航运算符 ?.,这样对象不存在时也不会报错。

属性、特性与类绑定

方括号绑定 DOM 属性,例如 [disabled]。没有对应属性的 HTML 特性(例如 aria 标签)需要使用 attr. 进行特性绑定。类绑定和样式绑定会根据值添加类或内联样式。

事件绑定与双向绑定

圆括号监听事件,例如 (click)。使用 [(ngModel)] 的双向绑定让表单字段与组件中的值保持一致,需要引入 FormsModule。#box 这样的模板引用变量为元素命名,供模板的其他部分使用。

示例 9: 插值

用双花括号把值显示在文字中。

import { Component } from '@angular/core';

@Component({
  selector: 'app-greeting',
  standalone: true,
  template: '<p>Hello, {{ name }}! You have {{ count + 1 }} message(s).</p>',
})
export class GreetingComponent {
  name = 'Ada';
  count = 2;
}

示例 10: 模板表达式

在模板中计算、使用三元运算符和安全导航。

<p>{{ price * quantity }}</p>
<p>{{ isLoggedIn ? 'Welcome back' : 'Please sign in' }}</p>
<p>{{ user?.name ?? 'Guest' }}</p>

示例 11: 属性绑定

用组件中的值设置元素的属性。

<img [src]="photoUrl" [alt]="photoAlt" width="120">
<button [disabled]="saving">Save</button>

示例 12: 特性绑定

设置没有对应属性的 HTML 特性。

<button [attr.aria-label]="closeLabel">X</button>
<td [attr.colspan]="columns">Total</td>

示例 13: 事件绑定

用户点击时运行组件的方法。

import { Component } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <p>Clicked {{ clicks }} times</p>
    <button (click)="increment()">Click me</button>
  `,
})
export class CounterComponent {
  clicks = 0;
  increment() {
    this.clicks++;
  }
}

示例 14: 使用 ngModel 的双向绑定

让输入框与组件中的值保持同步。

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-name-field',
  standalone: true,
  imports: [FormsModule],
  template: `
    <input [(ngModel)]="name">
    <p>You typed: {{ name }}</p>
  `,
})
export class NameFieldComponent {
  name = '';
}

示例 15: 类绑定

根据值添加或移除 CSS 类。

<div [class.active]="isActive" [class.error]="hasError">Panel</div>
<div [ngClass]="{ 'big': size === 'lg', 'hidden': !visible }">Box</div>

示例 16: 样式绑定

根据组件中的值设置内联样式。

<p [style.color]="textColor" [style.font-size.px]="fontSize">Styled text</p>

示例 17: 模板引用变量

为元素命名,以便模板的其他部分使用它。

<input #nameBox type="text">
<button (click)="greet(nameBox.value)">Greet</button>

知识点 3: 控制流:@if、@for、@switch 与 @defer

从 Angular 17 开始,模板内置了条件和循环的代码块。它们取代了旧的结构型指令,更易读。

@if 与 @else

@if 仅在条件为真时显示代码块。@else if 与 @else 在多个代码块之间选择一个。代码块可以嵌套,例如在 for 中放入 if。

带 track 与 @empty 的 @for

@for 为每一项重复代码块。track 告诉 Angular 每一项是谁,从而只重绘变化的行。@empty 在列表为空时显示提示。track 是必需的。

@switch 与 @defer

@switch 根据表达式的值选择代码块,并可用 @default 作为后备。@defer 会延迟加载页面的一部分,例如在它进入视口或用户与之交互时加载,在此之前显示 @placeholder 或 @loading。

示例 18: @if

只有条件为真时才显示代码块。

@if (items.length > 0) {
  <p>You have {{ items.length }} items.</p>
}

示例 19: @if 与 @else

在多个代码块之间选择一个。

@if (isLoggedIn) {
  <p>Welcome back!</p>
} @else if (isGuest) {
  <p>Browsing as a guest.</p>
} @else {
  <p>Please sign in.</p>
}

示例 20: 带 track 的 @for

为每一项重复代码块,track 为每一项提供稳定的标识。

<ul>
  @for (fruit of fruits; track fruit.id) {
    <li>{{ fruit.name }}</li>
  }
</ul>

示例 21: @for 与 @empty

列表为空时显示提示。

@for (post of posts; track post.id) {
  <article>{{ post.title }}</article>
} @empty {
  <p>No posts yet.</p>
}

示例 22: @switch

根据表达式的值选择代码块。

@switch (status) {
  @case ('draft') { <span>Draft</span> }
  @case ('published') { <span>Live</span> }
  @default { <span>Unknown</span> }
}

示例 23: 带占位符的 @defer

稍后加载页面的一部分,先显示占位符。

@defer (on viewport) {
  <app-heavy-chart />
} @placeholder {
  <p>Chart will load when visible.</p>
} @loading {
  <p>Loading chart...</p>
}

示例 24: 用 @let 定义局部值

为值命名一次,然后在模板中多次使用。

@let total = price * quantity;
<p>Total: {{ total }}</p>

知识点 4: 管道

管道在模板中转换值,例如格式化日期或金额。组件中的值保持不变。

内置的格式化管道

date、currency、number、uppercase、lowercase、titlecase 和 slice 在模板中格式化值。参数写在冒号后面,例如 date: 'longDate' 或 currency: 'USD'。

json、keyvalue 与 async

json 把对象显示为文本,调试时很有用。keyvalue 遍历对象的键和值。async 订阅一个 Observable 并显示其最新值,组件销毁时会自动取消订阅。

自定义管道

带有 @Pipe 装饰器并实现 transform 方法的类就是一个新的管道。当你需要在多个地方格式化同一类值时编写管道。管道应当没有副作用,这样 Angular 才能复用它的结果。

示例 25: 日期管道

格式化日期以便显示。

<p>{{ today | date: 'longDate' }}</p>
<p>{{ today | date: 'shortTime' }}</p>

示例 26: 货币与数字管道

格式化金额和小数。

<p>{{ 1234.5 | currency: 'USD' }}</p>
<p>{{ 3.14159 | number: '1.2-2' }}</p>

示例 27: 文本管道

在模板中改变大小写或截取字符串。

<p>{{ 'angular' | uppercase }}</p>
<p>{{ 'HELLO' | lowercase }}</p>
<p>{{ 'hello world' | titlecase }}</p>
<p>{{ 'Angular' | slice: 0 : 3 }}</p>

示例 28: json 与 keyvalue 管道

显示对象用于调试,或遍历其键和值。

<pre>{{ user | json }}</pre>
<ul>
  @for (item of settings | keyvalue; track item.key) {
    <li>{{ item.key }}: {{ item.value }}</li>
  }
</ul>

示例 29: async 管道

订阅 Observable 并显示最新的值。

import { Component } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { Observable, of } from 'rxjs';

@Component({
  selector: 'app-clock',
  standalone: true,
  imports: [AsyncPipe],
  template: '<p>Value: {{ value$ | async }}</p>',
})
export class ClockComponent {
  value$: Observable<number> = of(42);
}

示例 30: 自定义管道

编写一个缩短文本的管道。

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'shorten', standalone: true })
export class ShortenPipe implements PipeTransform {
  transform(value: string, max = 20): string {
    return value.length > max ? value.slice(0, max) + '...' : value;
  }
}

知识点 5: 组件之间的通信

数据通过输入从父组件流向子组件,事件通过输出向上流动。不是父子关系的组件可以改用共享服务。

输入

input() 声明父组件设置的值。可以给它默认值;当父组件必须提供该值时使用 input.required;当属性名应当不同时添加 alias。旧的 @Input 装饰器仍然可用。

输出与 @Output

output() 声明一个事件,子组件通过 .emit() 发出它。父组件用事件绑定监听,例如 (liked)='save()'。旧的 @Output 配合 EventEmitter 的行为相同。

投影、查询与共享服务

ng-content 允许父组件把自己的内容放进子组件。viewChild 和 contentChild 通过名称查找元素或模板。带有 Subject 或信号的共享服务,让距离较远的组件也能通信。

示例 31: input() 信号

接收父组件的值,并设置默认值。

import { Component, input } from '@angular/core';

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: '<p>Hello, {{ name() }}</p>',
})
export class UserCardComponent {
  name = input<string>('Guest');
}

示例 32: 必填的 input

强制父组件提供该值。

import { Component, input } from '@angular/core';

@Component({
  selector: 'app-price',
  standalone: true,
  template: '<p>{{ amount() }} THB</p>',
})
export class PriceComponent {
  amount = input.required<number>();
}

示例 33: output() 事件

从子组件向父组件发送事件。

import { Component, output } from '@angular/core';

@Component({
  selector: 'app-like-button',
  standalone: true,
  template: '<button (click)="liked.emit()">Like</button>',
})
export class LikeButtonComponent {
  liked = output<void>();
}

示例 34: @Output 与 EventEmitter

子组件发出事件的传统方式。

import { Component, EventEmitter, Output } from '@angular/core';

@Component({
  selector: 'app-search-box',
  standalone: true,
  template: '<input (keyup.enter)="submit($event)">',
})
export class SearchBoxComponent {
  @Output() searched = new EventEmitter<string>();

  submit(event: Event) {
    this.searched.emit((event.target as HTMLInputElement).value);
  }
}

示例 35: viewChild 信号查询

获取组件模板中某个元素的引用。

import { Component, ElementRef, viewChild } from '@angular/core';

@Component({
  selector: 'app-focus',
  standalone: true,
  template: '<input #box type="text">',
})
export class FocusComponent {
  box = viewChild<ElementRef<HTMLInputElement>>('box');

  focusBox() {
    this.box()?.nativeElement.focus();
  }
}

示例 36: ng-content 内容投影

让父组件把自己的内容放进子组件。

import { Component } from '@angular/core';

@Component({
  selector: 'app-panel',
  standalone: true,
  template: `
    <section class="panel">
      <ng-content></ng-content>
    </section>
  `,
})
export class PanelComponent {}

示例 37: contentChild 与 ng-template

接收父组件的模板,并为每一项渲染它。

import { Component, contentChild, TemplateRef } from '@angular/core';

@Component({
  selector: 'app-list',
  standalone: true,
  imports: [],
  template: `
    @for (item of items; track item) {
      <ng-container [ngTemplateOutlet]="itemTpl()" [ngTemplateOutletContext]="{ $implicit: item }"></ng-container>
    }
  `,
})
export class ListComponent {
  items = ['a', 'b'];
  itemTpl = contentChild<TemplateRef<unknown>>('itemTpl');
}

示例 38: host binding 与 host listener

绑定宿主元素,并监听它的事件。

import { Component, HostBinding, HostListener } from '@angular/core';

@Component({
  selector: 'app-hover-card',
  standalone: true,
  template: '<p>Hover me</p>',
})
export class HoverCardComponent {
  @HostBinding('class.highlighted') highlighted = false;

  @HostListener('mouseenter') onEnter() {
    this.highlighted = true;
  }

  @HostListener('mouseleave') onLeave() {
    this.highlighted = false;
  }
}

示例 39: host 元数据

在装饰器中设置宿主的类和特性。

import { Component } from '@angular/core';

@Component({
  selector: 'app-tag',
  standalone: true,
  template: '<ng-content></ng-content>',
  host: {
    'role': 'note',
    '[class.small]': 'small',
  },
})
export class TagComponent {
  small = true;
}

示例 40: 用于消息传递的共享服务

在非父子关系的组件之间发送消息。

import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class MessageBus {
  private messages = new Subject<string>();
  messages$ = this.messages.asObservable();

  send(text: string) {
    this.messages.next(text);
  }
}

知识点 6: 各种类型的属性

组件有几种属性,每种适合不同的需求:普通值、只读值、计算值、响应式信号以及双向值。

普通属性、readonly 与可选属性

普通属性是可以改变的字段。readonly 在字段设置之后禁止重新赋值。属性名后加问号,例如 email?,表示该值可能不存在,代码必须处理 undefined。

getter、setter 与静态属性

getter 在读取属性时运行代码,setter 在写入属性时运行代码。静态属性属于类本身,而不属于每个实例,适合像主题名称这样共享的默认值。

信号:signal、computed 与 effect

signal() 保存一个值,并在值变化时通知 Angular,读取时像函数一样调用,例如 count()。computed() 从信号派生出值,信号变化时自动更新。effect() 会在它读取的信号变化时运行代码。

model()、别名与 input.required

model() 是双向输入:子组件可以设置它,父组件的值随之变化,相当于自定义组件中的 ngModel。别名让输入使用不同的属性名。input.required 要求父组件提供值。

箭头函数属性

保存箭头函数的属性在把该函数作为回调传递时,仍然保持组件的 this,因为箭头函数在创建时就捕获了 this。

示例 41: 带默认值的类属性

带有初始值的普通属性。

import { Component } from '@angular/core';

@Component({
  selector: 'app-profile',
  standalone: true,
  template: '<p>{{ username }} ({{ role }})</p>',
})
export class ProfileComponent {
  username = 'mali';
  role: string = 'editor';
}

示例 42: readonly 属性

只能在声明时设置一次的属性。

import { Component } from '@angular/core';

@Component({
  selector: 'app-version',
  standalone: true,
  template: '<p>Version {{ version }}</p>',
})
export class VersionComponent {
  readonly version = '1.4.0';
}

示例 43: getter 与 setter 属性

读取或写入时运行代码的属性。

import { Component } from '@angular/core';

@Component({
  selector: 'app-temperature',
  standalone: true,
  template: '<p>{{ fahrenheit }} F</p>',
})
export class TemperatureComponent {
  private celsius = 0;

  get fahrenheit(): number {
    return this.celsius * 9 / 5 + 32;
  }

  set fahrenheit(value: number) {
    this.celsius = (value - 32) * 5 / 9;
  }
}

示例 44: 带 set 与 update 的信号属性

用 set 或 update 修改的响应式值。

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter-signal',
  standalone: true,
  template: `
    <p>{{ count() }}</p>
    <button (click)="count.update(n => n + 1)">+1</button>
    <button (click)="count.set(0)">Reset</button>
  `,
})
export class CounterSignalComponent {
  count = signal(0);
}

示例 45: computed 属性

由信号派生、会自动更新的值。

import { Component, computed, signal } from '@angular/core';

@Component({
  selector: 'app-cart',
  standalone: true,
  template: '<p>Total: {{ total() }}</p>',
})
export class CartComponent {
  prices = signal([120, 80, 50]);
  total = computed(() => this.prices().reduce((sum, p) => sum + p, 0));
}

示例 46: effect

每当它读取的信号变化时运行代码。

import { Component, effect, signal } from '@angular/core';

@Component({
  selector: 'app-theme',
  standalone: true,
  template: '<p>Theme: {{ theme() }}</p>',
})
export class ThemeComponent {
  theme = signal('light');

  constructor() {
    effect(() => {
      console.log('Theme changed to', this.theme());
    });
  }
}

示例 47: model() 双向输入

子组件也可以更新的输入,类似 ngModel。

import { Component, model } from '@angular/core';

@Component({
  selector: 'app-toggle',
  standalone: true,
  template: '<button (click)="on.set(!on())">{{ on() ? "On" : "Off" }}</button>',
})
export class ToggleComponent {
  on = model(false);
}

示例 48: 带别名的 input

在模板中使用不同的属性名。

import { Component, input } from '@angular/core';

@Component({
  selector: 'app-avatar',
  standalone: true,
  template: '<img [alt]="label()">',
})
export class AvatarComponent {
  label = input('Profile picture', { alias: 'caption' });
}

示例 49: 可选属性

可能没有值的属性,用问号标记。

interface Customer {
  id: number;
  name: string;
  email?: string;
}

const shopper: Customer = { id: 1, name: 'Ploy' };
console.log(shopper.email ?? 'no email');

示例 50: static 属性与箭头函数属性

整个类共享的值,以及保持 this 的函数。

import { Component } from '@angular/core';

@Component({
  selector: 'app-config',
  standalone: true,
  template: '<p>{{ appName }}</p>',
})
export class ConfigComponent {
  static readonly defaultTheme = 'light';
  appName = 'Blog';
  logName = () => console.log(this.appName);
}

知识点 7: 生命周期钩子

在组件生命周期的固定时间点,Angular 会调用特定的方法:创建时、输入变化时、视图绘制之后,以及被移除之前。

ngOnInit 与 ngOnChanges

ngOnInit 在 Angular 设置第一批输入之后只运行一次,因此适合加载数据。ngOnChanges 在每次输入变化时运行,并通过 SimpleChanges 接收旧值与新值。

ngOnDestroy

ngOnDestroy 在组件被移除之前运行。在这里取消长时间运行的数据流的订阅,并清除定时器,以防止内存泄漏。

ngAfterViewInit 与 ngDoCheck

ngAfterViewInit 在组件的视图和子视图绘制之后运行,此时视图中的元素可以使用。ngDoCheck 在每一轮变更检测中都会运行,因此应当保持轻量。

示例 51: ngOnInit

在 Angular 设置输入之后,只运行一次的初始化代码。

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

@Component({
  selector: 'app-todo-list',
  standalone: true,
  template: '<p>{{ items.length }} tasks loaded</p>',
})
export class TodoListComponent implements OnInit {
  items: string[] = [];

  ngOnInit() {
    this.items = ['Write', 'Test', 'Ship'];
  }
}

示例 52: ngOnChanges

父组件的输入变化时作出响应。

import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';

@Component({
  selector: 'app-badge-count',
  standalone: true,
  template: '<span>{{ count }}</span>',
})
export class BadgeCountComponent implements OnChanges {
  @Input() count = 0;

  ngOnChanges(changes: SimpleChanges) {
    if (changes['count']) {
      console.log('Count changed to', changes['count'].currentValue);
    }
  }
}

示例 53: ngOnDestroy

组件被移除时取消订阅并清除定时器。

import { Component, OnDestroy, OnInit } from '@angular/core';
import { interval, Subscription } from 'rxjs';

@Component({
  selector: 'app-timer',
  standalone: true,
  template: '<p>Ticks: {{ ticks }}</p>',
})
export class TimerComponent implements OnInit, OnDestroy {
  ticks = 0;
  private sub?: Subscription;

  ngOnInit() {
    this.sub = interval(1000).subscribe(() => this.ticks++);
  }

  ngOnDestroy() {
    this.sub?.unsubscribe();
  }
}

示例 54: ngAfterViewInit

模板绘制完成后使用视图中的元素。

import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core';

@Component({
  selector: 'app-autofocus',
  standalone: true,
  template: '<input #field>',
})
export class AutofocusComponent implements AfterViewInit {
  @ViewChild('field') field!: ElementRef<HTMLInputElement>;

  ngAfterViewInit() {
    this.field.nativeElement.focus();
  }
}

示例 55: ngDoCheck

在每次变更检测时执行检查。

import { Component, DoCheck } from '@angular/core';

@Component({
  selector: 'app-watcher',
  standalone: true,
  template: '<p>Checks run: {{ checks }}</p>',
})
export class WatcherComponent implements DoCheck {
  checks = 0;

  ngDoCheck() {
    this.checks++;
  }
}

知识点 8: 依赖注入与服务

服务保存多个组件都需要的逻辑和数据。Angular 负责创建服务,并通过依赖注入传入,因此组件不需要自己创建副本。

@Injectable 与 providedIn root

带有 @Injectable({ providedIn: 'root' }) 的类在整个应用中只创建一次。组件可以用 inject() 或构造函数参数获取它。在服务中使用信号保存模板可以读取的状态。

InjectionToken 与值型 provider

令牌用来标识非类的值,例如一个配置对象。你用 provider 注册它:useValue 用于固定的值,useClass 用于类,useFactory 用于构建值。

组件级 provider

组件上的 providers 数组让该组件及其子组件拥有服务自己的实例。这适合那些应当在每次创建组件时重置的状态。

示例 56: inject() 函数

不用构造函数参数即可获取服务。

import { Component, inject } from '@angular/core';
import { DataService } from './data.service';

@Component({
  selector: 'app-items',
  standalone: true,
  template: '<p>{{ data.count() }} items</p>',
})
export class ItemsComponent {
  data = inject(DataService);
}

示例 57: 可注入的服务

任何组件都可以接收的共享类。

import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CartService {
  private itemsSignal = signal<string[]>([]);
  readonly items = this.itemsSignal.asReadonly();

  add(item: string) {
    this.itemsSignal.update((list) => [...list, item]);
  }
}

示例 58: InjectionToken

提供非类的值,例如配置。

import { InjectionToken } from '@angular/core';

export interface AppSettings {
  apiUrl: string;
  pageSize: number;
}

export const APP_SETTINGS = new InjectionToken<AppSettings>('app.settings');

示例 59: useValue 的 provider

把固定的对象交给注入器。

import { ApplicationConfig } from '@angular/core';
import { APP_SETTINGS } from './app-settings.token';

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: APP_SETTINGS,
      useValue: { apiUrl: 'https://api.example.com', pageSize: 20 },
    },
  ],
};

示例 60: useClass 与 useFactory

需要时选择类,或创建一个值。

import { ApplicationConfig, inject } from '@angular/core';
import { LoggerService } from './logger.service';
import { ConsoleLogger } from './console-logger';

export const appConfig: ApplicationConfig = {
  providers: [
    { provide: LoggerService, useClass: ConsoleLogger },
    {
      provide: 'clock',
      useFactory: () => () => new Date().getFullYear(),
    },
  ],
};

示例 61: 组件级 provider

让服务在某个组件树中拥有自己的实例。

import { Component, inject } from '@angular/core';
import { CounterStore } from './counter.store';

@Component({
  selector: 'app-scoped-counter',
  standalone: true,
  template: '<p>{{ store.value() }}</p>',
  providers: [CounterStore],
})
export class ScopedCounterComponent {
  store = inject(CounterStore);
}

知识点 9: 路由

路由器把 URL 路径映射到组件,在 router-outlet 中显示匹配的页面,并且可以只在需要时加载页面。

路由与导航链接

Routes 数组把 '' 和 'about' 等路径映射到组件。routerLink 创建不会重新加载页面的链接,routerLinkActive 为当前页面的链接添加类。** 通配符路由用于处理未知的 URL。

参数与懒加载

像 'post/:id' 这样的路由会传递 id,你可以从 ActivatedRoute 中读取。使用动态导入的 loadComponent 只在用户打开页面时才加载其代码,从而让首次下载更小。

守卫、解析器与代码中的导航

CanActivate 守卫返回 true,或返回一个 UrlTree 来重定向。解析器在页面打开前加载数据。Router.navigate 从代码中导航,并可以传递查询参数。

示例 62: 路由

把 URL 路径映射到组件。

import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { AboutComponent } from './about/about.component';

export const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'about', component: AboutComponent },
  { path: '**', redirectTo: '' },
];

示例 63: routerLink 与 router-outlet

在页面之间建立链接,并显示匹配的页面。

<nav>
  <a routerLink="/" routerLinkActive="active">Home</a>
  <a routerLink="/about" routerLinkActive="active">About</a>
</nav>
<router-outlet></router-outlet>

示例 64: 路由参数

从 URL 中读取 id 等值。

import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

@Component({
  selector: 'app-post',
  standalone: true,
  template: '<p>Post id: {{ id }}</p>',
})
export class PostComponent {
  private route = inject(ActivatedRoute);
  id = this.route.snapshot.paramMap.get('id');
}

示例 65: 懒加载路由

只有用户打开页面时才加载其代码。

import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'admin',
    loadComponent: () =>
      import('./admin/admin.component').then((m) => m.AdminComponent),
  },
];

示例 66: CanActivate 守卫

根据检查结果允许或阻止路由。

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';

export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthService);
  return auth.isLoggedIn() ? true : inject(Router).createUrlTree(['/login']);
};

示例 67: 解析器

在页面打开之前加载数据。

import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { PostService } from './post.service';

export const postResolver: ResolveFn<string> = (route) => {
  return inject(PostService).titleFor(route.paramMap.get('id') ?? '');
};

示例 68: 编程式导航

用代码跳转到其他页面,并带上查询参数。

import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';

@Component({
  selector: 'app-checkout',
  standalone: true,
  template: '<button (click)="finish()">Finish</button>',
})
export class CheckoutComponent {
  private router = inject(Router);

  finish() {
    this.router.navigate(['/thanks'], { queryParams: { ref: 'cart' } });
  }
}

知识点 10: 表单

Angular 有两种表单风格。响应式表单在代码中构建表单,是大型表单的常用选择。模板驱动表单在 HTML 中使用 ngModel,适合小型表单。

FormControl、FormGroup 与 FormBuilder

FormControl 保存一个值,FormGroup 把多个控件组合在一起。FormBuilder 减少代码量,nonNullable 让重置后的值保持为字符串,而不是 null。

验证器与错误信息

Validators.required、minLength、min、max 和 email 是内置的。只有在控件无效且用户已经接触过它时才显示错误,这样用户在输入之前不会看到错误。自定义验证器是返回错误对象或 null 的函数。

FormArray 与 valueChanges

FormArray 是一组可以添加或删除的控件。valueChanges 是一个 Observable,每当值变化时发出新值,适合实时搜索或保存草稿。

示例 69: 响应式 FormControl

值保存在代码中的表单字段。

import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-email-input',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: '<input [formControl]="email">',
})
export class EmailInputComponent {
  email = new FormControl('', { nonNullable: true });
}

示例 70: FormGroup 与 FormBuilder

把多个字段组合成一个响应式表单。

import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-signup',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: '<form [formGroup]="form"></form>',
})
export class SignupComponent {
  private fb = inject(FormBuilder);
  form = this.fb.group({
    name: [''],
    email: [''],
  });
}

示例 71: 内置验证器

必填、最小长度、最小值、最大值和邮箱检查。

import { FormBuilder, Validators } from '@angular/forms';
import { inject } from '@angular/core';

const fb = inject(FormBuilder);
const form = fb.nonNullable.group({
  name: ['', [Validators.required, Validators.minLength(2)]],
  age: [18, [Validators.min(13), Validators.max(120)]],
  email: ['', Validators.email],
});

示例 72: 响应式表单模板

把 FormGroup 绑定到 HTML 并显示错误。

<form [formGroup]="form" (ngSubmit)="save()">
  <input formControlName="name" placeholder="Name">
  @if (form.controls.name.invalid && form.controls.name.touched) {
    <p class="error">Name is required.</p>
  }
  <button [disabled]="form.invalid">Save</button>
</form>

示例 73: FormArray

可以增减的表单控件列表。

import { FormArray, FormControl } from '@angular/forms';

const tags = new FormArray([
  new FormControl('angular'),
  new FormControl('typescript'),
]);

tags.push(new FormControl('rxjs'));
console.log(tags.length);

示例 74: 自定义验证器

编写自己的规则,例如不允许空格。

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function noSpaces(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = String(control.value ?? '');
    return value.includes(' ') ? { noSpaces: true } : null;
  };
}

示例 75: 模板驱动表单

直接在模板中用 ngModel 构建表单。

<form #form="ngForm" (ngSubmit)="submit(form.value)">
  <input name="title" ngModel required>
  <button [disabled]="form.invalid">Post</button>
</form>

示例 76: valueChanges

每当表单的值变化时作出响应。

import { FormControl } from '@angular/forms';

const search = new FormControl('');

search.valueChanges.subscribe((text) => {
  console.log('Searching for', text);
});

知识点 11: HTTP、RxJS 与异步数据

HttpClient 发送请求并返回 Observable。RxJS 操作符转换这些数据流,async 管道无需手动订阅即可显示结果。

设置 HttpClient

在应用配置中调用 provideHttpClient()。HttpClient 的 get 和 post 返回 Observable,在有人订阅之前什么也不做,因此在模板中要使用 async 管道或 toSignal。

操作符:map、filter 与 switchMap

map 改变每个值,filter 只保留需要的值。switchMap 在新值到来时取消之前的请求,适合随用户输入进行搜索。加上 debounceTime 可以等待输入停止。

错误与重试

catchError 处理失败的请求,并可以返回一个安全的值。retry 在失败之前再次尝试请求。出错时用户看到什么,必须事先决定好,因为空白页面会让人困惑。

示例 77: provideHttpClient

在应用配置中启用 HttpClient。

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient()],
};

示例 78: 用 async 管道获取数据

无需手动订阅即可显示来自 HTTP 的列表。

import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-users',
  standalone: true,
  imports: [AsyncPipe],
  template: `
    @for (user of users$ | async; track user.id) {
      <p>{{ user.name }}</p>
    }
  `,
})
export class UsersComponent {
  private http = inject(HttpClient);
  users$: Observable<{ id: number; name: string }[]> =
    this.http.get<{ id: number; name: string }[]>('/api/users');
}

示例 79: RxJS 的 map 与 filter

从数据流中转换并只保留需要的值。

import { map, filter } from 'rxjs';

const prices$ = of(120, 35, 80);
prices$
  .pipe(
    filter((p) => p > 50),
    map((p) => p * 1.07),
  )
  .subscribe((total) => console.log(total.toFixed(2)));

示例 80: 用于实时搜索的 switchMap

等待输入停止后,取消之前的请求。

import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';

@Component({
  selector: 'app-live-search',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: '<input [formControl]="query">',
})
export class LiveSearchComponent {
  private http = inject(HttpClient);
  query = new FormControl('', { nonNullable: true });

  results$ = this.query.valueChanges.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((q) => this.http.get<string[]>('/api/search', { params: { q } })),
  );
}

示例 81: catchError 与 retry

重试失败的请求,然后回退到安全的值。

import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, of, retry } from 'rxjs';

const http = inject(HttpClient);
const safeList$ = http.get<string[]>('/api/tags').pipe(
  retry(2),
  catchError(() => of([] as string[])),
);

示例 82: HTTP POST

用请求体把数据发送到服务器。

import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

export class PostService {
  private http = inject(HttpClient);

  createPost(title: string) {
    return this.http.post<{ id: number }>('/api/posts', { title });
  }
}

知识点 12: 指令

指令改变元素的行为,或决定元素是否出现。属性指令改变元素本身,结构型指令则添加或删除元素。

属性指令

选择器写在方括号中的指令,例如 [appHighlight],可以用 HostListener 响应鼠标事件,并改变元素的样式。应保持逻辑简单,复杂的行为应放在组件中。

结构型指令与 TemplateRef

结构型指令注入 TemplateRef 和 ViewContainerRef。当条件为真时,它用 createEmbeddedView 创建视图;为假时清除视图。Angular 的 *ngIf 和 @if 工作方式相同。

指令中输入的 setter

输入上的 setter 在父组件每次传入新值时运行。可以用它直接响应值,例如重建视图,而无需编写 ngOnChanges。

示例 83: 属性指令

改变元素外观的指令。

import { Directive, ElementRef, HostListener, inject, input } from '@angular/core';

@Directive({
  selector: '[appHighlight]',
  standalone: true,
})
export class HighlightDirective {
  color = input('yellow', { alias: 'appHighlight' });
  private el = inject<ElementRef<HTMLElement>>(ElementRef);

  @HostListener('mouseenter') onEnter() {
    this.el.nativeElement.style.backgroundColor = this.color();
  }

  @HostListener('mouseleave') onLeave() {
    this.el.nativeElement.style.backgroundColor = '';
  }
}

示例 84: 结构型指令

在页面中添加或移除元素的指令。

import { Directive, inject, Input, TemplateRef, ViewContainerRef } from '@angular/core';

@Directive({
  selector: '[appUnless]',
  standalone: true,
})
export class UnlessDirective {
  private template = inject<TemplateRef<unknown>>(TemplateRef);
  private view = inject(ViewContainerRef);

  @Input() set appUnless(condition: boolean) {
    this.view.clear();
    if (!condition) {
      this.view.createEmbeddedView(this.template);
    }
  }
}

示例 85: input 的 setter

父组件每次传入新值时运行代码。

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

@Component({
  selector: 'app-tag-list',
  standalone: true,
  template: '<p>{{ tags.join(", ") }}</p>',
})
export class TagListComponent {
  private _tags: string[] = [];

  @Input()
  set tags(value: string[]) {
    this._tags = value.map((t) => t.trim());
  }
  get tags(): string[] {
    return this._tags;
  }
}

知识点 13: 渲染、信号与变更检测

Angular 通过变更检测决定重绘哪些部分。信号与 OnPush 让你控制这一过程,使大型页面保持快速。

带索引的列表与计算值

在 @for 中可以使用 $index、$first 和 $last 来设置每一行的样式。computed 信号可以过滤列表,因此过滤结果会随搜索文字或列表的变化而更新。

OnPush 变更检测

使用 ChangeDetectionStrategy.OnPush 后,Angular 只在输入变化、组件内发生事件,或它读取的信号变化时才检查该组件。在组件很多的页面上,这能显著提速。

zoneless 与 toSignal

zoneless 应用不使用 zone.js 运行,因此视图通过信号和事件更新,而不是因为浏览器的每个事件而更新。toSignal 把 Observable 转换为信号,可以在模板中直接读取,无需订阅。

示例 86: 带 $index 与 $first 的 @for

使用循环中每一项的位置。

@for (step of steps; track step; let i = $index, first = $first, last = $last) {
  <li [class.first]="first" [class.last]="last">{{ i + 1 }}. {{ step }}</li>
}

示例 87: 用 computed 过滤

显示随用户输入更新的过滤列表。

import { Component, computed, signal } from '@angular/core';

@Component({
  selector: 'app-fruit-filter',
  standalone: true,
  template: `
    <input (input)="query.set($any($event.target).value)">
    @for (fruit of shown(); track fruit) {
      <p>{{ fruit }}</p>
    }
  `,
})
export class FruitFilterComponent {
  fruits = ['apple', 'banana', 'cherry'];
  query = signal('');
  shown = computed(() =>
    this.fruits.filter((f) => f.includes(this.query().toLowerCase())),
  );
}

示例 88: OnPush 变更检测

只在输入变化时检查组件。

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

@Component({
  selector: 'app-price-tag',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: '<p>{{ price }} THB</p>',
})
export class PriceTagComponent {
  @Input() price = 0;
}

示例 89: 无 Zone 的变更检测

不使用 zone.js 运行 Angular,并用信号更新视图。

import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [provideZonelessChangeDetection()],
};

示例 90: toSignal

把 Observable 转换为可在模板中读取的信号。

import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { interval, map } from 'rxjs';

@Component({
  selector: 'app-seconds',
  standalone: true,
  template: '<p>Seconds: {{ seconds() }}</p>',
})
export class SecondsComponent {
  seconds = toSignal(interval(1000).pipe(map((n) => n + 1)), { initialValue: 0 });
}

知识点 14: 测试与动画

测试会单独检查组件和服务。动画会在元素状态变化时平滑地改变它。

TestBed 与组件测试

TestBed 在测试模块中创建组件,detectChanges 运行变更检测,测试检查 nativeElement 的文字。每个测试只检查一种行为,这样失败时便能快速找到原因。

HttpTestingController 与 fakeAsync

provideHttpClientTesting 用假的网络替换真实的网络,expectOne 和 flush 让测试获得响应。fakeAsync 配合 tick 推进时间,因此无需等待就能测试延迟执行的操作。

动画触发器

动画触发器定义状态(例如 open 与 closed)以及它们之间的过渡和时间。把触发器绑定到模板中的值,当值变化时 Angular 就会播放过渡效果。

示例 91: 用 TestBed 测试组件

在测试中创建组件并检查它的文字。

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';

describe('CounterComponent', () => {
  let fixture: ComponentFixture<CounterComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({ imports: [CounterComponent] }).compileComponents();
    fixture = TestBed.createComponent(CounterComponent);
  });

  it('starts at zero', () => {
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('0');
  });
});

示例 92: HttpTestingController

无需真实服务器即可测试 HTTP 服务。

import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { UserService } from './user.service';

describe('UserService', () => {
  it('loads users', () => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting()],
    });
    const service = TestBed.inject(UserService);
    const http = TestBed.inject(HttpTestingController);

    service.users().subscribe((list) => expect(list.length).toBe(2));
    http.expectOne('/api/users').flush([{ id: 1 }, { id: 2 }]);
    http.verify();
  });
});

示例 93: fakeAsync 与 tick

在测试中推进时间,检查延迟执行的操作。

import { fakeAsync, tick } from '@angular/core/testing';

it('runs a delayed action', fakeAsync(() => {
  let done = false;
  setTimeout(() => (done = true), 500);
  tick(500);
  expect(done).toBeTrue();
}));

示例 94: 动画触发器

在元素状态变化时为其添加动画。

import { Component } from '@angular/core';
import { trigger, state, style, transition, animate } from '@angular/animations';

@Component({
  selector: 'app-drawer',
  standalone: true,
  animations: [
    trigger('slide', [
      state('closed', style({ height: '0px' })),
      state('open', style({ height: '200px' })),
      transition('closed <=> open', animate('300ms ease-in')),
    ]),
  ],
  template: '<div [@slide]="isOpen ? \'open\' : \'closed\'">Content</div>',
})
export class DrawerComponent {
  isOpen = false;
}

知识点 15: 配置、错误与部署

真实的应用需要为每个环境提供配置、应对意外错误的方案,以及可以发布的生产构建。

环境文件与初始化器

把 API 地址等设置保存在环境文件中,开发与生产环境使用不同的值。provideAppInitializer 在第一个页面绘制之前运行代码,例如加载配置。

错误与 ErrorHandler

Angular 会把未捕获的错误交给 ErrorHandler。你可以提供自己的类,把错误记录下来或发送到报告服务,这样就能在一个地方决定如何处理意外错误。

构建、测试与部署

ng build --configuration production 会在 dist/ 中生成优化后的文件。在构建流水线中,ng test --watch=false 只运行一次单元测试。把静态文件托管在支持单页应用的 Web 服务器或静态托管服务上即可。

示例 95: 环境配置

把 API 地址等设置保存在一个文件中。

export const environment = {
  production: false,
  apiUrl: 'http://localhost:3000/api',
};

示例 96: 自定义 ErrorHandler

在一个地方报告意外的错误。

import { ErrorHandler, Injectable } from '@angular/core';

@Injectable()
export class ReportingErrorHandler implements ErrorHandler {
  handleError(error: unknown): void {
    console.error('Unexpected error:', error);
  }
}

示例 97: provideAppInitializer

在应用启动之前加载配置。

import { ApplicationConfig, provideAppInitializer, inject } from '@angular/core';
import { ConfigService } from './config.service';

export const appConfig: ApplicationConfig = {
  providers: [
    provideAppInitializer(() => inject(ConfigService).load()),
  ],
};

示例 98: 独立组件的 imports

列出组件使用的指令和管道。

import { Component } from '@angular/core';
import { NgClass } from '@angular/common';
import { HighlightDirective } from './highlight.directive';
import { ShortenPipe } from './shorten.pipe';

@Component({
  selector: 'app-note',
  standalone: true,
  imports: [NgClass, HighlightDirective, ShortenPipe],
  template: '<p [ngClass]="{ bold: true }" appHighlight="pink">{{ text | shorten: 10 }}</p>',
})
export class NoteComponent {
  text = 'A long note about Angular standalone components';
}

示例 99: 交互时的 @defer

只有用户点击之后才加载页面的一部分。

<button #openBtn>Show comments</button>

@defer (on interaction(openBtn)) {
  <app-comments />
} @placeholder {
  <p>Comments will load after you click.</p>
}

示例 100: 构建与测试命令

日常工作中最常用的 Angular CLI 命令。

# development server
ng serve
# production build (outputs to dist/)
ng build --configuration production
# unit tests
ng test --watch=false

练习项目:任务列表

制作一个小应用,使用每一节的思路。先自己编写,然后与示例进行比较。

1. 创建一个独立组件项目,再创建一个 TaskList 组件,用信号保存任务数组。

2. 添加一个带必填文本框的表单,并用绑定到 (ngSubmit) 的方法把新任务加入列表。

3. 用 @for 和 track 显示任务,并在列表为空时用 @empty 显示提示。

4. 添加一个 computed 信号统计未完成的任务数量,并在模板中显示。

5. 添加一个任务详情页面的路由,用 ActivatedRoute 从 URL 读取 id。

6. 编写一个组件测试,检查列表为空时的提示,然后运行 ng test --watch=false。

下一步

接下来,请深入学习路由器,学习用信号或 NgRx 进行状态管理,学习使用 Angular SSR 进行服务端渲染,以及学习 Angular Material 这类组件库。官方文档 angular.dev 是最好的参考资料。本文的示例遵循 Angular 17 及以后版本推荐的语法。

总结

Angular 用组件构建应用。模板绑定数据和事件,控制流代码块显示或重复内容,管道格式化值,输入和输出连接父子组件。组件把状态保存在普通属性、只读值、getter 和信号中。服务通过依赖注入共享,路由把 URL 映射到页面,表单验证输入,HttpClient 与 RxJS 加载数据。测试、配置和生产构建让整个流程完整。记住这 100 个示例最好的方法,是制作任务列表项目,并且每次只改动一处。