Angular

angular
分类通用
作者Agentic Awesome Skills 社区
许可MIT
评分4.40/5
使用9.9K

Angular 专家

掌握现代 Angular 开发,包括 Signals、独立组件、无 Zone 应用、SSR/Hydration 以及最新的响应式模式。

何时使用此技能

  • 构建新的 Angular 应用 (v20+)
  • 实现基于 Signals 的响应式模式
  • 创建独立组件以及从 NgModules 迁移
  • 配置无 Zone (Zoneless) 的 Angular 应用
  • 实现 SSR、预渲染 (prerendering) 和注水 (hydration)
  • 优化 Angular 性能
  • 采用现代 Angular 模式和最佳实践

何时不要使用此技能

  • 从 AngularJS (1.x) 迁移 $\rightarrow$ 请使用 angular-migration 技能
  • 处理无法升级的旧版 Angular 应用
  • 通用的 TypeScript 问题 $\rightarrow$ 请使用 typescript-expert 技能

指导指令

1. 评估 Angular 版本和项目结构
2. 应用现代模式 (Signals, Standalone, Zoneless)
3. 使用正确的类型定义和响应式实现
4. 通过构建和测试进行验证

安全性

  • 在投入生产前,务必在开发环境中测试更改
  • 对现有应用进行渐进式迁移(避免大规模推倒重构)
  • 在过渡期间保持向后兼容性

---

Angular 版本时间线

| 版本 | 发布时间 | 核心特性 |
| -------------- | ------- | ------------------------------------------------------ |
| Angular 20 | 2025 Q2 | Signals 正式稳定, Zoneless 正式稳定, 增量注水 (Incremental hydration) |
| Angular 21 | 2025 Q4 | 默认 Signal-first, 增强型 SSR |
| Angular 22 | 2026 Q2 | Signal Forms, 无选择器组件 (Selectorless components) |

---

1. Signals:全新的响应式原语

Signals 是 Angular 的细粒度响应式系统,旨在取代基于 zone.js 的变更检测。

核心概念

typescript
import { signal, computed, effect } from "@angular/core";

// 可写信号 (Writable signal)
const count = signal(0);

// 读取值
console.log(count()); // 0

// 更新值
count.set(5); // 直接设置
count.update((v) => v + 1); // 函数式更新

// 计算信号 (Computed/derived signal)
const doubled = computed(() => count() * 2);

// 效果 (Effect/副作用)
effect(() => {
console.log(Count changed to: ${count()});
});

基于 Signal 的输入与输出

typescript
import { Component, input, output, model } from "@angular/core";

@Component({
selector: "app-user-card",
standalone: true,
template:
<div class="card">
<h3>{{ name() }}</h3>
<span>{{ role() }}</span>
<button (click)="select.emit(id())">Select</button>
</div>
,
})
export class UserCardComponent {
// Signal 输入 (只读)
id = input.required<string>();
name = input.required<string>();
role = input<string>("User"); // 带默认值

// 输出
select = output<string>();

// 双向绑定 (model)
isSelected = model(false);
}

// 使用方式:
// <app-user-card [id]="'123'" [name]="'John'" [(isSelected)]="selected" />

Signal 查询 (ViewChild/ContentChild)

typescript
import {
  Component,
  viewChild,
  viewChildren,
  contentChild,
} from "@angular/core";

@Component({
selector: "app-container",
standalone: true,
template:
<input #searchInput />
<app-item *ngFor="let item of items()" />
,
})
export class ContainerComponent {
// 基于 Signal 的查询


searchInput = viewChild<ElementRef>("searchInput");
items = viewChildren(ItemComponent);
projectedContent = contentChild(HeaderDirective);

focusSearch() {
this.searchInput()?.nativeElement.focus();
}
}

code
### 何时使用 Signals vs RxJS

| 使用场景 | Signals | RxJS |
| ----------------------- | --------------- | -------------------------------- |
| 组件本地状态 | ✅ 推荐 | 过度设计 |
| 派生/计算值 | ✅ computed() | combineLatest 可实现 |
| 副作用 | ✅ effect() | tap 操作符 |
| HTTP 请求 | ❌ | ✅ HttpClient 返回 Observable |
| 事件流 | ❌ | ✅ fromEvent 及相关操作符 |
| 复杂异步流 | ❌ | ✅ switchMap, mergeMap |

---

2. 独立组件 (Standalone Components)

独立组件是自包含的,不需要在 NgModule 中声明。

创建独立组件

typescript import { Component } from "@angular/core"; import { CommonModule } from "@angular/common"; import { RouterLink } from "@angular/router";

@Component({
selector: "app-header",
standalone: true,
imports: [CommonModule, RouterLink], // 直接导入
template:
<header>
<a routerLink="/">Home</a>
<a routerLink="/about">About</a>
</header>
,
})
export class HeaderComponent {}

code
### 无 NgModule 引导启动
typescript
// main.ts
import { bootstrapApplication } from "@angular/platform-browser";
import { provideRouter } from "@angular/router";
import { provideHttpClient } from "@angular/common/http";
import { AppComponent } from "./app/app.component";
import { routes } from "./app/app.routes";

bootstrapApplication(AppComponent, {
providers: [provideRouter(routes), provideHttpClient()],
});

code
### 独立组件的懒加载
typescript
// app.routes.ts
import { Routes } from "@angular/router";

export const routes: Routes = [
{
path: "dashboard",
loadComponent: () =>
import("./dashboard/dashboard.component").then(
(m) => m.DashboardComponent,
),
},
{
path: "admin",
loadChildren: () =>
import("./admin/admin.routes").then((m) => m.ADMIN_ROUTES),
},
];

code
---

3. 无 Zone 的 Angular (Zoneless Angular)

Zoneless 应用不使用 zone.js,从而提升性能并简化调试。

启用 Zoneless 模式

typescript // main.ts import { bootstrapApplication } from "@angular/platform-browser"; import { provideZonelessChangeDetection } from "@angular/core"; import { AppComponent } from "./app/app.component";

bootstrapApplication(AppComponent, {
providers: [provideZonelessChangeDetection()],
});

code
### Zoneless 组件模式
typescript
import { Component, signal, ChangeDetectionStrategy } from "@angular/core";

@Component({
selector: "app-counter",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template:
<div>Count: {{ count() }}</div>
<button (click)="increment()">+</button>
,
})
export class CounterComponent {
count = signal(0);

increment() {
this.count.update((v) => v + 1);
// 无需 zone.js - Signal 会触发变更检测
}
}

code
### Zoneless 的核心优势

  • 性能:异步 API 不再被 zone.js 拦截(patch)
  • 调试:堆栈跟踪更简洁,没有 zone 包装层
  • 包体积:移除 zone.js 后体积更小(节省约 15KB)
  • 互操作性
可扩展性:结合 Web Components 和微前端效果更佳

---

4. 服务端渲染 (SSR) 与注水 (Hydration)

使用 Angular CLI 设置 SSR

bash ng add @angular/ssr
code
### 注水配置
typescript // app.config.ts import { ApplicationConfig } from "@angular/core"; import { provideClientHydration, withEventReplay, } from "@angular/platform-browser";

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

code
### 渐进式注水 (v20+)
typescript
import { Component } from "@angular/core";

@Component({
selector: "app-page",
standalone: true,
template:
<app-hero />

@defer (hydrate on viewport) {
<app-comments />
}

@defer (hydrate on interaction) {
<app-chat-widget />
}
,
})
export class PageComponent {}

code
### 注水触发条件

| 触发条件 | 使用场景 |
| :--- | :--- |
| on idle | 低优先级,在浏览器空闲时注水 |
| on viewport | 当元素进入视口时注水 |
| on interaction | 在用户首次交互时注水 |
| on hover | 当用户悬停时注水 |
| on timer(ms) | 在指定延迟后注水 |

---

5. 现代路由模式

函数式路由守卫 (Functional Route Guards)

typescript // auth.guard.ts import { inject } from "@angular/core"; import { Router, CanActivateFn } from "@angular/router"; import { AuthService } from "./auth.service";

export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);

if (auth.isAuthenticated()) {
return true;
}

return router.createUrlTree(["/login"], {
queryParams: { returnUrl: state.url },
});
};

// 在路由中使用
export const routes: Routes = [
{
path: "dashboard",
loadComponent: () => import("./dashboard.component"),
canActivate: [authGuard],
},
];

code
### 路由级数据解析器 (Route-Level Data Resolvers)
typescript
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { UserService } from './user.service';
import { User } from './user.model';

export const userResolver: ResolveFn<User> = (route) => {
const userService = inject(UserService);
return userService.getUser(route.paramMap.get('id')!);
};

// 在路由中配置
{
path: 'user/:id',
loadComponent: () => import('./user.component'),
resolve: { user: userResolver }
}

// 在组件中使用
export class UserComponent {
private route = inject(ActivatedRoute);
user = toSignal(this.route.data.pipe(map(d => d['user'])));
}

code
---

6. 依赖注入 (DI) 模式

现代 inject() 函数

typescript import { Component, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { UserService } from './user.service';

@Component({...})
export class UserComponent {
// 现代 inject() 方式 - 无需构造函数
private http = inject(HttpClient);
private userService = inject(UserService);

// 可在任何注入上下文中工作
users = toSignal(this.userService.getUsers());
}

code
### 用于配置的注入令牌 (Injection Tokens)
typescript
import { InjectionToken, inject } from "@angular/core";

// 定义令牌
export const API_BASE_URL = new InjectionToken<string>("API_BASE_URL");

// 在配置中提供
bootstrapApplication(AppComponent, {
providers: [{ provide: API_BASE_URL, useValue: "https://api.example.com" }],
});

code
" }],
});

// 在服务中注入
@Injectable({ providedIn: "root" })
export class ApiService {
private baseUrl = inject(API_BASE_URL);

get(endpoint: string) {
return this.http.get(${this.baseUrl}/${endpoint});
}
}

---

7. 组件组合与复用

内容投影 (Slots)

typescript
@Component({
  selector: 'app-card',
  template: 
    <div class="card">
      <div class="header">
        <!-- 通过属性选择 -->
        <ng-content select="[card-header]"></ng-content>
      </div>
      <div class="body">
        <!-- 默认插槽 -->
        <ng-content></ng-content>
      </div>
    </div>
  
})
export class CardComponent {}

// 使用方式
<app-card>
<h3 card-header>标题</h3>
<p>正文内容</p>
</app-card>

宿主指令 (Composition)

typescript
// 无需继承即可实现可复用行为
@Directive({
  standalone: true,
  selector: '[appTooltip]',
  inputs: ['tooltip'] // Signal input 别名
})
export class TooltipDirective { ... }

@Component({
selector: 'app-button',
standalone: true,
hostDirectives: [
{
directive: TooltipDirective,
inputs: ['tooltip: title'] // 映射输入
}
],
template: <ng-content />
})
export class ButtonComponent {}

---

8. 状态管理模式

基于 Signal 的状态服务

typescript
import { Injectable, signal, computed } from "@angular/core";

interface AppState {
user: User | null;
theme: "light" | "dark";
notifications: Notification[];
}

@Injectable({ providedIn: "root" })
export class StateService {
// 私有可写 signal
private _user = signal<User | null>(null);
private _theme = signal<"light" | "dark">("light");
private _notifications = signal<Notification[]>([]);

// 公开只读 computed
readonly user = computed(() => this._user());
readonly theme = computed(() => this._theme());
readonly notifications = computed(() => this._notifications());
readonly unreadCount = computed(
() => this._notifications().filter((n) => !n.read).length,
);

// 操作方法
setUser(user: User | null) {
this._user.set(user);
}

toggleTheme() {
this._theme.update((t) => (t === "light" ? "dark" : "light"));
}

addNotification(notification: Notification) {
this._notifications.update((n) => [...n, notification]);
}
}

基于 Signals 的组件 Store 模式

typescript
import { Injectable, signal, computed, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { toSignal } from "@angular/core/rxjs-interop";

@Injectable()
export class ProductStore {
private http = inject(HttpClient);

// 状态
private _products = signal<Product[]>([]);
private _loading = signal(false);
private _filter = signal("");

// 选择器 (Selectors)
readonly products = computed(() => this._products());
readonly loading = computed(() => this._loading());
readonly filteredProducts = computed(() => {
const filter = this._filter().toLowerCase();
return this._products().filter((p) =>
p.name.toLowerCase().includes(filter),
);
});

// 操作方法
loadProducts() {
this._loading.set(true);
this.http.get<Product[]>("/api/products").subscribe({
next: (products) => {
this._products.set(products);
this._loading.set(false);
},
error: () => this._loading.set(false),
});
}

setFilter(filter: string) {
this._filter.set(filter);
}
}

---

9. 基于 Signals 的表单 (即将于 v

22+)

当前的响应式表单 (Reactive Forms)

typescript
import { Component, inject } from "@angular/core";
import { FormBuilder, Validators, ReactiveFormsModule } from "@angular/forms";

@Component({
selector: "app-user-form",
standalone: true,
imports: [ReactiveFormsModule],
template:
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="name" placeholder="Name" />
<input formControlName="email" type="email" placeholder="Email" />
<button [disabled]="form.invalid">Submit</button>
</form>
,
})
export class UserFormComponent {
private fb = inject(FormBuilder);

form = this.fb.group({
name: ["", Validators.required],
email: ["", [Validators.required, Validators.email]],
});

onSubmit() {
if (this.form.valid) {
console.log(this.form.value);
}
}
}

Signal 感知表单模式 (预览)

typescript
// 未来的 Signal Forms API (实验性)
import { Component, signal } from '@angular/core';

@Component({...})
export class SignalFormComponent {
name = signal('');
email = signal('');

// 计算验证状态
isValid = computed(() =>
this.name().length > 0 &&
this.email().includes('@')
);

submit() {
if (this.isValid()) {
console.log({ name: this.name(), email: this.email() });
}
}
}

---

10. 性能优化

变更检测策略 (Change Detection Strategies)

typescript
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  // 仅在以下情况检查:
  // 1. Input signal/引用发生变化
  // 2. 事件处理器运行
  // 3. Async 管道发出值
  // 4. Signal 值发生变化
})

用于延迟加载的 Defer 块

typescript
@Component({
  template: 
    <!-- 立即加载 -->
    <app-header />

<!-- 进入视口时延迟加载 -->
@defer (on viewport) {
<app-heavy-chart />
} @placeholder {
<div class="skeleton" />
} @loading (minimum 200ms) {
<app-spinner />
} @error {
<p>Failed to load chart</p>
}

})

NgOptimizedImage

typescript
import { NgOptimizedImage } from '@angular/common';

@Component({
imports: [NgOptimizedImage],
template:
<img
ngSrc="hero.jpg"
width="800"
height="600"
priority
/>

<img
ngSrc="thumbnail.jpg"
width="200"
height="150"
loading="lazy"
placeholder="blur"
/>

})

---

11. 测试现代 Angular

测试 Signal 组件

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

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

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent], // 独立组件导入
}).compileComponents();

fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it("should increment count", () => {
expect(component.count()).toBe(0);

component.increment();

expect(component.count()).toBe(1);
});

it("should update DOM on signal change", () => {
component.count.set(5);
fixture.detectChanges();

const el = fixture.nativeElement.querySelector(".count");
expect(el.textContent).toContain("5");
});
});

使用 Signal Inputs 进行测试

typescript
import { ComponentFixture, TestBed } from "@angular/core/
testing"; import { ComponentRef } from "@angular/core"; import { UserCardComponent } from "./user-card.component";

describe("UserCardComponent", () => {
let fixture: ComponentFixture<UserCardComponent>;
let componentRef: ComponentRef<UserCardComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserCardComponent],
}).compileComponents();

fixture = TestBed.createComponent(UserCardComponent);
componentRef = fixture.componentRef;

// 通过 setInput 设置 signal inputs
componentRef.setInput("id", "123");
componentRef.setInput("name", "John Doe");

fixture.detectChanges();
});

it("should display user name", () => {
const el = fixture.nativeElement.querySelector("h3");
expect(el.textContent).toContain("John Doe");
});
});
``

---

最佳实践总结

| 模式 | ✅ 推荐做法 | ❌ 避免做法 |
| -------------------- | ------------------------------ | ------------------------------- |
| 状态管理 | 使用 Signals 处理本地状态 | 对简单状态过度使用 RxJS |
| 组件 | 使用 Standalone 组件并直接导入 | 使用臃肿的 SharedModules |
| 变更检测 | OnPush + Signals | 到处使用默认变更检测 (Default CD) |
| 懒加载 | 使用
@deferloadComponent | 全部采用预加载 (Eager load) |
| 依赖注入 (DI) | 使用
inject() 函数 | 使用构造函数注入(过于冗长) |
| 输入 (Inputs) | 使用
input() signal 函数 | 使用 @Input() 装饰器(旧版) |
| Zoneless | 在新项目中启用 | 在未测试的情况下强行应用于旧项目 |

---

相关资源

---

常见问题排查

| 问题 | 解决方案 |
| ------------------------------ | --------------------------------------------------- |
| Signal 未更新 UI | 确保使用
OnPush 且以函数形式调用 signal,如 count() |
| 水合 (Hydration) 不匹配 | 检查服务端与客户端内容的的一致性 |
| 循环依赖 | 在
inject() 中配合 forwardRef 使用 |
| Zoneless 未检测到变更 | 通过 signal 更新触发,而非直接修改对象属性 |
| SSR fetch 失败 | 使用
TransferStatewithFetch()` |

局限性

  • 仅在任务明确符合上述范围时使用此技能。
  • 不要将输出结果视为环境特定验证、测试或专家评审的替代方案。
  • 如果缺少必要的输入、权限、安全边界或验收标准,请停止并请求澄清。