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() 由编译器静态识别并返回只读 InputSignal,output() 返回可发射的 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 长得像函数”只是表面形式。执行 computed 或 effect 时,只有本次实际读取的 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 或在销毁上下文中解绑。
| 问题 | Signal | RxJS | 普通值/Promise |
|---|---|---|---|
| 同步本地状态 | 首选 | 能做但偏重 | 需要手工通知 |
| 纯派生 | computed | map | 普通函数 |
| 多次异步事件 | 有限 | 首选 | 不适合 |
| 一次加载 | 可保存结果 | 可用 | 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 中显式存在,适合动态字段与单元测试。FormControl 的 value 不包含被禁用控件,提交完整结构时需辨别 form.value 与 form.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/markForCheck、ComponentRef.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.onStable。Zoneless 通知与 SSR
SSR、水合与渐进激活
服务端渲染先在服务器运行组件和路由,输出可见 HTML;客户端水合复用这些节点、连接事件和恢复框架状态。服务端 HTML 与客户端第一次渲染的结构必须一致。浏览器自动插入 <tbody>、服务端和客户端使用不同 locale/timezone、随机 ID、在构造期读 window、第三方脚本抢先改 DOM,都可能造成水合不匹配。
bootstrapApplication(AppComponent, {
providers: [provideClientHydration(withEventReplay())],
});事件重放会捕获水合完成前发生的点击、聚焦等原生事件,待监听器连接后重放,避免“页面看得见却点不动”。它不是重复提交的免责机制:写操作仍应幂等或有请求去重。TransferState 可把服务端已获取的公开数据交给客户端,避免首屏重复请求,但序列化内容会出现在响应 HTML 中,绝不能放 token、密钥或跨用户缓存数据。
渐进水合把 @defer 视图留在未激活状态,按 hydrate on viewport、interaction、idle 等触发下载和激活,并自动依赖事件重放。它能减少首屏 JavaScript,却增加边界、状态一致性和测试范围;只用于重组件与非即时交互区域。ngSkipHydration 是不兼容 DOM 库的临时逃生口,会让子树重新渲染,不应当作普遍修复。Hydration、Incremental Hydration
性能优化:先定位瓶颈,再缩短关键路径
优化顺序应从用户指标和 profile 开始,而不是统一加 OnPush:
- 用 Angular DevTools Profiler 找频繁检查的组件,用浏览器 Performance 区分脚本、样式、布局和绘制。
- 稳定
@for track身份;把昂贵纯派生移到computed/纯 pipe;虚拟化真正的大列表。 - 用路由懒加载和
@defer缩小初始 chunk,但保证首屏核心内容不被推迟。 - 避免重复 HTTP、重复 Observable 订阅和超大 TransferState;验证缓存失效规则。
- 图片声明尺寸并采用合适加载优先级,减少布局偏移;第三方脚本延后并测量主线程占用。
Signals 提高更新定位精度,但 DOM 数量、布局抖动、图片字节和长任务仍属于浏览器成本。任何“优化”都要保留对照测量,并覆盖键盘焦点、水合、慢网和低端 CPU;只看本机平均帧率不足以证明改进。
知识目录示例的运行证据
Angular 示例使用根级 CatalogService 读取并验证共享目录,组件 Signals 保存查询、选择和详情,computed 生成筛选与对比条目,Effect 只同步 URL。
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.onStable、onMicrotaskEmpty 或“任意异步都会刷新模板”的代码会静默陈旧。修正方式是让模板读取 Signal、使用 AsyncPipe/markForCheck,等待渲染则使用 afterNextRender;SSR 的自定义异步任务用 PendingTasks 声明。Zoneless 指南
| 高风险节点 | 影响 | 修复与验证 |
|---|---|---|
| Zone.js → 默认 zoneless | 未发出 Angular 通知的异步写入不再触发视图更新 | 删除隐式 Zone 假设;组件、表单、SSR 稳定性和第三方库逐项测试 |
| NgModule → standalone/函数式 provider | provider 作用域或重复实例可能改变 | 用 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 官方资料
- Angular Components:独立组件、模板与输入输出;核验于 2026-06-30。
- Angular Dependency Injection:provider、injector 与层级;核验于 2026-06-30。
- Angular Signals:Signal、computed、effect 与模板追踪;核验于 2026-06-30。
- Angular Version Compatibility:Node、TypeScript、RxJS 与浏览器支持范围;核验于 2026-06-30。