跳到正文
前端知识库
Angular

Angular 架构、响应式与资深面试

从 standalone 组件和依赖注入深入到变更检测、Signals、RxJS、分层架构、SSR 与性能诊断。

11 分钟Angular · Signals · RxJS · 依赖注入 · 面试

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 更新。
  • async pipe 收到新值并标记视图。
  • 代码显式调用变更检测 API。
@Component({
  selector: 'app-article-list',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `...`,
})
export class ArticleListComponent {}

OnPush 不是“只检查一次”,也不意味着所有输入都必须深冻结。它依赖可观察的更新信号,因此不可变更新能让引用变化清晰,也避免父子共享对象被原地修改后难以判断更新来源。

Signals 把依赖粒度推进到模板实际读取的信号。读取 Signal 时 Angular 记录消费者,更新时标记相关视图需要刷新。Zone 和 Signals 解决的维度不同:前者帮助发现异步边界,后者表达响应式依赖。

Signal 图、computedeffect

Signal 写入会使依赖的 computed 缓存失效,并通知消费者。computed 惰性求值且动态追踪本次执行读取的依赖:

readonly showArchived = signal(false)
readonly active = signal<Article[]>([])
readonly archived = signal<Article[]>([])

readonly visible = computed(() =>
  this.showArchived() ? this.archived() : this.active()
)

showArchivedfalse 时,当前计算不会依赖 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 可使用 useClassuseValueuseFactoryuseExisting。测试替换依赖时,替换 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 并恢复交互。组件必须避免在构造或模板求值时无条件访问 windowdocument、storage 等浏览器 API。

稳定 hydration 需要:

  • 服务端与客户端首次状态一致,传输已获取数据避免重复请求。
  • 时间、随机数、地区格式等不确定输出有统一输入。
  • 有效 HTML 嵌套,避免浏览器修正 DOM。
  • 根级服务按每次服务端请求隔离,不能跨用户共享可变状态。
  • 第三方 DOM 库延后到浏览器环境初始化并在销毁时清理。

延迟加载减少代码下载,defer block 还可根据视口、交互或空闲条件推迟模板依赖;但首屏关键内容不应为了“拆包”而延后到影响 LCP。

测试与架构边界

组件测试优先验证用户可见行为、输入输出和 DOM 可访问语义,不要紧耦合私有方法。纯领域函数直接单元测试;HTTP 服务使用测试后端验证请求契约;少量关键流程用端到端测试覆盖路由、表单和真实浏览器行为。

合理分层可以是:

  • 容器或页面组合数据与导航。
  • 展示组件通过 input/output 保持清晰契约。
  • facade 协调用例、状态与外部服务。
  • 领域层保存不依赖 Angular 的规则。
  • 基础设施层处理 HTTP、存储和遥测。

分层应服务复杂度,简单功能不必为每个请求创建五层空转封装。判断标准是变化原因能否隔离、依赖方向是否清晰以及测试能否聚焦。

性能诊断方法

  1. 用真实用户指标与浏览器 Performance 确认是加载、脚本、变更检测、布局还是绘制。
  2. 用 Angular DevTools 查看组件检查频率和慢组件。
  3. 减少数据与 DOM 数量,长列表采用虚拟滚动或分页。
  4. 使用 OnPush、稳定输入和 Signals 缩小需要检查的视图。
  5. 把模板中的排序、过滤和对象创建移到可缓存的派生状态。
  6. 路由与低频功能延迟加载,并检查 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。