组件与服务:依赖注入的主场

前置: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 对照(本教程最亲切的表格)

SpringAngular备注
@Service@Injectable()标记为可注入类
@Autowired 字段构造函数参数 / inject()容器负责装配
单例 scope(默认)providedIn:‘root’全容器一份
@Scope("prototype")组件级 provider每组件新实例
application context 层级注入器层级树就近覆盖
构造器注入测试 mockTestBed 注入 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, 40

5.2 与 Promise 差异表

维度PromiseObservable
推送次数一次定终身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