入门
CLI 安装与项目创建
Angular CLI 是开发的主要工具。ng new 脚手架会生成一个预配置路由和样式的完整项目。使用 ng generate(ng g)保持文件创建的一致性。CLI 负责构建、测试和部署。
# install Angular CLI globally
npm install -g @angular/cli
# create a new project
ng new my-app --routing --style=scss
cd my-app
# serve the app (default: localhost:4200)
ng serve --open
# generate components, services, etc.
ng generate component components/user-profile
ng g service services/auth
ng g module modules/admin --routing项目结构概览
Angular 强制使用清晰的项目结构。src/app 存放所有功能代码。main.ts 引导根组件。angular.json 配置构建、测试和启动选项。environments 文件夹让你按环境切换 API URL。
my-app/
├── src/
│ ├── app/ # application code lives here
│ │ ├── app.component.ts
│ │ ├── app.component.html
│ │ └── app.config.ts # standalone bootstrap config
│ ├── assets/ # static images, fonts
│ ├── environments/ # env-specific values
│ ├── index.html # main HTML entry
│ ├── main.ts # bootstrap entry point
│ └── styles.scss # global styles
├── angular.json # CLI workspace config
├── package.json
└── tsconfig.json启动与构建
ng serve 启动开发服务器,默认在 4200 端口提供 HMR 式热重载。生产构建会进行摇树优化、压缩并为文件名添加哈希以利于缓存。使用 --stats-json 配合 webpack-bundle-analyzer 查找臃肿的依赖。ng test 默认运行 Karma。
# dev server with live reload
ng serve --port 4300 --open
# production build (outputs to dist/)
ng build --configuration production
# analyze bundle size
ng build --stats-json
npx webpack-bundle-analyzer dist/my-app/stats.json
# run unit tests (Karma + Jasmine)
ng test
# run end-to-end tests
ng e2e代码生成(Schematics)
Schematics 确保文件命名和样板代码的一致性。--standalone 标志生成独立组件(v17 起推荐)。--route + --module 自动接入懒加载路由。用 --functional 创建的守卫返回函数而非类。
# generate a standalone component
ng g component user-profile --standalone
# generate a service with root provider
ng g service services/auth
# generate a guard (functional by default)
ng g guard guards/auth --functional
# generate a pipe, directive, or module
ng g pipe pipes/shorten
ng g directive directives/highlight
ng g module admin --routing --route admin --module app
# generate an interface or enum
ng g interface models/user
ng g enum models/role配置(angular.json)
angular.json 是工作区配置。每个项目有 build、serve、test 和 lint 目标。budgets 在包体积超阈值时警告或报错。polyfills 数组包含 Angular 所需的 zone.js。一个工作区可容纳多个项目(应用 + 库)。
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"outputPath": "dist/my-app",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"scripts": []
},
"configurations": {
"production": {
"budgets": [{ "type": "initial", "maximumWarning": "500kb" }]
}
}
}
}
}
}
}环境变量
Angular 在构建时根据配置(开发 vs 生产)替换环境文件。文件替换在 angular.json 的 fileReplacements 中配置。在任何地方注入 environment 对象即可访问环境特定值,无需硬编码 URL。切勿将真实密钥提交到版本控制。
// src/environments/environment.ts
export const environment = {
production: false,
apiUrl: 'http://localhost:3000/api',
apiKey: 'dev-key'
};
// src/environments/environment.prod.ts
export const environment = {
production: true,
apiUrl: 'https://api.example.com',
apiKey: 'prod-key'
};
// usage in a service
import { environment } from '../environments/environment';
@Injectable({ providedIn: 'root' })
export class ApiService {
private baseUrl = environment.apiUrl;
}组件基础
基本组件
组件是用 @Component 装饰的 TypeScript 类。selector 是模板中使用的自定义 HTML 标签。templateUrl/styleUrls 指向外部文件;用 template/styles 使用内联形式。@Input 标记父组件可绑定的属性。保持组件职责单一。
// user-profile.component.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-user-profile',
templateUrl: './user-profile.component.html',
styleUrls: ['./user-profile.component.scss'],
})
export class UserProfileComponent {
@Input() name = 'Guest';
@Input() age = 0;
greet() {
return `Hello, ${this.name}!`;
}
}内联模板组件
内联模板便于小组件使用,避免额外文件。用反引号书写多行字符串——内部的反引号需用反斜杠转义。styles 接受 CSS 字符串数组。:host 选择器定位组件自身元素。模板超过约 10 行时优先使用外部文件。
@Component({
selector: 'app-counter',
template: `
<button (click)="decrement()">-</button>
<span>{{ count }}</span>
<button (click)="increment()">+</button>
`,
styles: [`
:host { display: flex; gap: 8px; align-items: center; }
button { padding: 4px 12px; }
`],
})
export class CounterComponent {
count = 0;
increment() { this.count++; }
decrement() { this.count--; }
}独立组件(v14+)
独立组件不需要 NgModule——它们在 imports 数组中声明自己的依赖(CommonModule、FormsModule、其他组件)。这是现代推荐方式。只导入用到的内容以获得更好的摇树优化。可直接在 main.ts 中引导独立组件。
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-search',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<input [(ngModel)]="query" placeholder="Search..." />
<p>Searching for: {{ query }}</p>
`,
})
export class SearchComponent {
query = '';
}输入与输出
@Input 让父组件通过属性绑定传入数据。@Output 暴露一个 EventEmitter,父组件通过事件绑定订阅。始终为输入设置默认值以避免 undefined 错误。v17+ 可使用新的 input()/output() 信号 API 获得更简洁的代码。
@Component({
selector: 'app-toggle',
template: `
<button (click)="toggle()">{{ label }}: {{ on ? 'On' : 'Off' }}</button>
`,
})
export class ToggleComponent {
@Input() label = 'Toggle';
@Input() on = false;
@Output() changed = new EventEmitter<boolean>();
toggle() {
this.on = !this.on;
this.changed.emit(this.on);
}
}
<!-- parent template -->
<app-toggle [label]="'Power'" [on]="isOn" (changed)="onToggle($event)" />基于信号的输入(v17.1+)
input() 函数创建基于信号的、只读的输入,像函数一样调用。它通过 input.required<T>() 支持必填输入,支持转换器,并与 computed() 和 effect() 无缝协作。output() 是 @Output EventEmitter 的现代替代品。这是 Angular 组件 API 的未来方向。
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-card',
template: `
<div>{{ title() }} - {{ count() }}</div>
<button (click)="clicked.emit()">Click</button>
`,
})
export class CardComponent {
// readonly signal inputs with defaults
title = input<string>('Default');
count = input<number>(0);
// output() function replaces @Output
clicked = output<void>();
// computed derived from inputs
// requires import { computed }
// label = computed(() => this.title().toUpperCase());
}视图封装
Emulated(默认)通过添加唯一属性将样式作用域限定到组件,无需真正的 Shadow DOM。None 使样式全局生效(慎用)。ShadowDom 使用原生 Shadow DOM 实现真正隔离,但会影响样式穿透和某些第三方库。除非有特殊需求,否则坚持使用 Emulated。
@Component({
selector: 'app-styles',
template: `<p class="local">Emulated (default)</p>`,
styles: [`p.local { color: red; }`],
encapsulation: ViewEncapsulation.Emulated, // default
})
export class StylesComponent {}
// ViewEncapsulation.None -> styles leak globally
// ViewEncapsulation.ShadowDom -> real Shadow DOM (strong isolation)
// ViewEncapsulation.Emulated -> scoped attributes (default, recommended)模板语法
文本插值
双花括号 {{ }} 将表达式渲染为文本(HTML 转义)。可使用大多数 JS 表达式:算术、三元、方法调用、属性访问。不支持 new、typeof 或赋值运算符。管道(|)转换输出。插值在每次变更检测周期重新求值。
<p>{{ title }}</p> <!-- renders the value -->
<p>{{ 1 + 2 }}</p> <!-- expressions allowed -->
<p>{{ user.name }}</p> <!-- property access -->
<p>{{ items.length }}</p> <!-- method/property -->
<p>{{ price | currency:'USD' }}</p> <!-- pipe in interpolation -->
<p>{{ isReady ? 'Yes' : 'No' }}</p> <!-- ternary -->
<!-- one-time interpolation updates when the source changes -->
<!-- new control flow @if, @for work without *ngIf/*ngFor directives -->属性与特性绑定
[prop] 绑定到 DOM 属性(首选)。[attr.name] 绑定到 HTML 特性,用于没有 DOM 属性的场景(colspan、SVG 属性)。[class.x] 按真值切换单个类;[style.prop] 设置样式,支持 .px 等单位后缀。避免用 innerHTML 绑定不可信内容。
<!-- property binding: [prop]="expression" -->
<img [src]="user.avatar" [alt]="user.name" />
<button [disabled]="isSaving">Save</button>
<my-comp [data]="items"></my-comp>
<!-- attribute binding (for attrs without DOM props) -->
<td [attr.colspan]="2">Merged cell</td>
<svg [attr.viewBox]="box"></svg>
<!-- class & style binding -->
<div [class.active]="isActive">Active?</div>
<div [class]="dynamicClassString">
<div [style.color]="colorVar">
<div [style.width.px]="size">Width</div>事件绑定
(event) 绑定 DOM 事件和自定义 EventEmitter 输出。$event 是事件载荷(原生事件为 DOM Event,自定义事件为发出的值)。事件修饰符如 .enter、.esc、.prevent、.stop 用于过滤或转换事件。括号中的目标决定什么触发处理程序。
<button (click)="save()">Save</button>
<input (input)="onInput($event)" (keyup)="onKey($event)" />
<div (mouseenter)="hover=true" (mouseleave)="hover=false">Hover</div>
<!-- $event is the DOM event object -->
<input (keyup.enter)="submit()" (keyup.esc)="cancel()" />
<!-- passive events (scroll, touchmove) -->
<div (scroll.passive)="onScroll($event)">...</div>
<!-- binding to custom component outputs -->
<app-child (changed)="onChanged($event)"></app-child>模板引用变量
#var(或 ref-var)引用其声明所在的 DOM 元素或组件实例。在模板中任意位置可访问。对于组件,值是组件实例;对于元素,是 HTMLElement。在 @for 内部,$index、$first、$last、$even、$odd 是隐式上下文变量。
<input #nameInput type="text" />
<button (click)="nameInput.focus()">Focus</button>
<p>You typed: {{ nameInput.value }}</p>
<!-- ref to a component instance -->
<app-form #f="appForm"></app-form>
<button [disabled]="!f.valid" (click)="f.submit()">Submit</button>
<!-- ref inside @for -->
<ul>
@for (item of items; track item.id) {
<li>{{ item.name }} (index: {{ $index }})</li>
}
</ul>双向绑定(盒子里的香蕉)
[(ngModel)] 需要 FormsModule。[(x)] 语法解构为 [x] + (xChange),因此自定义双向绑定需要一个名为 x 的输入和一个名为 xChange 的输出。记住口诀'盒子里的香蕉'——圆括号在方括号里面。v17.2+ 的 model() 信号函数简化了这一过程。
<!-- [()] = "banana in a box" - property + event combined -->
<input [(ngModel)]="username" />
<!-- equivalent to -->
<input [ngModel]="username" (ngModelChange)="username = $event" />
<!-- two-way binding on custom components -->
<app-counter [(value)]="count"></app-counter>
<!-- requires: @Input() value; @Output() valueChange = new EventEmitter() -->
<!-- signals: model() for two-way (v17.2+) -->
// value = model<number>(0);安全导航与非空断言
?. 操作符安全导航可空链,返回 undefined 而非抛出错误。用于尚未加载的异步数据。! 操作符仅是编译时断言——不添加运行时检查,仅在你确定时使用。对于可能为 null 的数组,使用 ?? [] 作为兜底。
<!-- safe navigation: short-circuits to undefined if null -->
<p>{{ user?.profile?.bio }}</p>
<p>{{ user?.getName?.() }}</p>
<!-- non-null assertion: tells compiler it's not null -->
<p>{{ user!.name }}</p>
<!-- in property bindings -->
<img [src]="user?.avatar" />
<app-detail [data]="user!"></app-detail>
<!-- safe navigation in @for doesn't replace track -->
@for (item of items ?? []; track item.id) { ... }指令
@if / @else(新控制流)
新的 @if/@else if/@else 块语法取代 *ngIf。无需导入 CommonModule 或 NgIf,无星号,且 @else 原生支持(不像 ngIf 需要 ng-template 的 hack)。这是新代码的推荐语法。它是 v17 引入、v18 稳定的内置控制流的一部分。
@if (user) {
<p>Hello, {{ user.name }}</p>
} @else if (loading) {
<p>Loading...</p>
} @else {
<p>No user found.</p>
}
<!-- no structural directive import needed, no * prefix -->
<!-- works in standalone templates automatically (v17+) -->@for / @empty(新控制流)
@for 以强制的 track 表达式取代 *ngFor,实现高效 DOM diff。@empty 在集合为空时渲染兜底内容。隐式上下文变量($index、$first、$last 等)始终可用。用稳定的唯一 id 进行追踪对大列表性能至关重要。
<ul>
@for (item of items; track item.id) {
<li>{{ item.name }} ({{ $index }})</li>
} @empty {
<li>No items yet.</li>
}
</ul>
<!-- 'track' is REQUIRED for performance -->
<!-- implicit vars: $index, $first, $last, $even, $odd, $count -->
<!-- @for over a range -->
@for (i of [1,2,3,4,5]; track i) {
<span>{{ i }}</span>
}ngClass 与 ngStyle
ngClass 接受对象(真值键应用) 、数组或字符串。ngStyle 接受驼峰或连字符键的对象,支持单位后缀。需要导入 CommonModule 或具体指令。对于单个类/样式,[class.x] 和 [style.x] 更轻量且无需导入。
<div [ngClass]="{ active: isActive, disabled: !enabled }">Box</div>
<div [ngClass]="['card', 'shadow', themeClass]">Card</div>
<div [ngClass]="currentClass">String</div>
<div [ngStyle]="{ color: textColor, 'font-size.px': size }">Text</div>
<div [ngStyle]="styleObject">Styled</div>
<!-- import CommonModule (or NgClass/NgStyle) for these -->
<!-- prefer [class.x] and [style.x] for single value bindings -->@switch(新控制流)
@switch 取代 *ngSwitch(及 ngSwitchCase/ngSwitchDefault 指令)。更简洁——无需包裹容器,无需导入。每个 @case 仅在其值匹配 @switch 表达式时渲染。@default 是兜底。这是 v17+ 提供的内置控制流。
@switch (status) {
@case ('loading') {
<spinner>Loading...</spinner>
}
@case ('error') {
<p class="error">Something went wrong</p>
}
@case ('success') {
<p>Done!</p>
}
@default {
<p>Idle</p>
}
}自定义属性指令
属性指令改变 DOM 元素的外观或行为。selector 中的[方括号]匹配属性用法。ElementRef 提供直接 DOM 访问;HostListener 绑定 DOM 事件。注入 ElementRef<HTMLElement> 以获得类型安全。在服务端渲染场景中优先使用 Renderer2 操作 DOM。
import { Directive, ElementRef, Input, HostListener } from '@angular/core';
@Directive({
selector: '[appHighlight]',
})
export class HighlightDirective {
@Input('appHighlight') highlightColor = 'yellow';
constructor(private el: ElementRef<HTMLElement>) {}
@HostListener('mouseenter') onMouseEnter() {
this.el.nativeElement.style.backgroundColor = this.highlightColor;
}
@HostListener('mouseleave') onMouseLeave() {
this.el.nativeElement.style.backgroundColor = '';
}
}
<!-- usage: <p appHighlight="lightblue">Hover me</p> -->结构指令内部原理
结构指令通过 TemplateRef(被包裹的模板)和 ViewContainerRef(插入位置)添加/移除 DOM 节点。星号(*)是语法糖,将元素包裹在 <ng-template> 中。新的 @if/@for/@switch 块取代了大多数自定义结构指令,但此模式在高级场景中仍有用。
import { Directive, TemplateRef, ViewContainerRef, Input } from '@angular/core';
@Directive({ selector: '[appUnless]' })
export class UnlessDirective {
private hasView = false;
@Input() set appUnless(condition: boolean) {
if (!condition && !this.hasView) {
this.vc.createEmbeddedView(this.tpl);
this.hasView = true;
} else if (condition && this.hasView) {
this.vc.clear();
this.hasView = false;
}
}
constructor(private tpl: TemplateRef<unknown>, private vc: ViewContainerRef) {}
}
<!-- usage: <div *appUnless="hide">Shown when hide is false</div> -->管道
内置管道
管道转换模板显示值。常用内置管道:date、number、currency、percent、uppercase、lowercase、titlecase、slice、json、async。管道默认是纯的——仅当输入引用变化时才重新计算。格式字符串遵循 Angular 的区域感知规则。
<!-- date formatting -->
<p>{{ today | date }}</p> <!-- Sep 10, 2024 -->
<p>{{ today | date:'short' }}</p>
<p>{{ today | date:'yyyy-MM-dd HH:mm' }}</p>
<!-- number & currency -->
<p>{{ 3.14159 | number:'1.2-2' }}</p> <!-- 3.14 -->
<p>{{ price | currency:'USD':'symbol':'1.2-2' }}</p>
<p>{{ ratio | percent }}</p>
<!-- text transforms -->
<p>{{ 'hello world' | uppercase }}</p> <!-- HELLO WORLD -->
<p>{{ 'HELLO' | lowercase }}</p>
<p>{{ title | titlecase }}</p>
<p>{{ 'a/b/c' | slice:0:2 }}</p>
<!-- json (for debugging) -->
<pre>{{ obj | json }}</pre>管道链与参数
管道从左到右链式调用,每个管道接收前一个的输出。参数以冒号分隔且按位置传递。用于纯转换(格式化、过滤)以保持模板声明式。避免在性能敏感数据上使用非纯管道;应在组件中计算。
<!-- chain: left to right, output of one feeds the next -->
<p>{{ ' hello ' | trim | uppercase }}</p> <!-- HELLO -->
<p>{{ birthday | date:'fullDate' | uppercase }}</p>
<p>{{ items | slice:0:3 | json }}</p>
<!-- parameters separated by colons -->
<p>{{ amount | number:'1.2-2':'en-US' }}</p>
<p>{{ meeting | date:'shortTime' }}</p>
<p>{{ list | slice:1:5 }}</p>
<!-- multiple parameters -->
<p>{{ value | myPipe:arg1:arg2:arg3 }}</p>自定义管道
自定义管道实现 PipeTransform.transform()。@Pipe 的 name 在模板中使用。在组件的 imports 数组中注册独立管道(v15+),或在 NgModule 中声明。纯管道(默认)仅在输入引用变化时重新运行——传递新引用以触发更新。
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'truncate' })
export class TruncatePipe implements PipeTransform {
transform(value: string, limit = 20, ellipsis = '...'): string {
if (!value) return '';
return value.length > limit
? value.slice(0, limit) + ellipsis
: value;
}
}
// register in a standalone component imports:
// imports: [TruncatePipe]
<!-- usage -->
<p>{{ longText | truncate:50 }}</p>
<p>{{ longText | truncate:10:'>>' }}</p>Async 管道
async 管道订阅 Observable 或 Promise,渲染值,并在销毁时自动取消订阅——无需手动订阅/取消。结合 *ngIf as 或 @for,可优雅处理加载时的 null。当新值到达时它会标记组件进行检查,与 OnPush 集成。
@Component({
selector: 'app-user',
template: `
<div *ngIf="user$ | async as user">
{{ user.name }}
</div>
<ul>
@for (item of items$ | async; track item.id) {
<li>{{ item.name }}</li>
}
</ul>
`,
imports: [AsyncPipe, NgIf],
})
export class UserComponent {
user$ = this.http.get<User>('/api/user');
items$ = this.http.get<Item[]>('/api/items');
constructor(private http: HttpClient) {}
}纯管道 vs 非纯管道
纯管道缓存结果直到输入引用变化;变更数组或对象的属性不会触发它们。非纯管道(pure: false)每次变更检测都重新运行——便于过滤变更的数组但代价高昂。优先保持数据不可变(替换数组而非变更)并使用纯管道以获得性能。
// PURE (default): re-runs only when input reference changes
@Pipe({ name: 'filter' })
export class FilterPipe implements PipeTransform {
transform(items: Item[], term: string): Item[] {
return items.filter(i => i.name.includes(term));
}
}
// Issue: mutating items.push() won't trigger re-evaluation.
// IMPURE: re-runs on every change detection cycle
@Pipe({ name: 'filter', pure: false })
export class ImpureFilterPipe implements PipeTransform { ... }
// ⚠️ Expensive! Use sparingly — affects performance.i18n 与区域管道
date、number、currency 等管道遵循 LOCALE_ID。通过 registerLocaleData 在引导前注册区域数据。货币/数字格式遵循当前区域。对于多语言应用,按用户提供 LOCALE_ID 或使用 i18n 工具进行模板翻译。缺失的区域数据回退到 en-US。
// register locale data in app.config.ts
import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
registerLocaleData(localeFr);
// provide a default locale
providers: [{ provide: LOCALE_ID, useValue: 'fr-FR' }]
// usage - now locale-aware
<p>{{ price | currency:'EUR' }}</p> <!-- 1 234,56 € -->
<p>{{ today | date:'fullDate' }}</p> <!-- lundi 10 septembre 2024 -->
<p>{{ number | number:'1.2-2' }}</p> <!-- 1 234,56 -->
<p>{{ amount | currency:'JPY' }}</p> <!-- ¥1,235 -->服务与依赖注入
基本服务
providedIn: 'root' 创建一个全应用可用的单例,无需模块注册。该服务可摇树优化——未使用则被移除。使用 inject() 函数(v14+)代替构造函数注入以获得更简洁的代码和更易测试。服务持有共享状态和业务逻辑;组件保持展示性。
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class UserService {
private users = ['Alice', 'Bob'];
getUsers() { return this.users; }
addUser(name: string) { this.users.push(name); }
}
// usage in a component
export class UserListComponent {
// Angular injects the singleton instance
constructor(private users: UserService) {}
get users() { return this.usersService.getUsers(); }
private usersService = inject(UserService);
}inject() 函数
inject() 是构造函数注入的现代替代品。它可在字段初始化器中使用,使代码更可读并启用更好的 TypeScript 推断。它必须在构造期间运行(不能在之后的异步回调中)。一致地使用它——混用构造函数参数和 inject() 也可以,但 inject() 更简洁。
import { Injectable, inject } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
private router = inject(Router);
login(creds: Credentials) {
return this.http.post('/api/login', creds);
}
}
// in a component
export class DashboardComponent {
private auth = inject(AuthService);
user = this.auth.currentUser; // works in field initializers
}Provider 配置
Provider 可在任何层级配置(root、模块、组件)。useClass 切换实现(非常适合模拟)。useValue 提供常量。useExisting 将一个服务别名到另一个。useFactory 动态 创建实例。InjectionToken 用于非类值,如字符串或配置对象。
@Component({
// ... provide at component level for instance-per-component
providers: [
UserService, // shorthand
{ provide: Logger, useClass: ConsoleLogger },
{ provide: API_URL, useValue: 'https://api.example.com' },
{ provide: Storage, useExisting: LocalStorageService },
{
provide: Config,
useFactory: () => new Config(env.production)
},
],
})
export class AdminComponent {}
// InjectionToken for non-class values
export const API_URL = new InjectionToken<string>('API_URL');分层注入器
Angular 的 DI 树镜像组件树。providedIn: 'root' = 全应用一个实例。组件级 provider 创建子注入器——每个实例各有自己的。这对编辑器等有状态组件很有用。'any' 作用域到懒加载模块。'platform' 较罕见,用于多应用场景。
// root injector (singleton app-wide)
@Injectable({ providedIn: 'root' })
export class AuthService {}
// component-level provider: new instance for this component and children
@Component({
providers: [UserService], // each AdminComponent gets its own UserService
})
export class AdminComponent {}
// 'any' creates a lazy singleton per lazy module
@Injectable({ providedIn: 'any' })
export class SharedService {}
// 'platform' shared across all Angular apps on the page
@Injectable({ providedIn: 'platform' })
export class GlobalConfig {}多 Provider 与 Token
multi: true 在一个 token 下注册多个 provider,作为数组注入。适用于插件系统、拦截器、验证器或任何可扩展集合。注入顺序遵循注册顺序。结合 InjectionToken 正确地为数组指定类型。
// define a token for a list of plugins
export const PLUGIN = new InjectionToken<Plugin>('PLUGIN');
// provide multiple plugins
@Component({
providers: [
{ provide: PLUGIN, useClass: AuthPlugin, multi: true },
{ provide: PLUGIN, useClass: AnalyticsPlugin, multi: true },
],
})
export class AppComponent {}
// inject all of them as an array
export class PluginManager {
plugins = inject(PLUGIN); // Plugin[]
}可选与默认 Provider
@Optional() 或 inject(token, { optional: true }) 在未找到 provider 时返回 null 而非抛出错误。用于非关键依赖。其他修饰符:@SkipSelf()(不检查当前注入器)、@Self()(仅当前)、@Host()(上溯到宿主组件)。inject() 的等价物在选项对象中接受标志。
import { Injectable, inject, Optional, Inject } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class Logger {
constructor(@Optional() @Inject(CONFIG) private cfg?: Config) {
// cfg is null if no provider exists — handle gracefully
}
}
// or with inject()
export class Service {
cfg = inject(CONFIG, { optional: true }); // Config | null
baseUrl = inject(API_URL, { optional: true }) ?? '/api';
}
// skipSelf: look in parent injectors only
// self: only this component's injector
// host: limit to host component路由
路由配置
路由将 URL 路径映射到组件。loadComponent/loadChildren 启用懒加载(更小的初始包)。:id 是路由参数。通配符 '**' 捕获未匹配的 URL——放在最后。provideRouter 在独立应用中设置路由器。设置 title 用于浏览器标签名;可以是字符串或 ResolveFn。
// app.routes.ts (standalone)
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', component: HomeComponent, title: 'Home' },
{ path: 'about', loadComponent: () => import('./about/about.component')
.then(m => m.AboutComponent) },
{ path: 'users/:id', component: UserDetailComponent },
{ path: 'admin', loadChildren: () => import('./admin/admin.routes')
.then(m => m.ADMIN_ROUTES) },
{ path: '**', component: NotFoundComponent }, // wildcard
];
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes)]
};路由出口与链接
router-outlet 标记路由组件渲染的位置。routerLink 创建导航链接(参数用数组形式)。routerLinkActive 在路由匹配时应用类。编程式导航使用 router.navigate() 或 routerLink。命名出口支持辅助路由(如主视图旁的弹窗)。
<!-- app.component.html -->
<nav>
<a routerLink="/" routerLinkActive="active">Home</a>
<a routerLink="/about" routerLinkActive="active">About</a>
<a [routerLink]="['/users', userId]">My Profile</a>
</nav>
<router-outlet></router-outlet>
<!-- named outlet (secondary route) -->
<router-outlet name="popup"></router-outlet>
<!-- navigate programmatically -->
<button (click)="go()">Go</button>
// constructor(private router: Router) {}
// go() { this.router.navigate(['/users', 42]); }路由参数与查询参数
当组件在每次导航中销毁和重建时使用 snapshot 一次性读取。当同一组件实例处理不同参数(如从 /users/1 导航到 /users/2)时使用可观察的 param/queryParamMap。始终取消订阅或使用 async 管道以避免泄漏。
// reading route params
export class UserDetailComponent {
constructor(private route: ActivatedRoute) {
// snapshot: one-time read (good for static links)
const id = this.route.snapshot.paramMap.get('id');
// observable: reacts to param changes (same component reused)
this.route.paramMap.subscribe(params => {
console.log(params.get('id'));
});
}
}
// query params: /search?q=angular&page=2
this.route.queryParamMap.subscribe(qp => {
this.q = qp.get('q');
});
<!-- link with query params -->
<a [routerLink]="['/search']" [queryParams]="{ q: 'angular', page: 2 }">子路由与嵌套路由
子路由在父组件内的嵌套 router-outlet 中渲染。带 pathMatch: 'full' 的 redirectTo 重定向空子路径。此模式构建主从布局(父外壳,子内容)。每层嵌套在父模板中需要自己的 router-outlet。
export const routes: Routes = [
{
path: 'admin',
component: AdminLayoutComponent,
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'users', component: AdminUsersComponent },
{ path: 'settings', component: SettingsComponent },
],
},
];
<!-- AdminLayoutComponent template -->
<h2>Admin Panel</h2>
<nav>...</nav>
<router-outlet></router-outlet> <!-- child routes render here -->懒加载
懒加载将应用拆分为按需加载的块,缩小初始包。loadChildren 加载路由文件(Routes 的默认导出);loadComponent 加载单个独立组件。动态 import() 字符串必须是静态路径以便打包器拆分。用于导航后的功能区域。
// main routes - load feature modules on demand
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
},
{
path: 'reports',
loadComponent: () => import('./reports/reports.component')
.then(m => m.ReportsComponent),
},
];
// admin.routes.ts
export const ADMIN_ROUTES: Routes = [
{ path: '', component: AdminComponent },
{ path: 'users', component: AdminUsersComponent },
];路由事件与导航
路由事件(NavigationStart、NavigationEnd、GuardsCheckStart 等)让你跟踪导航生命周期。始终过滤到特定事件并取消订阅。router.navigate 接受 extras:queryParams、fragment、state(不在 URL 中的瞬态数据)、replaceUrl。state 可通过 router.getCurrentNavigation().extras.state 读取。
// subscribe to router events
export class AppComponent implements OnDestroy {
private sub = this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd)
).subscribe(e => {
console.log('Navigated to:', e.url);
this.analytics.track(e.url);
});
constructor(private router: Router) {}
ngOnDestroy() { this.sub.unsubscribe(); }
}
// navigation with extras
this.router.navigate(['/users'], {
queryParams: { sort: 'name' },
fragment: 'top',
state: { from: 'login' }, // passed to next route, not in URL
});表单
模板驱动表单
模板驱动表单使用 ngModel 和模板引用。需要 FormsModule。表单状态(有效性、touched、dirty)通过 ngForm 和 ngModel 指令跟踪。适合简单表单。验证在模板中声明(required、minlength、pattern)。通过 #e='ngModel' 等模板引用访问控件状态。
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule],
template: `
<form #f="ngForm" (ngSubmit)="save(f.value)">
<input name="email" [(ngModel)]="email" required email #e="ngModel" />
<span *ngIf="e.invalid && e.touched">Email required</span>
<input name="pwd" type="password" [(ngModel)]="pwd" required minlength="6" />
<button [disabled]="f.invalid">Submit</button>
</form>
`,
})
export class LoginComponent {
email = '';
pwd = '';
save(v: any) { console.log(v); }
}响应式表单设置
响应式表单在组件类中定义为 FormControl/FormGroup 实例。需要 ReactiveFormsModule。formControlName 将输入绑定到控件。表单模型是可观察的,可脱离 DOM 测试。nonNullable: true 防止清空输入时出现 null 值。复杂表单首选。
import { ReactiveFormsModule, FormControl, FormGroup } from '@angular/forms';
@Component({
selector: 'app-profile',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="save()">
<input formControlName="name" />
<input formControlName="email" />
<button [disabled]="form.invalid">Save</button>
</form>
<p>Valid: {{ form.valid }}</p>
`,
})
export class ProfileComponent {
form = new FormGroup({
name: new FormControl('', { nonNullable: true }),
email: new FormControl('', { nonNullable: true }),
});
save() { console.log(this.form.value); }
}FormBuilder 与类型化表单
FormBuilder 减少样板代码。fb.group() 从默认值推断类型。嵌套 group 建模嵌套数据。FormArray 管理动态控件列表。使用 nonNullable,控件保持其声明类型而非 null。用 get() 或 controls 进行类型安全访问。类型化表单(v14+)提供值自动补全和类型检查。
import { FormBuilder, FormGroup } from '@angular/forms';
export class CheckoutComponent {
private fb = inject(FormBuilder);
// typed form (v14+ nonNullable)
form = this.fb.group({
name: ['', { nonNullable: true }],
address: this.fb.group({
street: [''],
city: [''],
zip: [''],
}),
items: this.fb.array<CartItem>([]),
});
// type-safe access
get items() { return this.form.controls.items; }
addItem(item: CartItem) { this.items.push(this.fb.control(item)); }
}表单验证
Validators.required、.minLength、.maxLength、.pattern、.email、.min、.max 是内置的。通过数组应用多个。跨字段验证放在 FormGroup 上,验证器函数接收整个组。显示错误前检查 control.touched/dirty 以避免首次渲染就责备用户。用 .hasError('key') 测试特定错误。
import { Validators } from '@angular/forms';
// built-in validators
const email = new FormControl('', [
Validators.required,
Validators.email,
Validators.maxLength(100),
]);
const password = new FormControl('', [
Validators.required,
Validators.minLength(8),
Validators.pattern(/[A-Z]/),
]);
// cross-field validation at group level
const form = this.fb.group({
pwd: ['', Validators.required],
confirm: ['', Validators.required],
}, { validators: matchPassword }); // custom group validator
// reading state in template
<!-- <span *ngIf="email.touched && email.hasError('required')">Required</span> -->自定义验证器
自定义验证器是返回 ValidationErrors(一个映射)或 null(有效)的函数。异步验证器返回 Observable 或 Promise——用于服务端检查如用户名可用性。始终对异步验证器去抖动(通过 timer/switchMap)以避免每次按键都请求服务器。
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// function validator
export const matchPassword: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
const pwd = group.get('pwd')?.value;
const confirm = group.get('confirm')?.value;
return pwd === confirm ? null : { mismatch: true };
};
// async validator (returns Observable/Promise)
export function uniqueUsername(http: HttpClient): AsyncValidatorFn {
return (control: AbstractControl) => {
return timer(500).pipe(
switchMap(() => http.get(`/api/check/${control.value}`)),
map(exists => exists ? { taken: true } : null),
);
};
}表单值变化与状态
valueChanges 和 statusChanges 是在每次输入时发出的 Observable。debounceTime 防止快速输入时过度工作。takeUntil 配合 Subject 是销毁时取消订阅的惯用方式。patchValue 更新部分表单值;setValue 需要所有字段。使用 { emitEvent: false } 静默更新而不触发 valueChanges。
export class FilterComponent implements OnDestroy {
private destroy = new Subject<void>();
form = this.fb.group({ q: [''], category: ['all'] });
ngOnInit() {
// react to entire form changes (debounced)
this.form.valueChanges.pipe(
debounceTime(300),
takeUntil(this.destroy),
).subscribe(v => this.search(v));
// react to a single control
this.form.controls.q.valueChanges.subscribe(q => {
console.log('q changed:', q);
});
}
ngOnDestroy() { this.destroy.next(); }
}HttpClient
设置与基本 GET
provideHttpClient() 全应用注册客户端。HttpClient 方法返回冷的 Observable——仅在订阅时执行(在模板中用 async 管道或在服务中 subscribe)。响应通过泛型类型化。始终集中或逐调用处理错误。withFetch()(v18)启用基于 fetch 的现代后端。
// app.config.ts
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient()],
};
// usage in a service
@Injectable({ providedIn: 'root' })
export class PostService {
private http = inject(HttpClient);
private api = 'https://jsonplaceholder.typicode.com/posts';
getPosts() {
return this.http.get<Post[]>(this.api);
}
}
// in a component (use async pipe to auto-unsubscribe)
export class PostListComponent {
posts$ = this.posts.getPosts();
constructor(private posts: PostService) {}
}POST / PUT / DELETE
post/put/patch 返回发出一次响应后完成的 Observable。第二个参数是请求体。泛型类型收窄响应。URL 可以是带参数的模板字符串。始终订阅——Observable 是冷的,否则不会触发。考虑 toPromise()(已弃用)或 firstValueFrom() 用于一次性读取。
export class PostService {
private http = inject(HttpClient);
create(post: Omit<Post, 'id'>) {
return this.http.post<Post>('/api/posts', post);
}
update(id: number, patch: Partial<Post>) {
return this.http.put<Post>(`/api/posts/${id}`, patch);
}
remove(id: number) {
return this.http.delete<void>(`/api/posts/${id}`);
}
}
// all return Observables — subscribe or use async pipe
// HTTP methods return a single value then complete查询参数与请求头
params 接受对象、HttpParams 或数组——Angular 将它们编码到查询字符串。headers 设置请求头。observe: 'response' 返回完整 HttpResponse(状态、头、体)而非仅体。observe: 'events' 配合 reportProgress 流式传输文件上传/下载进度事件。
export class SearchService {
private http = inject(HttpClient);
search(term: string, page = 1) {
return this.http.get<Result[]>('/api/search', {
params: { q: term, page, sort: 'desc' },
headers: { 'X-Api-Key': 'secret' },
observe: 'response', // get full HttpResponse
responseType: 'json',
});
}
// observe: 'events' streams upload/download progress
upload(file: File) {
return this.http.post('/api/upload', file, {
reportProgress: true,
observe: 'events',
});
}
}拦截器
函数式拦截器(v15+)是通过 withInterceptors() 链接的普通函数。它们对每个请求按顺序运行,非常适合认证令牌、日志、重试逻辑或缓存。clone() 创建修改后的请求(请求不可变)。next(req) 函数返回响应流——retry、catchError 等操作符在此处应用。
// functional interceptor (v15+)
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = localStorage.getItem('token');
const authReq = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next(authReq);
};
// register in app.config.ts
providers: [provideHttpClient(withInterceptors([authInterceptor]))]
// error/logging interceptor
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
console.log('Request:', req.url);
return next(req).pipe(tap({
error: e => console.error('Failed:', req.url, e),
}));
};错误处 理
使用 RxJS 操作符实现 HTTP 韧性:retry 在出错时重新订阅,timeout 中止慢请求,catchError 将错误映射为兜底值或重新抛出。也始终在消费端处理错误——未捕获的 Observable 错误会停止流。用 next/error/complete 回调订阅以完整处理生命周期。
export class PostService {
getPost(id: number) {
return this.http.get<Post>(`/api/posts/${id}`).pipe(
retry(2), // retry on failure
timeout(5000), // abort after 5s
catchError(err => {
if (err.status === 404) return of(null); // graceful fallback
return throwError(() => new Error('Network error'));
}),
);
}
}
// component-side
this.posts.getPost(1).subscribe({
next: p => this.post = p,
error: e => this.error = e.message,
complete: () => this.loading = false,
});类型化响应与 withFetch
泛型为响应体提供类型,但运行时不验证——为安全起见,使用 zod 等运行时验证器。observe: 'response' 暴露状态、头和体。withFetch()(v18)使用 Fetch API 代替 XHR,启用流式传输和更好的服务端渲染。withXsrfConfiguration 添加自动 CSRF 令牌处理。
// typed generic — response is typed, runtime NOT validated
this.http.get<User[]>('/api/users');
// full HttpResponse observation
this.http.get<User>('/api/users/1', { observe: 'response' })
.subscribe(res => {
console.log(res.status); // 200
console.log(res.headers.get('X-Total-Count'));
console.log(res.body); // User
});
// opt into fetch() backend (v18+, recommended)
// provideHttpClient(withFetch())
// with XSRF protection enabled
// provideHttpClient(withXsrfConfiguration({ cookieName: 'XSRF-TOKEN' }))Observables 与 RxJS
基本 Observable
Observable 是惰性的——生产者仅在订阅时运行。它们可能发出零个或多个值,然后完成或出错。subscribe 回调接收 next、error、complete。始终取消订阅长生命周期的订阅(或使用 async 管道 / takeUntilDestroyed)。of() 同步发出值;from() 展平数组或 promise。
import { Observable } from 'rxjs';
const obs = new Observable<number>(subscriber => {
subscriber.next(1);
subscriber.next(2);
setTimeout(() => {
subscriber.next(3);
subscriber.complete();
}, 1000);
});
const sub = obs.subscribe({
next: v => console.log(v),
error: e => console.error(e),
complete: () => console.log('done'),
});
// cleanup
sub.unsubscribe();
// use of(...) for synchronous values
of(1, 2, 3).subscribe(v => console.log(v));
// from(iterable) for arrays/async iterables
from([1, 2, 3]).subscribe(console.log);常用操作符
操作符转换流。map/filter 类似数组方法。debounceTime 等待安静期(非常适合搜索输入)。distinctUntilChanged 跳过连续重复值。switchMap 在新值到达时取消之前的内部 Observable——非常适合边输入边搜索。操作符从左到右管道。
import { of, from, interval } from 'rxjs';
import { map, filter, switchMap, debounceTime, distinctUntilChanged } from 'rxjs/operators';
of(1, 2, 3, 4).pipe(
map(x => x * 10), // 10, 20, 30, 40
filter(x => x > 15), // 20, 30, 40
).subscribe(console.log);
// search input with debounce
input$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term => http.get(`/api/search?q=${term}`)),
).subscribe(results => this.results = results);组合流
combineLatest 在任意源发出时发出(所有源至少发出一次后)——适合派生视图状态。forkJoin 等待所有源完成,返回每个的最后值——用于并行 HTTP 请求。merge 交错发出。zip 按索引配对。race 选择第一个发出的 Observable 并忽略其余。
import { combineLatest, forkJoin, merge, race, zip } from 'rxjs';
// combineLatest: emits when ANY source emits, after all have emitted
combineLatest([user$, settings$]).subscribe(([u, s]) => ...);
// forkJoin: waits for all to complete, emits last values (like Promise.all)
forkJoin({ user: user$, posts: posts$ }).subscribe(r => ...);
// merge: emits values from all sources as they come
merge(clicks$, keys$).subscribe(e => ...);
// zip: pairs emissions by index
zip(range(1, 3), ['a', 'b', 'c']).subscribe(([n, letter]) => ...);
// race: takes the first to emit, ignores the rest
race(primary$, fallback$).subscribe(v => ...);Subject(多播)
Subject 既是 Observable 又是 Observer——调用 .next() 推送值。Subject:无初始值,迟到订阅者错过之前发出的值。BehaviorSubject:需要初始值,新订阅者立即获得当前值(用于状态)。ReplaySubject:为新订阅者缓冲最后 N 个值(用于重放事件)。
import { Subject, BehaviorSubject, ReplaySubject } from 'rxjs';
// Subject: emits to current subscribers only
const click$ = new Subject<MouseEvent>();
click$.subscribe(x => console.log('A:', x));
click$.next({} as MouseEvent); // A: logs it
// BehaviorSubject: keeps last value, new subs get it immediately
const count$ = new BehaviorSubject(0);
count$.subscribe(v => console.log(v)); // logs 0 immediately
count$.next(1); // logs 1 to subscribers
// ReplaySubject: replays last N values to new subscribers
const recent$ = new ReplaySubject<string>(2);
recent$.next('a'); recent$.next('b'); recent$.next('c');
recent$.subscribe(v => console.log(v)); // logs 'b', then 'c'takeUntil 与清理
takeUntil(unsubscribe$) 在通知器发出时自动完成订阅——避免组件泄漏的标准模式。takeUntilDestroyed()(v16+)使用 Angular 的 DestroyRef 自动取消订阅,无需 OnDestroy 样板。必须在注入上下文中调用(构造函数或字段初始化器)或显式接受 DestroyRef。
import { Subject, takeUntil, takeUntilDestroyed } from 'rxjs';
export class DemoComponent implements OnDestroy {
private destroy$ = new Subject<void>();
ngOnInit() {
this.svc.stream$.pipe(
takeUntil(this.destroy$),
).subscribe(data => this.data = data);
}
ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }
}
// v16+: takeUntilDestroyed (uses DestroyRef, no boilerplate)
export class DemoComponent {
data: any;
constructor() {
inject(SomeService).stream$.pipe(
takeUntilDestroyed(),
).subscribe(d => this.data = d);
}
}switchMap vs mergeMap vs concatMap
这些展平操作符在处理重叠内部 Observable 时不同。switchMap 取消(边输入边搜索)。mergeMap 并行(加载多个)。concatMap 排队(保持顺序)。exhaustMap 丢弃(防止重复提交)。选错会导致竞态条件——将操作符与用例的取消语义匹配。
import { from, interval } from 'rxjs';
import { switchMap, mergeMap, concatMap, exhaustMap } from 'rxjs/operators';
// switchMap: cancels the previous inner stream on new value
input$.pipe(switchMap(term => search(term)));
// Best for: typeahead search (discard stale results)
// mergeMap: runs all inner streams in parallel
ids$.pipe(mergeMap(id => fetchDetail(id)));
// Best for: parallel requests, order doesn't matter
// concatMap: queues inner streams, runs one at a time
queue$.pipe(concatMap(task => runTask(task)));
// Best for: ordered sequential operations
// exhaustMap: ignores new values while inner stream is active
save$.pipe(exhaustMap(() => saveForm()));
// Best for: prevent double-submit (ignore clicks while saving)生命周期钩子
钩子顺序概览
钩子按严格顺序触发。ngOnChanges 在 @Input 变化时触发(包括初始)。ngOnInit 在第一次 ngOnChanges 后触发一次。ngDoCheck 每个变更检测周期运行。After* 钩子表示内容/视图已就绪可查询 DOM。ngOnDestroy 是清理点——取消订阅、释放资源、清除定时器。
export class LifecycleComponent implements
OnChanges, OnInit, DoCheck, AfterContentInit,
AfterContentChecked, AfterViewInit, AfterViewChecked, OnDestroy {
ngOnChanges() { console.log('1. input changes'); }
ngOnInit() { console.log('2. component init (once)'); }
ngDoCheck() { console.log('3. custom change detection'); }
ngAfterContentInit() { console.log('4. projected content ready'); }
ngAfterContentChecked() { console.log('5. projected content checked'); }
ngAfterViewInit() { console.log('6. view (template) ready'); }
ngAfterViewChecked() { console.log('7. view checked'); }
ngOnDestroy() { console.log('8. cleanup before destroy'); }
}ngOnInit 与 ngOnDestroy
ngOnInit 在输入设置后运行一次——适合昂贵初始化(获取、订阅)的位置,因为构造函数应保持轻量。ngOnDestroy 是组件销毁时调用的唯一钩子——切勿跳过此处清理,否则会泄漏订阅、定时器和事件监听器。构造函数仅用于 DI;逻辑放 ngOnInit。
export class ChartComponent implements OnInit, OnDestroy {
private timer?: ReturnType<typeof setInterval>;
ngOnInit() {
// good for: data fetching, initialization, subscriptions
this.timer = setInterval(() => this.refresh(), 5000);
this.loadInitialData();
}
ngOnDestroy() {
// cleanup: clear timers, unsubscribe, release resources
if (this.timer) clearInterval(this.timer);
// also: this.sub.unsubscribe();
}
private refresh() { /* ... */ }
private loadInitialData() { /* ... */ }
}ngOnChanges(SimpleChange)
ngOnChanges 在 @Input 引用变化时触发,带有 SimpleChanges 映射:每个条目有 previousValue、currentValue、firstChange 和 isFirstChange()。当你变更对象属性时它不会触发——仅引用变化时。对于深层变化,使用带 setter 的 ngOnChanges 或带 IterableDiffers 的 ngDoCheck。
export class PaginationComponent implements OnChanges {
@Input() page = 1;
@Input() total = 0;
ngOnChanges(changes: SimpleChanges) {
if (changes['page'] && !changes['page'].firstChange) {
// react only when page actually changed (not on init)
console.log('page:', changes['page'].previousValue, '->', changes['page'].currentValue);
this.fetchPage(this.page);
}
}
private fetchPage(p: number) { /* ... */ }
}ngAfterViewInit 与 ViewChild
ViewChild/ContentChild 查询在各自的 init 钩子之后解析。ngAfterViewInit 用于组件自身模板中的元素;ngAfterContentInit 用于通过 <ng-content> 投影的内容。在 ngOnInit 中访问这些 ref 返回 undefined。! 非空断言告诉编译器它在 init 后已设置——但仅在 After* 钩子中安全使用。
export class CanvasComponent implements AfterViewInit {
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
// ❌ canvasRef is undefined in ngOnInit
ngOnInit() { /* this.canvasRef is undefined here */ }
// ✅ available after view init
ngAfterViewInit() {
const canvas = this.canvasRef.nativeElement;
const ctx = canvas.getContext('2d');
ctx?.fillText('Hello', 10, 50);
}
}
// template: <canvas #canvas></canvas>ngDoCheck(自定义检测)
ngDoCheck 每个变更检测周期运行——保持轻量。使用 IterableDiffers/KeyValueDiffers 检测 ngOnChanges 遗漏的数组/对象变更(因为引用未变)。这是跟踪内部变更的逃生舱。避免在此处放昂贵逻辑;它频繁运行可能导致性能问题。
export class ListComponent implements DoCheck {
@Input() items: Item[] = [];
private differ: IterableDiffer<Item>;
constructor(differs: IterableDiffers) {
this.differ = differs.find(items).create<Item>((i) => i.id);
}
ngDoCheck() {
const changes = this.differ.diff(this.items);
if (changes) {
changes.forEachAddedItem(r => console.log('Added:', r.item));
changes.forEachRemovedItem(r => console.log('Removed:', r.item));
}
}
}内容 vs 视图钩子
内容钩子(AfterContentInit/Checked)针对通过 <ng-content> 投影的内容触发。视图钩子(AfterViewInit/Checked)针对组件自身模板触发。内容钩子在视图钩子之前触发,因为内容在视图完成前初始化。@ContentChild 查询投影元素;@ViewChild 查询自身模板元素。
export class CardComponent implements
AfterContentInit, AfterViewInit, AfterContentChecked, AfterViewChecked {
@ContentChild('header') headerTpl?: TemplateRef<unknown>;
@ViewChild('body') bodyEl?: ElementRef<HTMLElement>;
// projected content (<ng-content>) is ready
ngAfterContentInit() {
console.log('content ready:', this.headerTpl);
}
// own template view is ready
ngAfterViewInit() {
console.log('view ready:', this.bodyEl?.nativeElement);
}
// ContentChecked/ViewChecked run every CD cycle after init — beware perf
}内容投影
基本 ng-content
<ng-content> 是父组件提供内容投影的占位符。可看作 Angular 的 <slot>。不带 select 时,所 有投影内容进入此处。投影内容在父组件上下文中编译——如 {{ }} 的绑定针对父组件而非 CardComponent 解析。
// card.component.ts
@Component({
selector: 'app-card',
template: `
<div class="card">
<ng-content></ng-content>
</div>
`,
styles: ['.card { border: 1px solid #ccc; padding: 16px; }'],
})
export class CardComponent {}
<!-- usage: anything inside projects into <ng-content> -->
<app-card>
<h3>Title</h3>
<p>Body content here.</p>
</app-card>多插槽投影
ng-content 上的 select 属性定位特定投影内容:按元素('header')、属性('[header]')、类('.active')或组合('div.foo[bar]')。未匹配的内容落入未带 select 的 ng-content。投影内容保持父组件的样式和上下文——仅其在 DOM 中的位置改变。
@Component({
selector: 'app-layout',
template: `
<header><ng-content select="[header]"></ng-content></header>
<main><ng-content></ng-content></main>
<footer><ng-content select="footer"></ng-content></footer>
`,
})
export class LayoutComponent {}
<!-- usage: select matches attributes, elements, classes -->
<app-layout>
<nav header>Top nav</nav>
<p>Main body</p>
<footer>Bottom</footer>
</app-layout>条件投影(ng-template)
对于条件或重复投影,ng-template 配合 ngTemplateOutlet 提供完全控制。ContentChildren 查询投影的指令。ngTemplateOutlet 在选定位置渲染 TemplateRef——适用于选项卡、手风琴或条件插槽。此模式将内容声明与其渲染位置/时机解耦。
@Component({
selector: 'app-tabs',
template: `
<div class="tabs">
@for (t of tabs; track t) {
<button (click)="active = t">{{ t.title }}</button>
}
</div>
@for (t of tabs; track t) {
@if (t === active) {
<ng-container [ngTemplateOutlet]="t.template" />
}
}
`,
})
export class TabsComponent {
@ContentChildren(TabDirective) tabs!: QueryList<TabDirective>;
active: any;
}ContentChild 与 ContentChildren
@ContentChild / @ContentChildren 查询投影内容(vs ViewChild 查询自身模板)。ContentChildren 返回 QueryList,内容变化时更新。在 ngAfterContentInit 中解析。{ descendants: true } 选项(默认)遍历所有投影内容。用 { read: ... } 查询特定类型(ElementRef、TemplateRef 等)。
@Directive({ selector: 'tab' })
export class TabDirective {
@Input() title = '';
@Input() active = false;
@ContentChild(TemplateRef) template!: TemplateRef<unknown>;
}
@Component({
selector: 'app-tabset',
template: `
@for (tab of tabs; track tab) {
<button (click)="select(tab)">{{ tab.title }}</button>
}
<ng-container *ngTemplateOutlet="activeTab?.template" />
`,
})
export class TabsetComponent implements AfterContentInit {
@ContentChildren(TabDirective) tabs!: QueryList<TabDirective>;
activeTab?: TabDirective;
ngAfterContentInit() {
this.activeTab = this.tabs.find(t => t.active) ?? this.tabs.first;
}
}带上下文的 ngTemplateOutlet
ngTemplateOutlet 渲染 TemplateRef 并带可选上下文。let-x 绑定到 context.$implicit(默认值);let-y='key' 绑定到 context.key。这就是 *ngFor 和 ngTemplateOutlet 传递循环变量的方式。用于父组件可自定义的可复用列表/网格/项模板,同时组件控制迭代逻辑。
@Component({
selector: 'app-list',
template: `
@for (item of items; track item.id) {
<ng-container *ngTemplateOutlet="itemTpl; context: { $implicit: item, i: $index }" />
}
<ng-template #itemTpl let-item let-i="i">
<li>{{ i }}: {{ item.name }}</li>
</ng-template>
`,
})
export class ListComponent {
items = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
}ng-content select 模式
select 接受类 CSS 选择器:类(.x)、属性([x])、元素(header)或逗号分隔列表('a, b')。未匹配任何选择器的内容落入未带 select 的 ng-content。若无兜底,未匹配内容被丢弃。选择器匹配投影元素自身的属性,而非其内部结构。
@Component({
selector: 'app-modal',
template: `
<div class="modal">
<ng-content select=".modal-header"></ng-content>
<ng-content select="[body]"></ng-content>
<ng-content select="app-modal-footer, footer"></ng-content>
<!-- fallback for unmatched content -->
<ng-content></ng-content>
</div>
`,
})
<!-- matches by class, attribute, element, or selector list -->
<app-modal>
<div class="modal-header">Title</div>
<section body>Body text</section>
<app-modal-footer>OK</app-modal-footer>
<p>This falls into the unselect'd slot.</p>
</app-modal>路由守卫
CanActivate(函数式)
函数式守卫(v14.2+)返回 true/false/UrlTree 或它们的 Observable/Promise。返回 UrlTree 重定向到该路由。inject() 可用,因为守卫在注入上下文中运行。CanActivate 阻止导航到路由。在每条路由的 canActivate 数组中注册守卫。它们在每次导航到该路由时运行。
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn) return true;
// redirect to login, preserving the attempted URL
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
// register in routes
{ path: 'dashboard', component: DashboardComponent, canActivate: [authGuard] }CanActivateChild
CanActivateChild 在父路由下的每个子路由之前运行——比在每个子路由上重复 canActivate 更简洁。用于功能区域的共享保护。返回与 CanActivate 相同的值类型。若也要保护父路由本身,也在父路由上添加 canActivate。
export const adminGuard: CanActivateChildFn = (route, state) => {
const auth = inject(AuthService);
if (!auth.isAdmin) {
return inject(Router).parseUrl('/forbidden');
}
return true;
};
// applies to all children of this route
{
path: 'admin',
canActivateChild: [adminGuard],
children: [
{ path: 'users', component: AdminUsersComponent },
{ path: 'settings', component: AdminSettingsComponent },
],
}CanDeactivate(守卫未保存更改)
CanDeactivate 在离开路由前运行——非常适合警告未保存的表单更改。守卫接收组件实例,因此调用其上的方法。返回 false 取消导航,true 继续。对于异步(模态确认),返回 Observable 或 Promise。组件必须实现守卫期望的契约。
import { CanDeactivateFn } from '@angular/router';
import { inject } from '@angular/core';
export interface CanComponentDeactivate {
canDeactivate: () => boolean | Observable<boolean>;
}
export const unsavedGuard: CanDeactivateFn<CanComponentDeactivate> = (component) => {
return component?.canDeactivate() ?? true;
};
// component
export class EditComponent implements CanComponentDeactivate {
canDeactivate() {
if (this.form.dirty) return confirm('Discard changes?');
return true;
}
}
// route: { path: 'edit/:id', canDeactivate: [unsavedGuard] }CanMatch(条件加载)
CanMatch(v14.1+)在懒加载路由的块下载之前运行——对于付费功能门控比 CanActivate 更高效。若返回 false,路由被完全跳过(其他匹配路由有机会)。CanActivate 会先加载块再拒绝。用 CanMatch 阻止下载用户无法访问的代码。
import { CanMatchFn } from '@angular/router';
export const paidFeatureGuard: CanMatchFn = (route, segments) => {
const auth = inject(AuthService);
// runs BEFORE the lazy chunk is even loaded
if (auth.hasProPlan) return true;
return inject(Router).createUrlTree(['/upgrade']);
};
// route configuration
{
path: 'premium',
canMatch: [paidFeatureGuard],
loadComponent: () => import('./premium/premium.component'),
}Resolve(预取数据)
Resolve 在路由激活前运行,延迟导航直到数据到达。解析的数据放入 route.snapshot.data。用于确保组件渲染时有数据(无空状态闪烁)。在解析器内处理错误——返回 EMPTY 取消导航。对于非阻塞加载,改为在 ngOnInit 中获取。
import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
export const userResolver: ResolveFn<User> = (route) => {
const id = route.paramMap.get('id')!;
return inject(UserService).getUser(+id).pipe(
catchError(() => {
inject(Router).navigate(['/users']);
return EMPTY;
}),
);
};
// route
{ path: 'users/:id', resolve: { user: userResolver }, component: UserComponent }
// component reads pre-fetched data
export class UserComponent {
user = this.route.snapshot.data['user'] as User;
constructor(private route: ActivatedRoute) {}
}守卫组合与顺序
多个守卫按数组顺序运行;第一个失败者取消导航。canMatch 在懒加载块加载前运行;canActivate 在之后。canDeactivate 在离开时运行。所有守卫可同步(返回 boolean)或异步(Observable/Promise)。保持快速——它们阻塞 UI。繁重的服务端检查应去抖动或缓存结果。
// multiple guards run in declaration order, all must pass
{
path: 'admin',
canActivate: [authGuard, roleGuard],
canMatch: [paidFeatureGuard],
canDeactivate: [unsavedGuard],
loadChildren: () => import('./admin/admin.routes'),
}
// guard returning Observable (async check)
export const roleGuard: CanActivateFn = () => {
return inject(AuthService).hasRole('admin').pipe(
map(ok => ok || inject(Router).parseUrl('/forbidden')),
);
};
// ordering: canMatch → resolve → canActivate → canActivateChild解析器
基本解析器(ResolveFn)
ResolveFn 是函数式解析器 API。它在导航完成前运行,返回值放在你分配的键下的 route.data 中。组件可从 snapshot.data 同步读取——无需加载状态。订阅 route.data 以在同组件路由间导航时响应式更新。
import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
export const postResolver: ResolveFn<Post> = (route, state) => {
const id = Number(route.paramMap.get('id'));
return inject(PostService).getPost(id);
};
// route
{
path: 'posts/:id',
resolve: { post: postResolver },
component: PostDetailComponent,
}
// reading the resolved data
export class PostDetailComponent {
post = this.route.snapshot.data['post'] as Post;
constructor(private route: ActivatedRoute) {}
// reactive: re-fetch when :id changes (same component reused)
ngOnInit() {
this.route.data.subscribe(d => this.post = d['post']);
}
}多个解析器
多个解析器并行运行——所有必须在路由激活前完成。resolve 中的每个键成为 route.data 中的一个键。若一个失败,导航被取消。对于独立数据,这很高效。若一个依赖另一个,嵌套它们或在单个解析器中链式调用。权衡解析器与 ngOnInit 获取。
{
path: 'dashboard',
component: DashboardComponent,
resolve: {
user: userResolver,
stats: statsResolver,
notifications: notifResolver,
},
}
// component
export class DashboardComponent {
constructor(private route: ActivatedRoute) {}
ngOnInit() {
const { user, stats, notifications } = this.route.snapshot.data;
// all three resolved before component loads
}
}解析器错误处理
显式处理解析器错误——未捕获的错误会静默取消导航。EMPTY 完成流而不发出,取消导航。返回 null 让组件优雅处理缺失数据。对于 404,重定向到未找到路由。始终记录或暴露错误;静默失败对用户难以调试。
export const userResolver: ResolveFn<User | null> = (route) => {
const id = route.paramMap.get('id')!;
const router = inject(Router);
const service = inject(UserService);
return service.getUser(+id).pipe(
catchError(err => {
if (err.status === 404) {
// redirect to a 'not found' page, cancel navigation
router.navigate(['/404']);
return EMPTY;
}
// re-throw other errors
return throwError(() => err);
}),
);
};解析器 vs ngOnInit 获取
解析器阻塞导航直到数据到达——UX 干净但感觉较慢。ngOnInit 获取是非阻塞的但要求组件中有加载状态。现代 Angular 指导倾向于非阻塞:在组件中获取并显示加载骨架以获得更好的感知性能。仅在组件确实无法在没有数据时渲染时才使用解析器。
// Approach A: Resolver (blocks navigation until data arrives)
// ✅ Component renders with data — no flicker
// ❌ Slower navigation feel, blocks route transition
{
path: 'user/:id',
resolve: { user: userResolver },
component: UserComponent,
}
// Approach B: Fetch in ngOnInit (non-blocking)
// ✅ Snappy navigation, show loading skeleton
// ❌ Component must handle null/loading state
export class UserComponent implements OnInit {
user?: User;
loading = true;
ngOnInit() {
this.route.params.subscribe(p => {
this.loading = true;
this.svc.getUser(+p['id']).subscribe(u => {
this.user = u;
this.loading = false;
});
});
}
}带查询参数与信号的解析器
解析器可从路由读取 paramMap 和 queryParamMap。当同一组件处理不同参数时,响应式订阅 route.data 而非使用 snapshot。使用信号时,从 data 订阅更新信号。将解析器与信号结合实现干净、响应式的数据流。避免繁重的解析器——它们阻塞导航。
// read query params inside a resolver
export const searchResolver: ResolveFn<SearchResult[]> = (route) => {
const q = route.queryParamMap.get('q') ?? '';
const page = Number(route.queryParamMap.get('page') ?? 1);
return inject(SearchService).search(q, page);
};
// resolver feeding a signal-based component
export class SearchComponent {
private route = inject(ActivatedRoute);
results = signal<SearchResult[]>([]);
constructor() {
this.route.data.subscribe(d => this.results.set(d['results']));
}
}动画
设置与基本动画
provideAnimations() 注册动画模块。trigger() 定义命名动画;transition() 声明状态间的变化。:enter 和 :leave 是通过 *ngIf 或 @if 添加/移除元素的内置伪状态。在模板中通过 @triggerName 应用。对于 v17+ 控制流,动画仍与 @if/@for 一起工作。
// app.config.ts
import { provideAnimations } from '@angular/platform-browser/animations';
export const appConfig: ApplicationConfig = {
providers: [provideAnimations()],
};
// component
@Component({
selector: 'app-fade',
animations: [
trigger('fadeIn', [
transition(':enter', [
style({ opacity: 0 }),
animate('300ms ease-in', style({ opacity: 1 })),
]),
transition(':leave', [
animate('300ms ease-out', style({ opacity: 0 })),
]),
]),
],
template: `<div *ngIf="show" @fadeIn>Hello</div>`,
})状态与过渡
state() 定义静止时的命名样式。transition('a => b') 从状态 a 动画到 b;'<=>' 是双向的。将触发器绑定到返回状态名称的表达式。状态样式立即应用(无动画);过渡在它们之间动画。使用 'void' 表示尚未在 DOM 中的元素,'*' 作为通配符。
@Component({
selector: 'app-toggle',
animations: [
trigger('openClose', [
state('open', style({ height: '*', opacity: 1 })),
state('closed', style({ height: '0', opacity: 0 })),
transition('open => closed', animate('200ms ease-out')),
transition('closed => open', animate('300ms ease-in')),
// bidirectional shortcut: 'open <=> closed'
]),
],
template: `
<button (click)="isOpen = !isOpen">Toggle</button>
<div [@openClose]="isOpen ? 'open' : 'closed'">Panel</div>
`,
})
export class ToggleComponent { isOpen = false; }关键帧与并行动画
keyframes() 定义带 offset(0 到 1)的中间步骤以进行细粒度控制——类似 CSS @keyframes。group() 并行运行动画;sequence() 依次运行。用这些组合复杂的入场/出场效果。保持动画简短(200-400ms)并为无障碍尊重 prefers-reduced-motion。
trigger('wiggle', [
transition('* => *', [
animate('600ms ease-in-out', keyframes([
style({ transform: 'translateX(0)', offset: 0 }),
style({ transform: 'translateX(-10px)', offset: 0.25 }),
style({ transform: 'translateX(10px)', offset: 0.75 }),
style({ transform: 'translateX(0)', offset: 1 }),
])),
]),
]),
// parallel/group animations
transition(':enter', [
group([
animate('300ms', style({ opacity: 1 })),
animate('300ms', style({ transform: 'translateY(0)' })),
]),
]),动画列表(@for)
将 :enter/:leave 动画应用于 @for 项——它们在项添加或移除时触发。track 确保 Angular 跨变更正确识别项,因此动画在实际添加/移除时触发(而非重排)。无正确追踪的列表动画可能卡顿。对于重排动画,使用 query() 和 animateChild()。
@Component({
selector: 'app-list',
animations: [
trigger('itemAnim', [
transition(':enter', [
style({ height: 0, opacity: 0 }),
animate('200ms', style({ height: '*', opacity: 1 })),
]),
transition(':leave', [
animate('200ms', style({ height: 0, opacity: 0 })),
]),
]),
],
template: `
@for (item of items; track item.id) {
<div @itemAnim>{{ item.name }}</div>
}
`,
})
export class AnimListComponent { items = [...]; }路由动画
将动画触发器绑定到 router-outlet 的激活状态。query(':enter'/:leave) 定位传入/传出的路由组件。{ optional: true } 避免一侧不存在时出错(初始加载无 :leave)。position: absolute 在过渡期间堆叠新旧组件。路由动画在视图间 提供应用般的过渡。
@Component({
selector: 'app-root',
animations: [
trigger('routeAnim', [
transition('* => *', [
query(':enter', [
style({ opacity: 0, position: 'absolute', width: '100%' }),
animate('300ms', style({ opacity: 1 })),
], { optional: true }),
query(':leave', [
animate('300ms', style({ opacity: 0 })),
], { optional: true }),
]),
]),
],
template: `
<div [@routeAnim]="o.isActivated ? o.activatedRoute : ''">
<router-outlet #o="outlet"></router-outlet>
</div>
`,
})减弱动画与 CSS 动画
始终为无障碍尊重 prefers-reduced-motion——一些用户会晕动症。Angular 动画为包增加约 50KB;对于简单淡入,普通 CSS 动画可能足够。需要状态化过渡(状态机)、父子协调或路由动画时使用 Angular 动画。CSS 对于悬停/淡入效果更轻量。
// respect prefers-reduced-motion
@Component({
selector: 'app-safe-anim',
animations: [
trigger('fade', [
transition(':enter', [
style({ opacity: 0 }),
animate('300ms', style({ opacity: 1 })),
]),
]),
],
// or just use CSS — Angular animations add bundle weight
template: `<div class="fade-in" @fade>Content</div>`,
styles: [`
@media (prefers-reduced-motion: reduce) {
.fade-in { animation: none !important; transition: none !important; }
}
`],
})测试
组件测试设置
TestBed 配置测试模块。对于独立组件,直接导入(无需 TestBed.configureTestingModule 声明)。createComponent 返回 ComponentFixture:用 .componentInstance 获取类,.nativeElement 获取 DOM,.detectChanges() 触发变更检测。带 async/await 的 beforeEach 处理 compileComponents。
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyComponent } from './my.component';
describe('MyComponent', () => {
let fixture: ComponentFixture<MyComponent>;
let component: MyComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MyComponent], // standalone components are imported
}).compileComponents();
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
fixture.detectChanges(); // runs initial change detection
});
it('should create', () => {
expect(component).toBeTruthy();
});
});用 Mock 服务测试
提供服务的 Mock 实现以隔离被测组件。jasmine.createSpy() 让你控制返回值并断言调用。useValue 用于对象 Mock,useClass 用于基于类的假实现。用 HttpTestingController 覆盖 HttpClient 进行 HTTP 测试。Mocking 保持测试快速和确定——无真实网络调用。
describe('UserComponent', () => {
let mockUserService = {
getUsers: jasmine.createSpy().and.returnValue(of([...])),
addUser: jasmine.createSpy().and.returnValue(of({})),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserComponent],
providers: [
{ provide: UserService, useValue: mockUserService },
],
}).compileComponents();
});
it('loads users on init', () => {
const fixture = TestBed.createComponent(UserComponent);
fixture.detectChanges();
expect(mockUserService.getUsers).toHaveBeenCalled();
expect(fixture.componentInstance.users.length).toBe(2);
});
});HTTP 测试
HttpTestingController 在测试中拦截 HttpClient 调用——无真实网络。expectOne 断言请求已发出并返回它;flush() 模拟服务器响应。verify() 确保无未匹配请求剩余。测试成功和错误路径:用状态码 flush(如 { status: 500, statusText: 'Server Error' })模拟错误。
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
describe('PostService', () => {
let service: PostService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
PostService,
provideHttpClient(),
provideHttpClientTesting(),
],
});
service = TestBed.inject(PostService);
httpMock = TestBed.inject(HttpTestingController);
});
it('fetches posts', () => {
service.getPosts().subscribe(posts => expect(posts.length).toBe(2));
const req = httpMock.expectOne('/api/posts');
expect(req.request.method).toBe('GET');
req.flush([{ id: 1 }, { id: 2 }]); // simulate response
httpMock.verify(); // no outstanding requests
});
});测试模板与交互
fixture.nativeElement 提供原始 DOM 访问用于断言。设置属性后 detectChanges() 同步模板绑定。spyOn 让你断言方法调用。对于组件内部查询,debugElement.query(By.css()) 是类型安全的。通过原生 DOM 方法(click())或 DebugElement.triggerEventHandler 触发自定义事件。
it('renders the title', () => {
const fixture = TestBed.createComponent(MyComponent);
fixture.componentInstance.title = 'Hello';
fixture.detectChanges();
const el: HTMLElement = fixture.nativeElement;
expect(el.querySelector('h1')?.textContent).toContain('Hello');
});
it('calls save on button click', () => {
const fixture = TestBed.createComponent(MyComponent);
const spy = spyOn(fixture.componentInstance, 'save');
const button = fixture.nativeElement.querySelector('button');
button.click();
expect(spy).toHaveBeenCalled();
});
// By.css for debug element queries (faster than nativeElement)
const debugEl = fixture.debugElement.query(By.css('.submit'));测试信号
信号是函数——在测试中调用它们(带括号)读取值。set() 直接更新可写信号,避免间接操作的需要。detectChanges() 同步信号驱动的模板。对于 computed/effect,detectChanges 或 TestBed.flushEffects() 确保依赖 effect 运行。信号使测试更同步和可预测。
describe('CounterComponent', () => {
it('increments count', () => {
const fixture = TestBed.createComponent(CounterComponent);
const comp = fixture.componentInstance;
expect(comp.count()).toBe(0);
comp.increment();
expect(comp.count()).toBe(1);
// force signal effects to run in tests
fixture.detectChanges();
});
it('renders count in template', () => {
const fixture = TestBed.createComponent(CounterComponent);
fixture.componentInstance.count.set(5);
fixture.detectChanges(); // syncs signal to template
expect(fixture.nativeElement.textContent).toContain('5');
});
});Cypress / Playwright E2E 测试
E2E 测试通过真实浏览器演练整个应用。使用 data-cy 属性作为选择器——稳定且不耦合 CSS 类。Cypress 在浏览器中运行;Playwright 跨浏览器运行。使用 ng e2e 或手动配置 Cypress/Playwright。E2E 测试捕获单元测试遗漏的集成问题但较慢——保持套件聚焦。
// cypress test (cypress/e2e/login.cy.ts)
describe('Login flow', () => {
beforeEach(() => cy.visit('/login'));
it('logs in with valid credentials', () => {
cy.get('[data-cy=email]').type('[email protected]');
cy.get('[data-cy=password]').type('secret123');
cy.get('[data-cy=submit]').click();
cy.url().should('include', '/dashboard');
cy.contains('Welcome, User');
});
});
// run with: npx cypress open (interactive)
// npx cypress run (headless CI)PWA
添加 PWA 支持
ng add @angular/pwa 脚手架生成一切:Service Worker 注册、Web Manifest 和图标。Service Worker 仅在生产构建中运行(ng build --configuration production)——在开发中禁用以避免缓存意外。registrationStrategy 控制 SW 何时注册;registerWhenStable 等待应用稳定。
# add @angular/service-worker
ng add @angular/pwa --project my-app
# this generates:
# - ngsw-config.json (service worker config)
# - manifest.webmanifest
# - icons in src/assets/icons/
# - registers service worker in app.config.ts
# app.config.ts (auto-updated)
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideServiceWorker('ngsw-worker.js', {
enabled: environment.production,
registrationStrategy: 'registerWhenStable:30000',
}),
],
};Service Worker 配置(ngsw-config.json)
assetGroups 缓存应用外壳(prefetch)和静态资源(lazy)。dataGroups 用 freshness/performance 策略和 TTL 缓存 API 响应。prefetch 急切下载;lazy 按需下载。freshness 先尝试网络,回退到缓存(适合新鲜数据);performance 先用缓存(适合较静态的数据)。