Agent Developer Roadmap
前端框架

Angular:Signals、依赖注入与变更检测边界

最后核验:2026-06-30|适用版本:Angular 20–22(示例锁定 22.0.4)|前置知识:TypeScript、RxJS、Browser APIs

Angular 解决的工程约束

Angular 不只是组件渲染库。它同时规定组件、模板、依赖注入、路由、表单、构建和测试的组合方式,适合需要长期统一约束、多团队协作和可预测升级路径的应用。固定成本是 CLI、编译器、依赖注入层级和较大的概念面;单页少量增强通常用原生平台或更小的客户端框架即可。

本篇把 Angular 核心与 RxJS 分开:Signals 表达同步状态图,RxJS 表达随时间到达且需要组合或取消的事件流。把所有值都包成 Observable,或反过来把所有异步流都压进 Signal,都会丢失原模型擅长的语义。

独立组件与模板编译

Angular 22 以 standalone component(独立组件)为默认组织方式。@Component 元数据把选择器、模板和依赖交给 Angular 编译器;模板不是运行时字符串拼接,而是经过类型检查并转换成创建、更新指令。strictTemplates 会使用组件类型检查属性、事件和控制流。

一个组件至少包含 TypeScript 类、模板和选择器。依赖的组件、指令和管道必须进入 imports;这不是文件顶部 import 的重复,而是告诉模板编译器“哪些模板能力在当前作用域可用”。组件之间通过输入接收数据、输出上报事件,子组件不应反向修改父组件持有的业务对象。

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

interface Entry {
  id: string;
  title: string;
  tags: readonly string[];
}

@Component({
  selector: 'roadmap-entry',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <article [class.selected]="selected()">
      <h3>{{ entry().title }}</h3>
      <p>{{ tagText() }}</p>
      <button
        type="button"
        [attr.aria-pressed]="selected()"
        (click)="selectedChange.emit(entry().id)"
      >切换选择</button>
    </article>
  `,
})
export class RoadmapEntry {
  readonly entry = input.required<Entry>();
  readonly selected = input(false);
  readonly selectedChange = output<string>();
  readonly tagText = computed(() => this.entry().tags.join(' · '));
}

父模板用 [entry]="item" 做属性绑定,用 (selectedChange)="toggle($event)" 监听输出。[attr.aria-pressed] 绑定 HTML attribute,[class.selected] 绑定类;它们和直接写字符串 attribute 不同。input() 由编译器静态识别并返回只读 InputSignaloutput() 返回可发射的 OutputEmitterRef。只有控件确实拥有可写值语义时才使用 model()[(value)] 双向绑定;普通数据流保持输入向下、事件向上,更容易跟踪写入来源。输入属性输出 API

模板引用变量(#searchInput)只在模板视图范围有效;内容投影 <ng-content> 让调用者提供结构;<ng-template> 表示尚未实例化的模板片段。它们解决的是视图组合,不应被拿来绕过明确的输入输出契约。直接通过 ElementRef.nativeElement 改 DOM 还会绕过渲染器、SSR 和水合假设,优先使用绑定、指令与渲染钩子。

模板中的 @if@for 是编译期控制流。列表必须提供稳定 track 键,否则重排时 DOM、焦点和局部控件状态可能错配。模板表达式只负责读取和派发动作,网络请求与复杂状态转换留在 TypeScript。

AOT 编译究竟生成什么

Angular 的 Ahead-of-Time(AOT)编译不只是把装饰器删掉。编译器读取 TypeScript 与组件元数据,解析模板,建立模板作用域,为绑定生成类型检查块(Type Check Block),再产生创建视图、更新绑定和销毁资源所需的指令。生产构建随后做打包、代码分割、压缩和 tree shaking。

首次渲染执行创建阶段:建立元素、文本、指令实例和监听器。后续变更检测主要执行更新阶段:重新计算已标记视图中的绑定,与上次值比较,仅在值改变时写 DOM。Angular 因此不是每轮重新拼接整棵 HTML,也不是通用 Virtual DOM diff。性能问题通常来自“过多视图被安排检查”“绑定本身昂贵”“列表身份不稳定”或“DOM/布局成本高”,不能只看组件数量。

@if@switch@for 会编译为嵌入视图的创建与切换逻辑。@for (entry of entries(); track entry.id)track 表达式决定旧视图和新数据如何对应;用数组索引跟踪可追加日志尚可,用于插入、排序和过滤则会把错误的输入框值、焦点和子组件状态沿位置复用。@empty 可以和循环共享空态。@defer 进一步建立代码分割和触发边界,但触发器应与真实交互或视口需求一致,不能把首屏必需内容一律延迟。

依赖注入的查找与生命周期

依赖注入(Dependency Injection,DI)把“组件需要什么”与“实例如何创建”分开。inject(Token) 从当前 injector 向父级查找 provider;providedIn: 'root' 通常得到应用级单例,组件级 providers 则为该组件子树建立新实例。

@Injectable({ providedIn: 'root' })
export class CatalogService {
  async load(signal: AbortSignal) {
    signal.throwIfAborted();
    return parseTechnologyEntries(catalog as unknown);
  }
}

DI 不是隐藏全局变量的许可证。只有需要替换实现、共享生命周期或依赖 Angular 上下文的服务才进入 injector;纯搜索与 URL 序列化继续使用普通函数,测试无需搭建 Angular TestBed。

Angular 的层级注入至少要区分两条链:EnvironmentInjector 管理应用、路由与环境 provider,元素 injector 随组件/指令树建立局部 provider。解析依赖时先从请求位置的元素层级向上,再进入对应环境层级;组件 providers 会为该组件及其视图后代建立实例边界。因此,同一个 token 在根、路由和组件提供时可能得到不同实例,这既能隔离编辑器会话,也可能意外复制缓存和 WebSocket。

export interface ApiConfig {
  baseUrl: string;
  timeoutMs: number;
}

export const API_CONFIG = new InjectionToken<ApiConfig>('API_CONFIG');

bootstrapApplication(AppComponent, {
  providers: [
    { provide: API_CONFIG, useValue: { baseUrl: '/api', timeoutMs: 8_000 } },
    { provide: CatalogPort, useClass: HttpCatalogService },
    { provide: LegacyCatalog, useExisting: CatalogPort },
    {
      provide: SEARCH_CLIENT,
      useFactory: () => new SearchClient(inject(API_CONFIG)),
    },
  ],
});

useClass 选择实现,useValue 提供配置,useFactory 负责需要其他依赖的构造,useExisting 为同一实例建立别名;多 provider 则把多个值收集成数组。接口在 JavaScript 运行时不存在,不能直接作为 token,需要抽象类或 InjectionToken<T>inject() 只能在注入上下文中调用,例如字段初始化器、构造期间、provider factory 或 runInInjectionContext 回调;普通异步回调执行时上下文早已离开。

服务生命周期取决于提供它的 injector。清理定时器、订阅和浏览器监听器时使用 DestroyRef.onDestroy,不要假设所有 service 都活到页面关闭。请求级 SSR 尤其不能把用户身份或请求数据放进跨请求共享的全局单例;是否隔离必须由服务端 bootstrap 与 injector 边界证明。

Signals 与同步状态图

signal() 保存可写源状态,computed() 声明纯派生值,effect() 在依赖变化后同步外部系统。读取 Signal 时 Angular 记录依赖;写入后只让相关消费者失效。派生结果不另存一份,因此不会产生“查询变了但结果未同步”的双真值。

readonly entries = signal<TechnologyEntry[]>([]);
readonly query = signal('');
readonly filtered = computed(() => search(this.entries(), this.query()));

constructor() {
  effect(() => history.replaceState(
    null,
    '',
    writeUrlState({ query: this.query(), selectedIds: this.selectedIds() }),
  ));
}

effect() 不应承担可由 computed() 完成的派生,也不应在依赖图里无条件写回源 Signal,否则会造成额外轮次或循环。只有 History、存储、日志、第三方控件等 Angular 外部系统需要 Effect。

Signals 要理解的是动态依赖图,“getter 长得像函数”只是表面形式。执行 computedeffect 时,只有本次实际读取的 Signal 成为依赖;条件分支切换后,旧分支依赖会被移除。写入 Signal 先让消费者失效,computed 在下一次读取时惰性重算并缓存结果。对象仍按引用比较:原地 entries().push(x) 不会经过 set/update,也破坏只读推理,应生成新数组。

readonly mode = signal<'all' | 'selected'>('all');
readonly entries = signal<readonly Entry[]>([]);
readonly selectedIds = signal<ReadonlySet<string>>(new Set());

readonly visible = computed(() => {
  const all = this.entries();
  if (this.mode() === 'all') return all;
  const selected = this.selectedIds();
  return all.filter((entry) => selected.has(entry.id));
});

toggle(id: string) {
  this.selectedIds.update((previous) => {
    const next = new Set(previous);
    next.has(id) ? next.delete(id) : next.add(id);
    return next;
  });
}

这里 mode === 'all' 时不会读取 selectedIds,选择变化也无需让 visible 失效。untracked 可用于 Effect 中不应建立依赖的偶然读取,但不能用于隐藏错误依赖。Effect 会在销毁上下文结束时自动清理;手工订阅外部 API 时仍要在 effect 回调返回的清理函数或 DestroyRef 中解除。不要用 Effect 在两个 Signal 间搬运状态,这会引入中间不一致和循环;能表达为 computed 就只保留一个真值源。Signals 指南

RxJS 与异步生命周期

RxJS Observable 可以表达多值、取消、背压式组合与时间操作;HTTP 一次响应也可用 Promise。选择依据是数据生命周期,不是团队习惯。本仓库的静态目录只读取一次,因此服务返回 Promise;组件销毁时使用 AbortController 取消。实时搜索建议使用 switchMap 取消旧请求,并在模板用 AsyncPipe 或在销毁上下文中解绑。

问题SignalRxJS普通值/Promise
同步本地状态首选能做但偏重需要手工通知
纯派生computedmap普通函数
多次异步事件有限首选不适合
一次加载可保存结果可用Promise 最小
取消旧搜索配合请求控制器switchMap手工 AbortSignal

不要在同一字段上维护 Signal 和 BehaviorSubject 两份可写状态。若必须跨边界转换,明确唯一写入方,并使用 Angular 提供的互操作工具。

Observable 默认是惰性的:没有订阅者,管道通常不执行。冷 Observable 可为每次订阅重新发请求或计算;热源(DOM 事件、Subject、共享连接)则独立产生值。subscribe() 不等于“调用异步函数”,它建立一段需要处理完成、错误和取消的生命周期。模板展示优先 AsyncPipe,命令式副作用使用 takeUntilDestroyed();不要留下裸订阅。

readonly query = new FormControl('', { nonNullable: true });

readonly results$ = this.query.valueChanges.pipe(
  debounceTime(200),
  distinctUntilChanged(),
  switchMap((query) =>
    this.http.get<readonly Entry[]>('/api/search', { params: { query } }).pipe(
      catchError((error) => {
        this.report(error);
        return of([] as readonly Entry[]);
      }),
    ),
  ),
  shareReplay({ bufferSize: 1, refCount: true }),
);

switchMap 在新查询到达时取消旧内部订阅,适合“只要最后一次”;保存操作通常不能被新点击悄悄取消,可考虑 concatMap 排队或 exhaustMap 忽略重复提交。shareReplay 会改变冷/热和缓存生命周期,必须明确错误、完成以及最后订阅离开后是否断开。toSignal(observable, { initialValue }) 适合把 Observable 结果交给同步模板读取,toObservable(signal) 适合进入 RxJS 时间操作;转换点只设一次,避免来回桥接形成难以定位的调度链。

表单、路由与应用边界

Angular 表单有模板驱动和响应式两套模型。复杂业务表单应优先响应式表单:控件树、校验器、禁用状态和值变更都在 TypeScript 中显式存在,适合动态字段与单元测试。FormControlvalue 不包含被禁用控件,提交完整结构时需辨别 form.valueform.getRawValue()

readonly form = new FormGroup({
  title: new FormControl('', {
    nonNullable: true,
    validators: [Validators.required, Validators.maxLength(80)],
  }),
  level: new FormControl<'beginner' | 'advanced'>('beginner', {
    nonNullable: true,
  }),
  tags: new FormArray<FormControl<string>>([]),
});

submit() {
  this.form.markAllAsTouched();
  if (this.form.invalid) return;
  return this.catalog.save(this.form.getRawValue());
}

浏览器约束和 Angular validator 都只是即时反馈,服务端仍要重新验证。异步 validator 应完成并能取消,不能每次按键留下并发请求。自定义控件若要接入表单,应实现明确的值、触碰、禁用契约,或使用当前推荐的表单 API;不要同时监听原生输入、组件输出和 valueChanges 三路写入同一值。

Router 把 URL 映射到组件树,并提供嵌套路由、参数、resolver、guard 和懒加载:

export const routes: Routes = [
  {
    path: 'entries',
    loadComponent: () => import('./entries.page').then((m) => m.EntriesPage),
  },
  {
    path: 'entries/:id',
    resolve: { entry: entryResolver },
    canActivate: [signedInGuard],
    loadComponent: () => import('./entry.page').then((m) => m.EntryPage),
  },
  { path: '', pathMatch: 'full', redirectTo: 'entries' },
];

路由参数、查询参数和导航状态是不同来源;可分享状态应进入 URL,短暂 UI 状态才留在内存。resolver 会阻塞导航,适合页面不可缺少的小数据,不适合把整个仪表盘串行化。guard 运行在客户端时只能控制导航体验,攻击者可绕过它直接请求 API,因此权限必须由服务端执行。懒加载减少初始代码,但共享依赖可能仍被抽入公共 chunk,应以构建产物和真实路由瀑布验证。

变更检测与 zoneless

变更检测执行模板更新指令,把组件状态投影到 DOM。OnPush 组件在输入变化、事件、显式标记或模板读取的 Signal 失效时进入检查;它不是“永不检查”。直接修改对象内部字段但保持输入引用不变,可能让子组件无法观察变化,应更新引用或把状态建模为 Signal。

Angular 22 支持 zoneless(无 Zone.js)应用。它依赖 Angular 已知的通知来源,而非拦截所有异步 API。迁移前先清除依赖隐式全局检查的代码,再启用 zoneless 并跑完整交互测试;手工到处调用 detectChanges() 只是把隐式耦合换成显式补丁。Zoneless 指南

一次更新可拆成四步:事件或异步结果写状态;Angular 通过模板监听器、模板读取的 Signal、AsyncPipe/markForCheckComponentRef.setInput 等来源得知视图变脏;调度器合并同一轮通知;变更检测执行相关视图的更新指令并提交 DOM。OnPush 缩小的是可被遍历的范围,不会让昂贵绑定自动变快。模板调用 filterEntries() 会在每次检查重新执行,稳定纯派生应改为 computed 或纯 pipe。

常见陈旧视图并非“Angular 没检测到对象变化”这么笼统:

现象根因修复
定时器修改普通字段后 zoneless 页面不更新写入不是 Angular 通知来源用模板读取的 Signal,或在边界 markForCheck
OnPush 子组件收到同一对象引用父级原地修改输入创建新引用或把共享状态建模为 Signal
测试靠 fixture.detectChanges() 才通过测试强制刷新,掩盖生产通知缺失触发真实通知并等待 whenStable()
ExpressionChangedAfterItHasBeenCheckedError同一检查周期后半段又改了已检查绑定修正数据流和生命周期,不用 setTimeout 掩盖

在 zoneless SSR 中,自定义异步任务若影响序列化 HTML,必须通过 PendingTasks.run()pendingUntilEvent() 告诉 Angular;否则服务端可能在数据到达前输出。等待 DOM 提交用 afterNextRender/afterEveryRender,不是依赖不会再发出的 NgZone.onStableZoneless 通知与 SSR

SSR、水合与渐进激活

服务端渲染先在服务器运行组件和路由,输出可见 HTML;客户端水合复用这些节点、连接事件和恢复框架状态。服务端 HTML 与客户端第一次渲染的结构必须一致。浏览器自动插入 <tbody>、服务端和客户端使用不同 locale/timezone、随机 ID、在构造期读 window、第三方脚本抢先改 DOM,都可能造成水合不匹配。

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

事件重放会捕获水合完成前发生的点击、聚焦等原生事件,待监听器连接后重放,避免“页面看得见却点不动”。它不是重复提交的免责机制:写操作仍应幂等或有请求去重。TransferState 可把服务端已获取的公开数据交给客户端,避免首屏重复请求,但序列化内容会出现在响应 HTML 中,绝不能放 token、密钥或跨用户缓存数据。

渐进水合把 @defer 视图留在未激活状态,按 hydrate on viewportinteractionidle 等触发下载和激活,并自动依赖事件重放。它能减少首屏 JavaScript,却增加边界、状态一致性和测试范围;只用于重组件与非即时交互区域。ngSkipHydration 是不兼容 DOM 库的临时逃生口,会让子树重新渲染,不应当作普遍修复。HydrationIncremental Hydration

性能优化:先定位瓶颈,再缩短关键路径

优化顺序应从用户指标和 profile 开始,而不是统一加 OnPush

  1. 用 Angular DevTools Profiler 找频繁检查的组件,用浏览器 Performance 区分脚本、样式、布局和绘制。
  2. 稳定 @for track 身份;把昂贵纯派生移到 computed/纯 pipe;虚拟化真正的大列表。
  3. 用路由懒加载和 @defer 缩小初始 chunk,但保证首屏核心内容不被推迟。
  4. 避免重复 HTTP、重复 Observable 订阅和超大 TransferState;验证缓存失效规则。
  5. 图片声明尺寸并采用合适加载优先级,减少布局偏移;第三方脚本延后并测量主线程占用。

Signals 提高更新定位精度,但 DOM 数量、布局抖动、图片字节和长任务仍属于浏览器成本。任何“优化”都要保留对照测量,并覆盖键盘焦点、水合、慢网和低端 CPU;只看本机平均帧率不足以证明改进。

知识目录示例的运行证据

Angular 示例使用根级 CatalogService 读取并验证共享目录,组件 Signals 保存查询、选择和详情,computed 生成筛选与对比条目,Effect 只同步 URL。

运行效果Angular 知识目录运行效果
新窗口打开

正在加载示例……

fnm exec --using=24.15.0 pnpm --filter @roadmap/angular-slice test
fnm exec --using=24.15.0 pnpm --filter @roadmap/angular-slice preview --port 4203

输入:访问 http://127.0.0.1:4203/,搜索 signal,选择四项后尝试第五项,再刷新页面。

预期结果:Vitest 和生产构建通过;目录只显示匹配项,第五项被拒绝且已有选择保留,刷新后从 URL 恢复查询与选择。Angular 22 要求 Node.js 22.22.3、24.15.0 或 26.0.0 以上的对应支持范围。版本兼容表

Angular DevTools 与故障定位

Angular DevTools 用组件树检查输入、Signal 与变更检测,用 Profiler 找重复检查;浏览器 Performance 和 Network 仍是主线程、布局与请求事实来源。模板错误先看编译诊断,DI 错误从缺失 token 和 injector 层级反向查找,异步陈旧结果检查取消和订阅清理。

安全上,插值和属性绑定默认按上下文转义或净化;bypassSecurityTrust* 会绕过保护,只能用于经过独立可信验证的固定内容。权限判断必须在服务端执行,客户端路由守卫只改善导航体验。Angular Security

重大重构、SSR 严重漏洞与 Angular 20 到 22 升级

Angular 近三个 major 的主线不是新增装饰器,而是把变更通知从全局 Zone.js 猜测迁到显式 Signals/Angular API,并继续收敛 standalone、内建控制流、SSR 与 hydration。Angular 20 提供稳定 provideZonelessChangeDetection,21 起 zoneless 成为默认;升级后依赖 NgZone.onStableonMicrotaskEmpty 或“任意异步都会刷新模板”的代码会静默陈旧。修正方式是让模板读取 Signal、使用 AsyncPipe/markForCheck,等待渲染则使用 afterNextRender;SSR 的自定义异步任务用 PendingTasks 声明。Zoneless 指南

高风险节点影响修复与验证
Zone.js → 默认 zoneless未发出 Angular 通知的异步写入不再触发视图更新删除隐式 Zone 假设;组件、表单、SSR 稳定性和第三方库逐项测试
NgModule → standalone/函数式 providerprovider 作用域或重复实例可能改变用 injector tree 检查实例来源与销毁,避免把请求状态放 root singleton
Observable → Signal 互操作重复订阅、同步/异步首值和清理语义混淆toSignal/toObservable 只放明确边界,验证 DestroyRef 清理
SSR/hydration 增强服务端请求、事件重放和客户端初值跨越信任边界比较服务端 HTML、TransferState 与客户端首次值,禁止密钥进入序列化

Angular SSR 在 2025–2026 有多次高影响安全修复。CVE-2025-62427 是 URL 解析中的高危 SSRF;2026-02 的 CVE-2026-27739(GHSA-x288-3778-4hhx,CVSS 9.2)进一步指出 SSR 请求流水线信任 Host/X-Forwarded-*,可能把相对 HttpClient 请求导向攻击者或内部服务。后者影响 19.2.21 前、20.3.17 前、21.1.5 前等版本;旧 @nguniversal 线没有修复版本。应升级到受支持 major 的最新 patch,并在入口代理和应用同时限制可信 host/forwarded headers;不要用请求头拼 API base URL。Angular SSR 安全公告CVE-2025-62427

安全回归至少发送协议相对路径、反斜杠、伪造 Host/X-Forwarded-Host/Port,确认 SSR 不访问外部或云元数据地址;同时确认绝对 API origin 来自可信配置。只在 CDN 写规则不是完整修复,因为自托管、预览和旁路入口可能不经过同一代理。

Angular 采用半年 major 与迁移工具。Angular 20–22 持续推进 Signals、独立 API、zoneless 与新的构建基础;升级应使用 ng update,逐个 major 处理弃用,并同时核对 Node、TypeScript 与 RxJS 范围。Angular 22 于 2026-06-03 进入 Active 支持,21 和 20 处于 LTS。发布策略

验收标准:能说明 injector 作用域、Signal 依赖图、RxJS 生命周期和变更检测通知来源;能从编译、DI、响应式和浏览器四层定位故障,而不是把所有问题归因于 Zone.js。

Angular 官方资料

在 GitHub 上编辑此页

本页目录