组件与服务:依赖注入的主场
前置:Angular 基础
目标:掌握 @Input/@Output/ViewChild 通信三式,吃透 Angular DI(Java 后端最亲切的一章),理解 standalone 演进与生命周期钩子,RxJS 入门并用 ProductService 完成 HTTP 获取。
1. 组件通信
1.1 @Input:父到子
import { Component, input } from '@angular/core';
@Component({
selector: 'app-user-card',
template: `
<div class="card">
<h3>{{ name() }}</h3>
@if (vip()) { <span class="badge">VIP</span> }
</div>
`,
})
export class UserCardComponent {
// 现代 signal 输入(Angular 17+): 必填 name + 可选 vip 带默认值
name = input.required<string>();
vip = input(false);
}<!-- 父组件模板 -->
<app-user-card [name]="currentUser.name" [vip]="isVip" />传统装饰器写法(老项目海量存在,必须认识):
import { Component, Input } from '@angular/core';
@Component({ selector: 'app-user-card', /* ... */ })
export class UserCardComponent {
@Input({ required: true }) name!: string;
@Input() vip = false;
}对照 React props:[name]="x" 即 <UserCard name={x} />;区别是 Angular 用装饰器/signal 函数在类上声明,React 在函数参数解构。
1.2 @Output + EventEmitter:子到父
子组件不能改父传入的数据(同 React props 只读),要”上报”就发事件:
import { Component, output } from '@angular/core';
@Component({
selector: 'app-confirm-dialog',
template: `
<div class="dialog">
<p>确认删除吗?</p>
<button (click)="confirmed.emit(true)">确定</button>
<button (click)="confirmed.emit(false)">取消</button>
</div>
`,
})
export class ConfirmDialogComponent {
confirmed = output<boolean>(); // 现代写法
}传统等价物:
import { Component, Output, EventEmitter } from '@angular/core';
export class ConfirmDialogComponent {
@Output() confirmed = new EventEmitter<boolean>();
ok() { this.confirmed.emit(true); }
}父组件监听:
<app-confirm-dialog (confirmed)="onConfirm($event)" />($event) 就是 emit 出来的值。对照 Vue 的 @Output ≈ emits、React 的 onConfirmed 回调 prop——三种框架在这件事上语义完全一致。
1.3 ViewChild:父直接抓子组件引用
需要命令式调用子组件公开方法时(如让表单组件 reset):
import { Component, viewChild } from '@angular/core';
import { EditorComponent } from './editor.component';
@Component({
template: `
<app-editor />
<button (click)="clearEditor()">清空编辑器</button>
`,
})
export class ToolbarComponent {
editor = viewChild.required(EditorComponent);
clearEditor() {
this.editor().reset(); // 直接调子组件方法
}
}使用纪律:ViewChild 是逃生舱——能用 Input/Output 数据流解决的别用它,它让父子产生紧耦合,等于绕过接口直连实现类。
通信选型小结:
| 场景 | 手段 |
|---|---|
| 父传数据 | @Input / input() |
| 子报事件 | @Output+EventEmitter / output() |
| 调子组件方法 | viewChild(慎用) |
| 无关组件共享 | 服务 + DI(本章主角) |
2. 服务与依赖注入
2.1 什么是服务
服务是承载业务逻辑/数据访问的普通 TS 类,与视图解耦。Angular 的杀手锏是:你不用自己 new 它,容器会注入。
// product.service.ts
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root', // 关键: 注册到根注入器 → 全应用单例
})
export class LoggerService {
log(msg: string) {
console.log(`[${new Date().toISOString()}] ${msg}`);
}
}消费方零手动装配:
import { Component, inject } from '@angular/core';
import { LoggerService } from './logger.service';
@Component({ /* ... */ })
export class OrderComponent {
private logger = inject(LoggerService); // 现代注入方式
placeOrder() {
this.logger.log('下单成功');
}
}2.2 与 Spring 对照(本教程最亲切的表格)
| Spring | Angular | 备注 |
|---|---|---|
@Service | @Injectable() | 标记为可注入类 |
@Autowired 字段 | 构造函数参数 / inject() | 容器负责装配 |
| 单例 scope(默认) | providedIn:‘root’ | 全容器一份 |
@Scope("prototype") | 组件级 provider | 每组件新实例 |
| application context 层级 | 注入器层级树 | 就近覆盖 |
| 构造器注入测试 mock | TestBed 注入 fake 服务 | 可测性同源 |
这就是 Java 后端学 Angular 最亲切的原因:IoC/DI 思想完全同构,只是注解换成了装饰器、Bean 换成了服务。Spring 里”面向接口编程 + 容器装配”的全部收益(解耦、可测、可替换),Angular 一个不少。
2.3 注入器层级
Angular 的 DI 容器是一棵树:root → 懒加载模块 → 各组件。同一服务可以在不同层级提供不同实例:
@Component({
providers: [CartService], // 该组件及其子树获得专属实例(局部 prototype)
template: '...',
})
export class CartPageComponent {}查找规则:组件要某依赖时从自己往上找最近的 provider——类似 Java 里子上下文 Bean 覆盖父上下文。
3. NgModules 与 standalone 演进
历史包袱一分钟讲清:Angular 2~14 时代,所有代码必须挂进 NgModule(@NgModule 装饰器的模块清单),每个组件要在某个 module 的 declarations 里登记,每个要用到的特性模块要在 imports 里引入。样板多、心智重。
Angular 14 引入 standalone 组件并在 17+ 成为默认推荐:组件自带 imports 数组,不再需要任何 NgModule:
// 新世界: 没有 app.module.ts, main.ts 直接引导根组件
bootstrapApplication(AppComponent, {
providers: [provideRouter(routes)], // 平台级 provider 在这里
});维护老项目会遇到 NgModule(看到 declarations 数组就是旧世界),新代码一律 standalone。类比:NgModule 像 Spring 的 XML 配置时代,standalone 是注解驱动时代。
4. 生命周期钩子重点
组件从创建到销毁有八个钩子,高频使用的三个:
| 钩子 | 触发时机 | 典型用途 |
|---|---|---|
| ngOnInit | 输入属性就绪后,首次变更检测前 | 初始化取数(对应 useEffect(fn,[])) |
| ngOnDestroy | 销毁前 | 解订阅/清定时器(对应 useEffect 清理函数) |
| ngOnChanges | 任一 @Input 变化时 | 响应输入变化(对应 useEffect(fn,[deps])) |
import { Component, OnInit, OnDestroy, OnChanges, SimpleChanges, input, DestroyRef, inject } from '@angular/core';
@Component({ selector: 'app-profile', template: '{{ name() }}' })
export class ProfileComponent implements OnInit, OnChanges, OnDestroy {
userId = input.required<number>();
private timer?: ReturnType<typeof setInterval>;
constructor() {
// 现代 API: 不实现接口也能注册销毁回调
inject(DestroyRef).onDestroy(() => console.log('清理资源'));
}
ngOnInit() {
this.timer = setInterval(() => console.log('tick'), 1000);
}
ngOnChanges(changes: SimpleChanges) {
const id = changes['userId']?.currentValue;
if (id) console.log('重新拉取用户', id);
}
ngOnDestroy() {
if (this.timer) clearInterval(this.timer);
}
}与 useEffect 映射关系值得背下来:ngOnInit≈空依赖 effect、ngOnChanges≈deps effect、ngOnDestroy≈cleanup。Angular 钩子按名字显式声明,React 靠依赖数组隐式表达——殊途同归。
5. RxJS 初识
5.1 Observable 订阅模型
RxJS 是 Angular 全家桶的响应式底座(HttpClient、Router 事件、ReactiveForms 全返回 Observable)。心智模型:Observable 是一条随时间推送值的流水线,subscribe 才接水。
import { of, map, filter } from 'rxjs';
of(1, 2, 3, 4)
.pipe(
filter(n => n % 2 === 0),
map(n => n * 10),
)
.subscribe(v => console.log(v)); // 20, 405.2 与 Promise 差异表
| 维度 | Promise | Observable |
|---|---|---|
| 推送次数 | 一次定终身 | 0~N 次 |
| 惰性执行 | 创建即执行 | 订阅才执行(冷) |
| 取消 | 不可取消 | unsubscribe 即取消 |
| 操作符链 | then 串行 | pipe + 数百个组合算子 |
| 多播 | 天然单播 | Subject 可广播 |
一句话:Promise 是”一次性快递”,Observable 是”可退订的杂志订阅”。HttpClient 场景下取消能力尤其关键——路由切换时退订未完成请求即天然防竞态。
5.3 async 管道:订阅的正确姿势
组件里手动 subscribe 再手动管理退订太啰嗦,模板 async 管道自动订阅+销毁时自动退订:
@Component({
template: '<p>{{ clock$ | async }}</p>',
})
export class ClockComponent {
clock$ = interval(1000).pipe(map(() => new Date().toLocaleTimeString()));
}命名惯例 $ 后缀表示 Observable。async 管道让”忘记退订导致内存泄漏”这类事故在模板层被系统性消灭。
6. HttpClient 与拦截器预览
HttpClient 是内置的 HTTP 服务,返回 Observable:
this.http.get<Product[]>('/api/products').subscribe(list => ...);它的完整用法(类型化、拦截器加 token、统一错误处理)在 下一章 展开,本章先用最简形态打通数据流。
7. 实战:ProductService 注入 + HTTP 获取
目标:把上一章写死在组件里的产品数组抽成服务,并切换到 HTTP 数据源(先用本地 mock json 模拟后端)。
7.1 准备 mock 接口
public/api/products.json(dev server 会静态伺服 public 目录):
[
{ "id": 1, "name": "机械键盘", "price": 399, "stock": 25 },
{ "id": 2, "name": "无线鼠标", "price": 129, "stock": 8 },
{ "id": 3, "name": "显示器支架", "price": 199, "stock": 0 },
{ "id": 4, "name": "USB-C 扩展坞", "price": 249, "stock": 42 }
]7.2 ProductService
ng g service services/product// src/app/services/product.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { delay, tap } from 'rxjs';
import { Product } from '../product';
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private baseUrl = 'api/products'; // 相对路径走 dev server 静态目录
getProducts() {
return this.http.get<Product[]>(this.baseUrl).pipe(
delay(300), // 模拟网络延迟
tap(list => console.log('获取到', list.length, '个产品')),
);
}
getProduct(id: number) {
return this.http.get<Product>(`${this.baseUrl}/${id}.json`);
}
}注意服务里没有任何 Angular UI 概念——纯业务/数据访问,正如 Service 层不掺 Controller 职责。
7.3 组件消费
// src/app/app.component.ts(节选改造)
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { provideHttpClient } from '@angular/common/http';
import { bootstrapApplication } from '@angular/platform-browser';
import { ProductService } from './services/product.service';
import { Product } from './product';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<main>
<h1>产品列表</h1>
<input [(ngModel)]="keyword" placeholder="搜索..." />
<p *ngIf="loading">加载中...</p>
<p *ngIf="error">{{ error }}</p>
<table *ngIf="!loading && !error">
<tr *ngFor="let p of filtered()">
<td>{{ p.name }}</td>
<td>{{ p.price | currency:'CNY' }}</td>
</tr>
</table>
</main>
`,
})
export class AppComponent implements OnInit {
private productService = inject(ProductService);
products: Product[] = [];
keyword = '';
loading = false;
error = '';
ngOnInit() {
this.loading = true;
this.productService.getProducts().subscribe({
next: list => { this.products = list; this.loading = false; },
error: err => { this.error = '加载失败:' + err.message; this.loading = false; },
});
}
filtered(): Product[] {
return this.products.filter(p =>
p.name.toLowerCase().includes(this.keyword.trim().toLowerCase()),
);
}
}
// main.ts: 注册 HttpClient 提供者
bootstrapApplication(AppComponent, {
providers: [provideHttpClient()],
});架构检查清单:
- 数据获取逻辑住在 ProductService,组件只管展示与交互;
- DI 让组件对服务的创建方式零感知,换成真后端只改 baseUrl;
- loading/error 双态齐备;
- subscribe 回调里更新成员变量触发变更检测刷新表格;
- (进阶体会)把 subscribe 改成
products$ = ps.getProducts()+*ngFor="let p of products$ | async",订阅管理交给管道。
自检清单:
- @Input/@Output/viewChild 三式的适用边界
- providedIn:‘root’ = root 单例,能对着说出 Spring 对照表
- standalone 替代 NgModule 的原因说得出
- OnInit/OnDestroy/OnChanges 与 useEffect 三态映射
- Observable vs Promise 五条差异,async 管道为何安全
小结
| 知识点 | 一句话 |
|---|---|
| 通信 | Input 下行、Output 上行、viewChild 逃生舱 |
| 服务 | @Injectable 业务载体,providedIn:‘root’ 单例 |
| DI | 构造器注入 + 层级注入器,Spring 同构 |
| standalone | 组件自带 imports,NgModule 进博物馆 |
| 生命周期 | OnInit/OnChanges/OnDestroy 对应 effect 三态 |
| RxJS | 可退订的多值流,async 管道免手管订阅 |
| HttpClient | 返回 Observable 的官方请求服务 |
路由守卫与拦截器深水区见 路由与 HTTP。