Angular 架构、响应式与资深面试
从 standalone 组件和依赖注入深入到变更检测、Signals、RxJS、分层架构、SSR 与性能诊断。
Angular 提供组件、路由、表单、HTTP 和依赖注入等完整能力,适合需要统一工程约束和长期演进的大型应用。
Standalone 组件
现代 Angular 组件可以直接声明依赖,不必先放入 NgModule:
import { Component, signal } from '@angular/core'
@Component({
selector: 'app-counter',
standalone: true,
template: `
<button type="button" (click)="increment()">
{{ count() }}
</button>
`,
})
export class CounterComponent {
readonly count = signal(0)
increment() {
this.count.update((value) => value + 1)
}
}
组件由 TypeScript 类、HTML 模板和可选样式组成。模板只负责声明视图,复杂业务规则应放在类、服务或领域层。
模板绑定
{{ value }}:插值,输出文本。[disabled]="loading":属性绑定。(click)="save()":事件绑定。[(ngModel)]="name":双向绑定,需要导入 FormsModule。
<article [class.is-selected]="selected">
<h2>{{ article.title }}</h2>
<button type="button" [disabled]="loading" (click)="select(article.id)">
选择
</button>
</article>
模板表达式应保持轻量。不要在模板中反复调用昂贵方法,因为变更检测可能多次求值。
Signals 与派生状态
Signal 保存响应式值,computed 表达派生状态:
import { computed, signal } from '@angular/core'
readonly articles = signal<Article[]>([])
readonly query = signal('')
readonly visibleArticles = computed(() => {
const normalized = this.query().trim().toLowerCase()
return this.articles().filter((article) =>
article.title.toLowerCase().includes(normalized)
)
})
使用 set 替换值,使用 update 根据旧值计算新值。不要直接修改 Signal 内的数组再期待所有消费者正确更新。
this.articles.update((articles) => [newArticle, ...articles])
effect 用于与外部系统同步,不用于生成另一个派生状态;派生值应优先使用 computed。
输入与输出
父组件通过输入传值,子组件通过输出事件表达动作:
import { Component, input, output } from '@angular/core'
@Component({
selector: 'app-article-card',
standalone: true,
template: `
<h2>{{ article().title }}</h2>
<button type="button" (click)="selected.emit(article().id)">
阅读
</button>
`,
})
export class ArticleCardComponent {
readonly article = input.required<Article>()
readonly selected = output<string>()
}
输入是只读契约。子组件不应修改父组件拥有的数据,而应发出带有业务含义的事件。
依赖注入
服务封装跨组件共享的业务能力,通过注入获得依赖:
import { Injectable, inject } from '@angular/core'
import { HttpClient } from '@angular/common/http'
@Injectable({ providedIn: 'root' })
export class ArticleService {
private readonly http = inject(HttpClient)
loadAll() {
return this.http.get<Article[]>('/api/articles')
}
}
依赖注入让组件依赖显式且易于替换测试。服务并不等于全局可变状态;只在确实需要共享生命周期时才把状态放进根级服务。
RxJS 与异步数据
Angular HTTP 返回 Observable。模板展示流数据时优先使用 async pipe,它会自动订阅和清理:
readonly articles$ = this.articleService.loadAll().pipe(
catchError((error) => {
this.errorMessage.set('文章加载失败')
return of([])
})
)
@if (articles$ | async; as articles) {
<app-article-list [articles]="articles" />
} @else {
<p>加载中...</p>
}
手动订阅时必须明确生命周期。可使用 takeUntilDestroyed() 让订阅随注入上下文销毁。
路由与延迟加载
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./home/home.component').then((module) => module.HomeComponent),
},
{
path: 'articles/:id',
loadComponent: () =>
import('./article/article.component').then((module) => module.ArticleComponent),
},
]
路由级动态导入可以减少初始 JavaScript。鉴权、数据预取和离开确认分别使用 guard、resolver 等明确边界,但不要把所有业务逻辑塞进路由守卫。
表单选择
- 模板驱动表单适合字段少、验证简单的页面。
- Reactive Forms 适合动态字段、组合验证和复杂交互。
readonly form = new FormGroup({
title: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(80)],
}),
})
错误提示应由“字段已交互”和“具体错误类型”共同决定,避免页面一打开就显示全部错误。
性能原则
- 列表提供稳定追踪键,避免不必要的 DOM 重建。
- 大页面按路由或功能延迟加载。
- 派生状态使用
computed,避免在模板中反复执行过滤和排序。 - 使用 Angular DevTools 检查变更检测和组件更新。
- 服务保持职责清晰,避免形成承载所有状态的巨型单例。
变更检测与视图更新
Angular 视图更新包含“什么时机检查”和“检查时读取什么”两个问题。传统 Zone.js 集成会在异步任务完成后通知 Angular 安排变更检测;模板绑定在检查期间求值,并把变化写入 DOM。
默认策略会检查应用中可达的视图。OnPush 让组件子树只在特定条件下重新进入检查,例如:
- 输入引用通过模板绑定发生变化。
- 组件或后代处理事件。
- 模板读取的 Signal 更新。
asyncpipe 收到新值并标记视图。- 代码显式调用变更检测 API。
@Component({
selector: 'app-article-list',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `...`,
})
export class ArticleListComponent {}
OnPush 不是“只检查一次”,也不意味着所有输入都必须深冻结。它依赖可观察的更新信号,因此不可变更新能让引用变化清晰,也避免父子共享对象被原地修改后难以判断更新来源。
Signals 把依赖粒度推进到模板实际读取的信号。读取 Signal 时 Angular 记录消费者,更新时标记相关视图需要刷新。Zone 和 Signals 解决的维度不同:前者帮助发现异步边界,后者表达响应式依赖。
Signal 图、computed 与 effect
Signal 写入会使依赖的 computed 缓存失效,并通知消费者。computed 惰性求值且动态追踪本次执行读取的依赖:
readonly showArchived = signal(false)
readonly active = signal<Article[]>([])
readonly archived = signal<Article[]>([])
readonly visible = computed(() =>
this.showArchived() ? this.archived() : this.active()
)
当 showArchived 为 false 时,当前计算不会依赖 archived。后续分支改变时,依赖图会重新建立。
effect 异步响应所读 Signal,适合日志、持久化或命令式第三方 API。以下行为要谨慎:
- 用 effect 把一个 Signal 值复制到另一个 Signal,容易形成重复状态或循环。
- effect 需要清理定时器、订阅或连接时注册 cleanup。
- 构造器之外创建 effect 要确保拥有正确注入上下文和销毁生命周期。
- 不需要追踪的读取可用
untracked,但不能借此隐藏真实依赖。
派生状态用 computed,状态转换在事件或明确命令中完成,外部同步才用 effect。
依赖注入层级与 provider 作用域
Angular DI 是层级化的,不是单一全局容器。常见 provider 位置决定实例生命周期:
providedIn: 'root'通常是应用级单例,并支持未使用服务的 tree shaking。- 路由 providers 可让实例随路由环境存在,适合功能域状态。
- 组件 providers 为该组件子树创建实例,组件重建时状态也重建。
export const ARTICLE_API = new InjectionToken<string>('ARTICLE_API')
bootstrapApplication(AppComponent, {
providers: [
{ provide: ARTICLE_API, useValue: '/api/articles' },
],
})
InjectionToken 用于接口、配置和其他没有运行时类值的依赖。provider 可使用 useClass、useValue、useFactory 或 useExisting。测试替换依赖时,替换 token 比在组件里直接 new 具体服务更容易。
DI 解决对象创建、组合与生命周期,不自动保证架构合理。若根级服务同时持有 UI 临时状态、网络、缓存和领域规则,它仍然是难以测试和拆分的全局对象。
RxJS 流与高阶映射
Observable 可以产生零到多个值,具备完成与取消语义;Promise 只代表单个未来结果。HTTP 请求通常是冷 Observable:每次订阅都会触发请求,除非显式共享。
搜索场景常用:
readonly results$ = this.queryControl.valueChanges.pipe(
debounceTime(250),
distinctUntilChanged(),
switchMap((query) =>
this.articleService.search(query).pipe(
catchError(() => of([]))
)
),
)
四个高阶映射操作符体现不同并发策略:
switchMap:新输入取消旧内部订阅,适合搜索与路由参数请求。concatMap:按顺序排队,适合必须保持顺序的写入。mergeMap:并发执行,可配置并发数,适合互不依赖任务。exhaustMap:运行期间忽略新输入,适合防止重复提交。
选择依据是业务并发语义,不是个人偏好。取消 HTTP 订阅通常会中止底层请求,但服务端可能已经执行写操作,因此创建订单等操作仍需幂等键。
shareReplay 可共享并重放结果,但要明确 bufferSize、引用计数、错误与长期缓存生命周期。无上限共享流可能保留资源或把陈旧结果永久当作真相。
Signal 与 Observable 的边界
Signal 适合当前同步值和模板派生;Observable 适合随时间发生的多值事件流、组合异步与取消。它们不是互相替代关系。
Angular 提供互操作工具把 Observable 转为 Signal,或把 Signal 暴露为 Observable。转换时要明确:
- Signal 必须随时可同步读取,因此需要初始值或确认源会同步发值。
- Observable 的错误与完成语义转换后放在哪里处理。
- 转换创建的订阅由哪个注入上下文销毁。
- 不要在 getter 或模板中反复创建转换,避免重复订阅。
组件展示单个流可直接用 async pipe;多个字段共同派生时,根据团队模型选择在 RxJS 管道或 Signal 图中集中计算,避免同一状态两边来回复制。
表单建模与类型边界
Reactive Forms 不只是把输入放进 FormGroup,还要区分原始输入、有效表单值和提交 DTO。用户正在输入时,数值框也可能处于空字符串或不完整状态;不要过早强转为领域模型。
跨字段验证器应返回稳定错误键,并挂在拥有全部依赖字段的组上。异步校验需要防抖、取消和服务端最终校验。setErrors 覆盖现有错误时要谨慎,避免抹掉其他 validator 结果。
严格类型表单应使用 nonNullable 或显式包含 null,并理解禁用控件不出现在普通 value 中;需要全部字段时使用 getRawValue()。最终提交仍要在服务端校验,因为前端规则可被绕过。
路由边界、Guard 与 Resolver
Guard 控制是否允许导航,不是安全授权边界。前端代码和路由都可被绕过,服务端必须独立验证权限。
Resolver 可以让路由在激活前准备关键数据,但过度使用会延迟页面出现。关键首屏数据可在路由层获取,次要区域通过骨架渐进加载。路由参数流可能在复用同一组件时继续变化,不能只依赖组件构造时的快照。
功能路由的 providers 可以形成清晰领域作用域:页面内多个组件共享 facade/store,离开路由后释放。大型应用按领域能力组织文件与依赖,通常比按 components/services/models 技术类型建立全局目录更易维护。
SSR、Hydration 与平台 API
服务端渲染先生成 HTML,客户端 hydration 复用 DOM 并恢复交互。组件必须避免在构造或模板求值时无条件访问 window、document、storage 等浏览器 API。
稳定 hydration 需要:
- 服务端与客户端首次状态一致,传输已获取数据避免重复请求。
- 时间、随机数、地区格式等不确定输出有统一输入。
- 有效 HTML 嵌套,避免浏览器修正 DOM。
- 根级服务按每次服务端请求隔离,不能跨用户共享可变状态。
- 第三方 DOM 库延后到浏览器环境初始化并在销毁时清理。
延迟加载减少代码下载,defer block 还可根据视口、交互或空闲条件推迟模板依赖;但首屏关键内容不应为了“拆包”而延后到影响 LCP。
测试与架构边界
组件测试优先验证用户可见行为、输入输出和 DOM 可访问语义,不要紧耦合私有方法。纯领域函数直接单元测试;HTTP 服务使用测试后端验证请求契约;少量关键流程用端到端测试覆盖路由、表单和真实浏览器行为。
合理分层可以是:
- 容器或页面组合数据与导航。
- 展示组件通过 input/output 保持清晰契约。
- facade 协调用例、状态与外部服务。
- 领域层保存不依赖 Angular 的规则。
- 基础设施层处理 HTTP、存储和遥测。
分层应服务复杂度,简单功能不必为每个请求创建五层空转封装。判断标准是变化原因能否隔离、依赖方向是否清晰以及测试能否聚焦。
性能诊断方法
- 用真实用户指标与浏览器 Performance 确认是加载、脚本、变更检测、布局还是绘制。
- 用 Angular DevTools 查看组件检查频率和慢组件。
- 减少数据与 DOM 数量,长列表采用虚拟滚动或分页。
- 使用
OnPush、稳定输入和 Signals 缩小需要检查的视图。 - 把模板中的排序、过滤和对象创建移到可缓存的派生状态。
- 路由与低频功能延迟加载,并检查 chunk 瀑布和重复依赖。
不要把所有代码移到 runOutsideAngular 当作优化。它适合高频且不需要每次更新 UI 的第三方事件,必要时在最终状态变化时重新进入 Angular。任何优化都要验证用户交互延迟是否真实下降。
资深面试追问
OnPush 组件为什么仍然会更新?
因为它不是永久跳过检查。输入引用变化、模板事件、Signal 通知、async pipe、祖先显式标记等都可以让视图重新进入检查。回答时应结合具体触发源定位,而不是简单说“可能是 Angular bug”。(知识点:变更检测与视图更新)
Subject、BehaviorSubject 和 Signal 如何选择?
Subject 表示没有当前值要求的多播事件;BehaviorSubject 始终保存并同步发出当前值;Signal 是同步可读的响应式状态,并与 Angular 视图追踪深度集成。事件流不必强行变成状态,模板当前值也不必全部放入 Subject。选择取决于是否需要时间组合、错误/完成、同步当前值和模板依赖。(知识点:Signal 与 Observable 的边界)
为什么订阅会内存泄漏?
订阅本身不是必然泄漏。有限 HTTP 流完成后会释放;长期流若订阅者被源持有,且组件销毁时没有取消,组件及其闭包就可能继续可达。优先 async pipe、takeUntilDestroyed 或 Signal 互操作,并清理自定义资源。(知识点:RxJS 与异步数据)
Angular 适合大型项目的依据是什么?
价值来自统一的 DI、路由、表单、HTTP、构建与测试约定,以及强类型模板和清晰生命周期,能降低多团队决策分散。但框架完整也意味着学习、升级和初始约束成本。是否适合取决于团队规模、生命周期、交互复杂度和已有生态,不是由包体积或流行度单独决定。(知识点:测试与架构边界)
如何设计可替换的数据访问层?
组件依赖 facade 或领域接口,不直接拼 URL;通过 InjectionToken 注入实现;DTO 在基础设施边界解析为领域模型;错误转换为稳定业务语义;缓存和重试策略集中且可观测;测试使用替代 provider。抽象应围绕真实替换点,不为每个类机械创建一一对应接口。(知识点:依赖注入层级与 provider 作用域)
实践检查
- 组件输入、输出和依赖清晰,模板没有复杂业务计算。
- Signal 更新产生新值,派生状态由
computed表达。 - Observable 订阅具备错误、完成和销毁策略。
- 路由页面按需加载,核心首屏依赖保持精简。
- 表单验证同时覆盖类型约束、运行时规则和可访问提示。
- 服务用于共享能力,而不是默认存放所有组件状态。
- 能解释 Zone、Signal 依赖和
OnPush各自解决的问题。 - RxJS 高阶映射操作符与真实并发、取消语义一致。
- provider 作用域与业务状态生命周期匹配,不默认全部放在 root。