Skip to content

Angular

本文基于 Angular 22(2026-06-03 发布的最新稳定版,官方 24 个月支持窗口内 Active)讲解现代 Angular 的完整形态:standalone 组件 + 信号(signals)+ 可选 Zoneless 的新范式,以及沿用至今的依赖注入与 RxJS 生态。 核心信号 API 示例已用 @angular/core@22.1.4 在 Node v20.20.1 实测;模板/路由等浏览器侧代码标注适用版本并给出可直接运行的最小工程。

全景:全家桶框架 vs 库

React/Vue 是「库 + 社区拼装」:状态、路由、请求、表单、测试各选其道,由你决定取舍。Angular 是完整框架:CLI、组件化、依赖注入、路由、HTTP、表单、响应式、测试工具全部内建且版本一致——换来的代价是概念多、学习曲线陡;换来的是大型团队/大型应用的统一约束

维度AngularReactVue
定位全家桶框架视图库渐进式框架
响应式模型信号 +(传统)Zone.js 脏检查手动 setState / useSyncExternalStore响应式代理 / ref
样式作用域组件内 :host 与封装CSS-in-JS / CSS ModulesSFC scoped
变更检测框架调度(含 Zoneless)组件树重渲染组件级响应追踪
内置CLI、DI、路由、HTTP、表单、i18n、测试需自行拼装官方 Router/Pinia/Vite
TypeScript一等公民(AngularJS 是 JS,Angular 2+ 必须 TS)推荐但可选推荐但可选

一句话定位:选择 Angular 等于接受一整套「框架替你决策」的约束——这套约束在 Google 内部支撑了 Gmail、Google Drive 等超大型应用。

版本演进速览(每个大版本一次语义升级)

版本时间关键变化
AngularJS 1.x2010–前端 MV* 先驱,与 2+ 完全重写、不兼容(已 EOL)
v2–v82016–2019TS + 组件化 + Zone.js + 依赖注入;稳定演进
v92020Ivy 编译与渲染管线成为默认
v142022Standalone 组件(Developer Preview);NgModule 开始退场
v172023-11Standalone 成为默认;新控制流 @if/@for/@switch 稳定
v192024-11信号(signals)稳定input/output/model/computed/effect
v18→v212024–2025Zoneless 从实验到新项目默认;v21 起 Zone.js 退出默认脚手架
v222026-06当前 Active;Signal Forms、Angular Aria 稳定

Angular 自 v22 起改为每 12 个月一个大版本(此前每 6 个月);每个大版本维护 24 个月(12 个月 Active + 12 个月 LTS),可跨一个 major 用 ng update 自动迁移。

现代 Angular 四支柱

组件化 + 依赖注入 + 服务层 + 响应式(信号与 RxJS),再加内建路由/表单/HTTP。历史上「模块(NgModule)」曾包裹组件并组织 DI,v14+ 起 standalone 组件自带依赖声明、不再需要 NgModule 外壳——新项目生成的 app.ts 完全没有 NgModule。

快速上手(Standalone 最小工程)

bash
npx -y @angular/cli@22 new my-app --style=css --standalone   # standalone 已是默认
cd my-app && ng serve   # http://localhost:4200

组件即「类 + 元数据 + 模板」:

ts
// src/app/app.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',          // 模板里用 <app-root> 引用
  standalone: true,              // v17+ 默认 true,可省略
  template: `
    <h1>{{ title }}</h1>
    <button (click)="count.set(count() + 1)">count = {{ count() }}</button>
  `,
})
export class AppComponent {
  title = 'Hello Angular';
  count = signal(0);             // 信号字段(见「信号与变更检测」)
}

应用引导(不需要 NgModule,不需要 bootstrapModule):

ts
// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent);   // 可选第二个参数 provideAppConfig(...)

要点:selector 名唯一;类字段直接可在模板插值中使用;事件 (click) 绑方法或表达式;方法内更新信号即触发重渲染——组件不需要手动 setState

组件与模板

数据绑定的四种形态

语法方向示例说明
{{ expr }}单向(数据→视图){{ user.name }}插值,也可用于方法返回值
[prop]="expr"单向[disabled]="!valid"属性绑定,与 DOM 属性而非 HTML 属性绑定
(event)="stmt"单向(视图→数据)(click)="save()"事件绑定;$event 可取事件对象
[(model)]="x"双向[(ngModel)]="name"语法糖:[x] + (xChange);信号用 [(model)][(x)]

事件传值:(input)="onInput($event.target.value)";模板局部变量 #input + (keyup)="onEnter(input.value)"

控制流块(v17+,替代 *ngIf / *ngFor / *ngSwitch)

html
<!-- 条件 -->
@if (user; as u) {            <!-- as 别名避免重复读取 -->
  <p>Hello {{ u.name }}</p>
} @else if (guest) {
  <p>游客</p>
} @else {
  <p>未登录</p>
}

<!-- 列表 -->
@for (item of items; track item.id; let i = $index) {
  <li>{{ i }}: {{ item.title }}</li>
} @empty {                    <!-- 空列表兜底,替代 *ngFor 的 ng-template 怪招 -->
  <li>暂无数据</li>
}

<!-- 分支 -->
@switch (status) {
  @case ('loading') { <span>加载中…</span> }
  @default { <span>{{ status }}</span> }
}

track(对应 *ngFor:key)给每行稳定标识,必须提供——否则列表增删会引发整段 DOM 重建;@for 还自带 $index/$first/$last/$even 等上下文变量。

组件间通信:input / output / model(v19+ 信号 API 稳定)

ts
// child.ts
import { Component, input, output, model } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <button (click)="count.update(v => v + 1)">+1</button>
    <button (click)="reset.emit()">重置</button>
  `,
})
export class CounterComponent {
  count = input(0);                  // 父传子:只读信号 input
  min = input(0, { alias: 'minVal' }); // 别名:父用 [minVal]
  countChange = output<number>();    // 子传父:事件发射器(信号化封装)
  reset = output();
  value = model(0);                  // 双向:父可 [(value)]
}

父组件:

html
<app-counter [count]="base" (countChange)="onCount($event)" (reset)="resetAll()" [(value)]="n" />

input() 返回值是只读信号:子组件读 this.count() 即可拿到最新值;output() 在模板里用 (事件名) 订阅;model() 是可双向写回的信号——内部 model.set(...) 会自动向外发 valueChange

生命周期与副作用

钩子时机常见用途
constructor实例创建(无输入)注入服务
ngOnInit首次输入绑定完成首次取数据
ngOnChanges每次输入变化依赖输入的派生逻辑(新代码优先用 computed
ngAfterViewInit视图就绪操作第三方 DOM 库
ngOnDestroy销毁前取消订阅、清理定时器

信号化写法优先:输入派生值用 computed(() => this.x() + 1),模板自动更新;effect(() => ...) 在依赖变化时执行副作用(类似 watchEffect),默认在变更检测期间运行,不要在 effect 里写信号(会死循环/触发额外检测)。

@defer:按需加载的块

html
@defer (on viewport) {              <!-- 滚动进入视口才加载 -->
  <app-heavy-chart />
} @placeholder { <p>图表加载中…</p> }
@loading (minimum 500ms) { <p>仍在加载…</p> }

on viewport / on idle / on timer(2s) / on interaction 等触发条件;块内组件自动被代码分割成独立 chunk——Angular 内建懒加载到模板级

信号与变更检测

为什么需要信号

传统 Angular 用 Zone.js 给浏览器的宏任务、微任务、DOM 事件、XHR 打补丁:任何异步发生都通知 Angular「可能变了」,于是从根组件向下遍历整棵组件树做脏检查(每次点击都全树跑一遍)。配合 OnPush 只能粗粒度跳过子树,本质是「全局广播」。

信号(signal) 把依赖关系变成细粒度图computed 只知道自己读了哪些信号;信号变化时只通知直接依赖者,视图更新只重算受影响的部分——这同时也让 Zoneless(不依赖 Zone.js 打补丁)成为可能:变化由信号/事件显式通知框架,而非全局拦截。

信号核心 API(Node 实测输出)

js
// 编译运行:node(@angular/core@22.1.4,信号 API 与平台无关,Node 可直接跑)
const { signal, computed, untracked } = require('@angular/core')

const count = signal(0)
count.set(2)                  // 赋值:set(新值)
count.update(v => v + 1)      // 基于旧值更新:update(fn)
const arr = signal([1, 2, 3])
arr.update(a => [...a, 4])    // 不可变更新:返回新数组(见「踩坑」mutate)
console.log(arr())            // [1, 2, 3, 4]

const doubled = computed(() => count() * 2)   // 派生值,读时惰性计算
console.log(count(), doubled())               // 3 6
count.set(10)
console.log(count(), doubled())               // 10 20:依赖变化后自动重算

let recompute = 0
const source = signal(1)
const c2 = computed(() => { recompute++; return source() * 2 })
console.log(c2(), recompute)                  // 2 1:首次读取才算
c2()                                          // 缓存命中
source.set(2)                                 // 依赖变了,但还没人读
console.log(recompute)                        // 1:惰性——不读就不算
console.log(c2(), recompute)                  // 4 2:读取时重算一次
source.set(2)                                 // 同值(默认 === 相等)
c2()
console.log(recompute)                        // 2:同值不通知,不触发重算

const flag = signal('a')
let evals = 0
const mix = computed(() => { evals++; return count() + untracked(() => flag().length) })
console.log(mix(), evals)                     // 11 1:10 + 'a'.length = 1
flag.set('abcdef')                            // flag 未被追踪……
console.log(mix(), evals)                     // 11 1:mix 不因 flag 失效(缓存旧值)

三条行为即信号的全部心智模型:

  1. 惰性computed 首次被读才计算,之后缓存;依赖变脏只做标记,下一次读取才重算;
  2. 依赖追踪computed 求值时读取过的信号成为依赖;untracked(() => ...) 读信号但不建立依赖
  3. 同值不通知set 相同值(默认 === 判断,可用 signal(0, { equal }) 自定义)不会使依赖失效——避免无谓重算。

模板中信号直接调用取值:{{ count() }};写 count.set(...)update 后由框架调度更新。

变更检测策略

  • Default:所有组件按需检查(信号驱动下只检查受影响组件);
  • ChangeDetectionStrategy.OnPush:仅当输入引用变化 / 组件内事件 / 信号变化时检查——现代 Angular 默认思想接近它;
  • Zoneless:v18 起 provideZonelessChangeDetection() 实验性启用,v21+ 新项目默认开启、不再引入 Zone.js(迁移老项目时第三方非信号回调可能不再触发检测,见「踩坑」)。手动兜底:ChangeDetectorRef.markForCheck()

与 RxJS 互通

ts
import { toSignal, toObservable } from '@angular/core/rxjs-interop';

const user = toSignal(http.get<User>('/api/user')); // Observable → 信号(模板直接 user())
const obs$ = toObservable(someSignal);              // 信号 → Observable(与 RxJS 流合并)

依赖注入:框架的灵魂

依赖注入(DI)让服务「声明要什么就注入什么」,调用方与实现解耦;这是 Angular 与 React/Vue 最大的架构性差异。

服务与注入器树

ts
// user.service.ts —— providedIn 决定注入器层级
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })   // root:全局单例,随处可注入
export class UserService {
  async fetch(id: number) { /* fetch 用户 */ }
}

注入器构成层级树:根注入器 → 路由/懒加载注入器 → 组件级注入器。可见性规则:下层可注入上层提供的服务,反之不可。若某服务只想在组件及其子树内共享,写进组件 providers

ts
@Component({
  standalone: true,
  providers: [CartService],          // 每个组件实例一份新实例
})
export class ProductCardComponent {}

providedIn: 'root' 与组件 providers 的区别即「全局单例 vs 按组件作用域隔离」——状态共享/隔离的经典坑位(见「踩坑」)。

注入方式:构造器注入与 inject()

ts
import { inject } from '@angular/core';

@Component({ ... })
export class ProductsComponent {
  // 传统:构造函数参数注入
  constructor(private user: UserService) {}
  // 现代(函数式,可在 guard/interceptor 等任意 context 用):
  private http = inject(HttpClient);
}

同一次注入在构造器与 inject()不可混用(Angular 会报 NG0203),选一种即可;inject() 只能在注入上下文(构造、字段初始化、provideXxx 工厂)里调用。

提供者 token 的四种写法与 InjectionToken

ts
import { InjectionToken, inject } from '@angular/core';

// 无类可依时用 InjectionToken 描述依赖
export const API_BASE = new InjectionToken<string>('API base URL');

// 同一个 token 可给出不同提供方式:
providers: [
  UserService,                                     // 等价 useClass:类即 token
  { provide: API_BASE, useValue: '/api/v2' },      // 常量值
  { provide: Logger, useClass: ConsoleLogger },    // 换实现
  { provide: BACKEND, useFactory: () => build(inject(Config)), deps: [] }, // 工厂
  { provide: UserService, useExisting: AccountService }, // 别名
]

类在 @Injectable 时即注册;非类常量必须 InjectionToken@Optional() 允许注入不存在时报 null@Self()/@SkipSelf()/@Host() 限定查找层级。

RxJS 与异步编程

Angular 的异步世界由 Observable 流驱动:HTTP 返回流、表单值是流、路由参数是流、事件是流。RxJS 提供时间上的数组方法——map/filter/reduce 作用于「未来到达的每个值」。

核心操作(rxjs 7.8.2 Node 实测)

js
// 编译运行:node(rxjs 是纯库,无需浏览器)
const { of, interval, map, filter, take, combineLatest, BehaviorSubject } = require('rxjs')

// 变换 + 过滤:of(1..5) 依次发射
of(1, 2, 3, 4, 5).pipe(
  map(x => x * 10),
  filter(x => x >= 30),
).subscribe(x => console.log(x))        // 30 → 40 → 50(每个值一个回调)

// BehaviorSubject:有初始值的「当前值 + 变化流」;combineLatest 合并最新值
const a = new BehaviorSubject(0)
const b = new BehaviorSubject(10)
combineLatest([a, b]).subscribe(([x, y]) => console.log(x + y))
a.next(1)                               // → 11
b.next(20)                              // → 21

interval(1000).pipe(take(1)).subscribe(x => console.log(x)) // 1 秒后输出 0

HttpClient:一次请求就是一个流

ts
// 编译运行:Angular 组件内使用(浏览器环境)
import { HttpClient } from '@angular/common/http';
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';

@Component({
  standalone: true,
  imports: [AsyncPipe],
  template: `
    <div *ngIf="users$ | async as users; else loading">   <!-- 传统写法:pipe + async -->
      @for (user of users; track user.id) { <p>{{ user.name }}</p> }
    </div>
    <ng-template #loading><p>加载中…</p></ng-template>
  `,
})
export class UsersComponent {
  private http = inject(HttpClient);
  // async 管道负责订阅与自动退订(组件销毁时)——手动 subscribe 必须记得退订
  users$ = this.http.get<User[]>('/api/users');
}

习惯差别:RxJS 里不 subscribe 就什么都没发生async 管道 / toSignal 帮你管理订阅与生命周期,优先于在组件里手动 subscribe(手动订阅忘退订 = 内存泄漏)。

流式组合:用操作符编排时序

ts
// 用户切换时自动请求其订单;只取最新一次切换的结果
private userSwitched = new Subject<number>();

orders$ = this.userSwitched.pipe(
  debounceTime(300),                 // 防抖:停止切换 300ms 后
  switchMap(id => this.http.get<Order[]>(`/api/users/${id}/orders`)), // 切换到最新请求,旧请求自动取消
  catchError(() => of([])),          // 错误也要给出流的值,否则 UI 静默
);

RxJS 还是信号?

场景推荐
组件内本地状态 / 派生值 / 输入输出信号(简单、类型友好、无订阅管理)
HTTP 请求、表单值、路由参数、事件合并、防抖/竞态取消RxJS(异步编排能力是信号没有的)
需要两者桥接toSignal / toObservable

信号解决「状态如何渲染」,RxJS 解决「随时间而来的数据如何编排」——现代 Angular 的答案是两个都要,各司其职。

路由

ts
// app.routes.ts —— 声明式路由表,懒加载是默认
import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: '/products' },
  { path: 'products',
    loadComponent: () => import('./products/products.component')
      .then(m => m.ProductsComponent),          // 路由级懒加载
    canActivate: [() => inject(AuthService).loggedIn() || redirect('/login')], // 函数守卫
  },
  { path: 'product/:id', component: ProductComponent },
  { path: '**', component: NotFoundComponent }, // 兜底
];
ts
// main.ts 或 app.config.ts
import { provideRouter } from '@angular/router';

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

模板内用 <router-outlet /> 承载当前路由组件;routerLink="/products"routerLinkActive="active" 导航与高亮;编程式导航 inject(Router).navigate(['/product', id]);路由参数 inject(ActivatedRoute).paramMap(值随导航更新的 Observable)。v19+ 支持 withComponentInputBinding() 让路由参数直接绑定到组件 input

表单

Angular 有两条路线,现代推荐响应式表单(数据模型与模板分离、可测试性最好):

ts
// 编译运行:Angular 表单模块(浏览器环境)
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">
      <input formControlName="name" />
      <p *ngIf="form.controls.name.touched && form.controls.name.invalid">姓名必填</p>
      <button [disabled]="form.invalid">提交</button>
    </form>
  `,
})
export class ProfileComponent {
  private fb = inject(FormBuilder);
  form = this.fb.nonNullable.group({
    name: ['', [Validators.required, Validators.minLength(2)]],
    email: ['', [Validators.required, Validators.email]],
  });

  submit() {
    if (this.form.invalid) return;
    console.log(this.form.getRawValue());   // { name, email }(禁用字段也在 raw 中)
  }
}

响应式表单的模型即对象:FormGroup(对象)/ FormArray(数组)/ FormControl(叶子);校验器是纯函数,可组合、可异步;.valueChanges 是 Observable——表单验证联动、动态依赖都在流上解决。模板驱动表单([(ngModel)] + NgForm)适合简单表单,但校验逻辑散落在模板、难做单元测试。v22 起 Signal FormsformControl()/formGroup() 信号式 API)稳定,进一步对齐信号范式。

工程化与 SSR

bash
ng new app                     # 脚手架:standalone + zoneless 默认
ng serve                       # 开发(热更新)
ng build                       # 产物默认 outputPath dist/;ng deploy 见各平台
ng g component hero            # 生成器:组件/服务/管道/指令/路由守卫
ng test                        # 单元测试(Karma/Jasmine 或配置 Vitest)
ng update @angular/core@22     # 跨 major 自动迁移
  • 测试TestBed 建组件环境(可覆盖注入器、模拟服务),ComponentFixture 驱动;Jasmine/Karma 是传统,Vitest + ng test --builder 是社区主流;
  • SSR/水合:v17+ provideServerRendering;增量水合(v19 稳定)只对交互部分激活客户端;@defer 与路由懒加载天然配合 SSR;
  • 依赖注入贯穿各层:HTTP 拦截器 provideHttpClient(withInterceptors([...]))、错误处理、功能守卫(functional guards)都走 inject() 函数式生态。

踩坑记录

  • signal.mutate() 已移除(Angular 22 实测 count.mutate is not a function):v20 起废弃——就地修改与信号「不可变值」语义冲突且难追踪。数组/对象更新一律 signal.update(v => [...v, x]) 返回新引用;
  • computed 里写副作用、effect 里写信号:computed 必须纯(只读依赖、不写状态);effect 中写另一个信号会触发连锁调度,可能无限循环——写信号放事件回调,effect 只做日志/外部 API 调用;
  • 忘记退订 Observable:手动 subscribe 而不在 ngOnDestroy 退订 = 泄漏。用 async 管道 / takeUntilDestroyed()(v16+)/ toSignal 让框架代管;只有 HttpClient 请求会自动 complete 无需退订;
  • Zone.js → Zoneless 迁移后局部不刷新:第三方库用 setTimeout/addEventListener 内部更新(非信号、非 zone 感知)不再触发检测——用 ChangeDetectorRef.markForCheck() 显式标记,或把这些回调包进 ApplicationRef.tick() 之前升级为信号源;
  • providedIn: 'root' 误用为「每页面新状态」:root 是全局单例,多组件共享会串状态;需要隔离就放进组件 providers。反过来,懒加载路由里若在 providers 重复提供某 root 服务,会得到多个实例(路由注入器隔离)——判断清楚生命周期归属;
  • 新旧两套语法混用*ngIf/*ngFor/*ngSwitch(NgModule 时代指令)与新控制流 @if/@for/@switch 不要在同一模板混搭;新项目全部用 @ 块,迁移工具 ng generate @angular/core:control-flow 可批量转换;
  • track 缺失或不可靠@for 不写 track(或 track 用对象自身而非 id)会导致增删时整个列表重建,且表单输入框在复用节点时会串值——始终 track 稳定唯一键;
  • input 信号以为是普通值input()InputSignal,模板里也要 user() 调用——写成 user.name 会拿到 undefined;派生逻辑放 computed,不要写 ngOnChanges
  • 构造器与 inject() 混用:同组件里两种注入方式并用触发 NG0203。选函数式 inject() 后保持一致;
  • 把 Angular 当 React 写:试图「每个组件自管状态、随手手动 setState」会撞上框架约束。Angular 的正确姿势是服务(DI)+ 信号/流——学习曲线的本质是范式差异,不是 API 差异。

术语速查

术语一句话
Standalone组件自带依赖声明、不依赖 NgModule 的形态(v14+ 预览,v17+ 默认)
NgModule旧的组织单元(声明/导入/导出/提供者),被 standalone 取代,老项目仍常见
模板HTML + 模板语法(插值/绑定/控制流块/事件)
信号(signal)携带响应式值的容器:读取即建立依赖,写入触发更新
computed由信号派生、惰性缓存的计算值
effect依赖变化时执行的副作用回调(类似 watchEffect)
变更检测数据变化后框架更新 DOM 的机制
Zone.js给异步打补丁以触发全局脏检查的库(Zoneless 后不再默认)
OnPush手动声明「仅在输入/事件/信号变化时检查」的策略
依赖注入(DI)声明所需依赖、由注入器树按层级提供实例的机制
注入器树根 → 路由/懒加载 → 组件 的层级服务作用域
InjectionToken为无法用类作 token 的依赖(常量/配置)定义的键
RxJS / Observable惰性推流式异步数据源;操作符在 pipe 中变换
Subject / BehaviorSubject多播事件源 / 带当前值的多播事件源
async 管道模板中订阅并自动退订 Observable 的管道
响应式表单模型与模板分离、可校验可测试的表单体系
懒加载路由 / @defer 组件按需加载的代码分割
SSR / 水合服务端渲染首屏后,客户端增量接管交互

状态与参考

  • 状态:已收录(2026-09-02,由前端领域规划清单「Angular|模块化架构、依赖注入、RxJS 响应式编程」转正式)。
  • 版本与实测:基于 Angular 22(2026-06-03 发布,当前 Active;版本线见 官方 Releases)。信号 API 示例以 @angular/core@22.1.4 + Node v20.20.1 实跑(输出如文中注释,并由此确认 signal.mutate() 已移除);RxJS 示例以 rxjs@7.8.2 实跑;模板/路由/表单示例为 Angular 22 标准用法,可复制进 ng new 生成的最小工程直接运行。
  • 参考Angular 官方文档(Guide / API / Update guide)、Signals 指南RxJS 文档Zoneless 演进
  • 阅读联动:与 React/Vue 的定位差异对照前端领域概览React/Vue 3;TypeScript 基础见语言笔记 TypeScript

下一步

  • [ ] 补充 Angular 官方 Guide 的分篇镜像(类似 Vue 3 栏目),把模板、DI、表单、路由逐步展开为独立子页
  • [ ] 写一个「服务 + 信号 + HttpClient + 路由」的完整迷你应用示例并跑通测试
  • [ ] 对照 React 补一篇三框架心智模型对比,落地选型决策表

写作规范请参阅领域概览

基于 VitePress 构建 · 内容以知识共享方式沉淀