Angular 快速入门:服务和依赖注入

Angular 快速入门:服务和依赖注入

前三篇我们把 Todo 应用从"能看"做到了"能用"。但有一个问题:所有数据逻辑都写在 TodoComponent

如果明天有个 DashboardComponent 也要展示待办数据怎么办?复制一遍?还是抽出来?

这篇我们来解决这个问题。你会学到 Angular 最核心的设计之一------依赖注入(DI)Service。同时也会讨论 Vue/React 开发者最关心的几个问题:

  • Angular 有 provide / inject 吗?

    Angular可以使用 Service 来实现。

  • Angular 逻辑也是用 Hooks 来复用吗?

    Angular居于 Class 组件设计,逻辑复用需用 Service 来实现。

  • Angular 怎么做状态管理?

    推荐使用 Service + Signals 来实现。

  • Angular 的事件总线怎么用?

    和其他框架类似。

1. 为什么需要 Service?

先看看当前 TodoComponent 里有什么:

  • todos 数组(数据)
  • toggleDone()addTodo()deleteTodo()(操作数据的方法)
  • titlenewTodoText(UI 状态)

数据和操作混在一起,当另一个组件也需要操作 todos 时,你只能复制代码。

Service 就是来解决这个问题的:把数据和操作数据的逻辑,从组件里抽出来,放到一个独立的、可注入的类里。

组件只负责"展示和交互",Service 只负责"数据和业务逻辑"。这就是 Angular 推崇的职责分离

对比 React/Vue:React 里你把逻辑抽到自定义 Hooks 里,Vue 里你抽到 Composition API 函数或 Pinia store 里。Angular 的做法是 Service------一个普通的 TypeScript 类,加上 @Service() 装饰器。

2. 创建第一个 Service

用 CLI 创建 Service:

bash 复制代码
ng generate service todo

简写:

bash 复制代码
ng g s todo

生成 todo.ts

typescript 复制代码
import { Service } from '@angular/core';

@Service()
export class Todo {

  constructor() { }
}

关键就是这个 @Service() 装饰器

它的意思是:"我是一个服务,Angular 会帮我管理它的生命周期,整个应用共享同一个实例。"

如果你在更早版本的 Angular 里看到 @Injectable({providedIn: 'root'}),不用奇怪------那是旧写法,功能一样。Angular 22 之后新生成的服务默认使用 @Service()

旧写法还有一种场景:@Injectable() 不带 providedIn,然后在组件里通过 providers: [MyService] 手动提供。这种对应新写法里用 @Service() + 组件 providers: [Todo] 做局部覆盖。效果完全一样,只是新写法更简洁了。

把数据搬进去

TodoComponent 里的数据和操作方法搬到 TodoService------哦不对,现在类名叫 Todo

这里要注意:Service 的类名是 Todo,但我们的数据项也叫 Todo。为了避免名字撞车,把数据项接口换个名字:

types.ts 里定义:

typescript 复制代码
export interface TodoItem {
  id: number;
  text: string;
  done: boolean;
}

然后在 todo.ts 里:

typescript 复制代码
import { Service } from '@angular/core';
import { TodoItem } from './types';

@Service()
export class Todo {
  private items: TodoItem[] = [
    { id: 1, text: '学习 Angular 基础', done: false },
    { id: 2, text: '写一个 Todo 应用', done: true },
    { id: 3, text: '对比 React 和 Vue 的差异', done: false }
  ];

  getItems() {
    return this.items;
  }

  toggleDone(id: number) {
    const item = this.items.find(t => t.id === id);
    if (item) item.done = !item.done;
  }

  addTodo(text: string) {
    if (!text.trim()) return;
    this.items.push({ id: Date.now(), text, done: false });
  }

  deleteTodo(id: number) {
    this.items = this.items.filter(item => item.id !== id);
  }
}

注意 itemsprivate 的,外部只能通过 getItems() 访问。这是一种封装习惯------不让外部直接修改内部数据。

等等,这里的 items 是响应式的吗?

答案是:不是。 默认情况下,Service 里的数据不是响应式的。它就是一个普通的 TypeScript 数组。

但等一下------前面我们在组件里直接写 todos 数组,修改后页面也会更新啊?为什么放到 Service 里就不一样了?

关键区别在这里:

  • 组件里的属性 :Angular 的变更检测会自动检查组件类的属性,变了就更新模板。
  • Service 里的属性 :Angular 不会自动追踪 Service 内部的数据变化。

你可能会问:"那上面的 getItems() 能正常工作吗?"------能,因为 getItems() 在组件初始化时被调用了一次,把数据取出来赋给了组件的 items 属性。这时候数据在组件里,Angular 能检测到。但如果其他组件改了 Service 的数据,你这个组件不会自动知道,因为数据已经"复制"出来了。

简单理解:

复制代码
组件 A ──getItems()──→ 拿到 items 副本
组件 B ──addTodo()──→ Service 里的 items 变了,但组件 A 不知道

怎么解决?后面第 5 节会讲------用 BehaviorSubject (RxJS)或 Signals 让数据变成响应式的。

对比 Vue/Pinia:Pinia 的 state 默认就是响应式的,因为 Vue 的 reactivity 系统会自动追踪。React 用 useState 管理状态,跨组件共享也需要 Context 或 Redux。Angular 的做法更"手动"------基础 Service 不负责响应式,需要你主动选择用 RxJS 或 Signals。

3. 在组件里注入 Service

现在回到 TodoComponent,把原来的数据操作换成 Service。

Angular 的注入方式很直接------在 constructor 里声明参数

typescript 复制代码
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TodoItemComponent } from '../todo-item/todo-item';
import { Todo } from '../todo';                     // ← 导入 Service

@Component({
  selector: 'app-todo',
  standalone: true,
  imports: [CommonModule, FormsModule, TodoItemComponent],
  templateUrl: './todo.html',
  styleUrls: ['./todo.css']
})
export class TodoComponent {
  items: any[];
  newTodoText = '';

  // ↓ Angular 会自动创建一个 Todo 实例,注入到这里
  constructor(private todo: Todo) {
    this.items = this.todo.getItems();
  }

  toggleDone(id: number) {
    this.todo.toggleDone(id);
  }

  addTodo() {
    this.todo.addTodo(this.newTodoText);
    this.newTodoText = '';
  }

  deleteTodo(id: number) {
    this.todo.deleteTodo(id);
  }
}

看到 constructor(private todo: Todo) 了吗?这就是注入。

Angular 看到 constructor 的参数类型是 Todo,就会自动去找对应的实例,传进来。你不用 new Todo() 手动创建,Angular 帮你做这事。

保存,刷新。应用应该跟之前一样工作------但数据已经不在组件里了,而是在 Service 里统一管理。

模板里原来用的 item.textitem.done 这些不变,只要把引用的变量名从 todos 改成 items 就行:

html 复制代码
<ul *ngIf="items.length > 0; else emptyState">
  <app-todo-item
    *ngFor="let item of items"
    [todo]="item"
    (toggle)="toggleDone($event)"
    (delete)="deleteTodo($event)">
  </app-todo-item>
</ul>

为什么不自己 new 你当然可以自己 const todo = new Todo()。但你自己 new 的时候,每个组件都会创建自己的 Service 实例,数据就不共享了。Angular 的 DI 保证:加了 @Service() 的服务,整个应用只有一个实例。
跨框架对比

  • React:没有 DI 概念。共享逻辑用自定义 Hooks,但每次调用 Hook 都是独立的作用域。要共享状态,得用 Context 或外部状态管理库。
  • Vue 3 :Composition API 函数(如 useTodo())可以共享逻辑,但状态共享也需要外部的 reactive 对象或 Pinia。
  • Angular :DI 是框架的一部分。@Service() 就是"全局单例",谁需要谁 inject,不需要额外配置。

4. Angular 的 Provide/Inject 机制

Vue 有 provide/inject,React 有 Context。Angular 也有等价的机制,而且更强大。

全局提供(root 级别)

typescript 复制代码
@Service()

加了 @Service() 的类,默认就是全局单例------整个应用共享一个实例,所有注入的地方拿到的都是同一个对象。

这对应 Vue 里 app.provide('key', value) 或者 Pinia 的全局 store,对应 React 里放在根组件的 Context Provider。

局部提供(组件级别)

加了 @Service() 默认就是全局单例 ,整个应用共享一个实例。这对应 Vue 的 app.provide() 或 React 根组件的 Context Provider。

但有的时候你希望"每个组件树有自己的实例"------比如每个产品页各有独立的购物车状态。这时候用组件的 providers 数组:

typescript 复制代码
import { Todo } from '../todo';

@Component({
  selector: 'app-todo',
  standalone: true,
  providers: [Todo],           // ← 覆盖全局单例,创建局部实例
  // ...
})

效果是:这个组件和它的子组件拿到的 Todo 是一个新实例,跟全局那个不是同一个。其他组件不受影响。

你可能会问:"@Service() 不就是全局了吗?加 providers 不是多此一举?"

不是的。@Service() 是全局默认值providers 是局部覆盖------你可以理解为:

  • @Service() 说:"如果一个组件没特别指定,就用全局那个。"
  • providers: [Todo] 说:"我这个子树不用全局的,我自己建一个。"

Angular 的 DI 会就近查找 :从当前组件开始,往上找最近的 provider。如果当前组件有 providers: [Todo],就用它自己的;没有,就用全局的。

typescript 复制代码
// 父组件提供局部实例
@Component({
  providers: [Todo],
})
export class ParentComponent {}

// 子组件注入------拿到的是父组件提供的那个实例,不是全局的
@Component({...})
export class ChildComponent {
  constructor(private todo: Todo) {}
}

这种"就近查找"的机制叫层级注入器------每个组件都有自己的一层注入器,子组件会继承父组件的注入器。用图表示:

复制代码
根注入器(@Service() 注册到这里)
  └── AppComponent
       └── TodoComponent(providers: [Todo] → 创建新实例)
            └── TodoItemComponent(注入 Todo → 找到 TodoComponent 的实例)
       └── HeaderComponent(注入 Todo → 没找到局部 providers,用全局的)

Vue/React 对比总结

场景 Angular Vue 3 React
全局单例 @Service() app.provide() / Pinia 根 Context Provider
局部作用域 组件 providers: [Service] provide(key, value) <Context.Provider>
子组件获取 constructor 注入 inject(key) useContext()
注入 Key TypeScript 类型(class) 字符串或 Symbol Context 对象

区别在于:Angular 的 DI 是类型驱动的 ------你通过类型来查找和注入。Vue 和 React 是显式 Key 驱动的------你要指定一个 key。Angular 类型驱动的方式在编译期就能检查错误,Vue/React 显式 Key 的方式更灵活。

InjectionToken ------ 当提供的不只是一个类

有时候你想提供的不是一个 Service 类,而是一个值(比如 API 地址、配置对象)。这时候用 InjectionToken

typescript 复制代码
import { InjectionToken } from '@angular/core';

export const API_URL = new InjectionToken<string>('API_URL');

提供:

typescript 复制代码
@Component({
  providers: [{ provide: API_URL, useValue: 'https://api.example.com' }]
})
export class AppComponent {}

这里的 useValue 只是演示传递了一个字符串,并不是说它只能接收字符串类型。

注入:

typescript 复制代码
constructor(@Inject(API_URL) private apiUrl: string) {}

这比 React Context 更灵活一点------你可以注入任何东西(常量、配置、工厂函数),不只是组件树上的值。

5. Angular 怎么做状态管理?

很多从 React/Vue 转过来的人会问:"Angular 有 Vuex/Pinia/Redux 吗?"

答案是:大多数场景不需要。

场景一:简单共享 → Service 就够了

如果只是多个组件共享同一份数据,@Service() 就是天然的 store。数据在 service 里,谁需要谁注入。

场景二:响应式共享 → Service + RxJS

上面的 getItems() 返回一个数组,但如果一个组件修改了数据,另一个组件不会自动知道。

RxJS 的 BehaviorSubject 来解决。先把 Service 改成响应式的:

typescript 复制代码
import { Service } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { TodoItem } from './types';

@Service()
export class Todo {
  private items: TodoItem[] = [];

  private itemsSubject = new BehaviorSubject<TodoItem[]>(this.items);

  constructor() {
    // 初始化数据
    this.items = [
      { id: 1, text: '学习 Angular 基础', done: false },
      { id: 2, text: '写一个 Todo 应用', done: true },
      { id: 3, text: '对比 React 和 Vue 的差异', done: false }
    ];
    this.itemsSubject.next([...this.items]);
  }

  // 返回 Observable,组件订阅它
  getItems() {
    return this.itemsSubject.asObservable();
  }

  addTodo(text: string) {
    this.items = [...this.items, { id: Date.now(), text, done: false }];
    this.itemsSubject.next(this.items);
  }

  toggleDone(id: number) {
    this.items = this.items.map(item =>
      item.id === id ? { ...item, done: !item.done } : item
    );
    this.itemsSubject.next(this.items);
  }

  deleteTodo(id: number) {
    this.items = this.items.filter(item => item.id !== id);
    this.itemsSubject.next(this.items);
  }
}

注意这里改为不可变更新 ------每次修改都创建新数组(mapfilter、展开运算符)。而不是 push 或直接修改原数组。这样能保证 BehaviorSubject 发出的始终是新引用。

然后在组件里,不用 subscribe,用 async pipe。注意这里要用 inject() 而不是 constructor 注入------因为 items$ 是类属性初始化器,它在 constructor 之前 执行,this.todo 还没赋值:

typescript 复制代码
import { Component, inject } from '@angular/core';
import { CommonModule, AsyncPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TodoItemComponent } from '../todo-item/todo-item';
import { Todo } from '../todo';
import { TodoItem } from '../types';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-todo',
  standalone: true,
  imports: [CommonModule, FormsModule, TodoItemComponent, AsyncPipe],
  templateUrl: './todo.html',
  styleUrls: ['./todo.css']
})
export class TodoComponent {
  private todo = inject(Todo); // 注入 Service
  items$: Observable<TodoItem[]> = this.todo.getItems(); // 获取 Observable
  
  newTodoText = '';

  toggleDone(id: number) {
    this.todo.toggleDone(id);
  }

  addTodo() {
    this.todo.addTodo(this.newTodoText);
    this.newTodoText = '';
  }

  deleteTodo(id: number) {
    this.todo.deleteTodo(id);
  }
}

为什么不用 constructor?因为 items$ = this.todo.getItems() 在类属性初始化阶段执行,此时 constructor 还没跑,private todo: Todo 还没赋值。inject() 是 Angular 14+ 提供的函数,可以在 constructor 外部注入,专门解决这个问题。

模板里用 async pipe 接收:

html 复制代码
<ul *ngIf="(items$ | async)?.length! > 0; else emptyState">
  <app-todo-item
    *ngFor="let item of items$ | async"
    [todo]="item"
    (toggle)="toggleDone($event)"
    (delete)="deleteTodo($event)">
  </app-todo-item>
</ul>

<ng-template #emptyState>
  <p>还没有待办,输入一条开始吧 🎉</p>
</ng-template>

items$ | async| 就是 Angular 的 pipe(管道) 操作符。async 管道会自动订阅 Observable,把值取出来;当组件销毁时,自动取消订阅。

为什么subscribe 可能跑不通?

在构造器中手动 subscribe 时,然后手动this.items = data 确实能更新属性,但 Angular 的变更检测在某些情况下不会自动触发(比如回调是在 Angular 的 zone 之外执行的),页面就可能不更新。

async pipe 没有这个问题------它是 Angular 官方推荐的 Observable 绑定方式,变更检测总能正确触发。而且它自动处理了订阅和取消订阅,不用担心内存泄漏。

这就相当于实现了一个简易的 Pinia store------数据是响应式的,一处修改,多处自动更新。

对比一下:

  • Vuex/Piniastate + getters + actions,背后是 Vue 的 reactivity。
  • Reduxstore + reducer + dispatch,不可变数据。
  • Angular + RxJSBehaviorSubject + asObservable(),数据流驱动。

Angular 的这种做法比 Redux 简洁,但比纯 Pinia 要多写一点"发布通知"的代码(每次修改都要 .next())。不过 RxJS 的力量远不止于此------你还可以 combineLatestdebounceswitchMap 处理各种复杂场景。

场景三:复杂状态管理 → NgRx

如果你的应用确实很复杂(比如多个 Store、复杂的副作用、时间旅行调试),Angular 生态有 NgRx------它就是 Angular 版的 Redux:

复制代码
Component → dispatch Action → Reducer → Store(State) → Selector → Component

用过 Redux 的人会感觉很熟悉------Action、Reducer、Effect、Selector,概念一样。NgRx 只是把 Redux 的模式用 Angular 的 DI 和 RxJS 重新实现了。

场景四⭐️:Angular 17+ 的新选择 → Signals

从 Angular 17 开始,Signals 成为了新的响应式原语:

typescript 复制代码
import { signal, computed, inject } from '@angular/core';
import { Service } from '@angular/core';

export interface TodoItem {
  id: number;
  text: string;
  done: boolean;
}

@Service()
export class Todo {
  private items = signal<TodoItem[]>([]);

  // 初始化数据放到 constructor 里
  constructor() {
    this.items.set([
      { id: 1, text: '学习 Angular 基础', done: false },
      { id: 2, text: '写一个 Todo 应用', done: true },
      { id: 3, text: '对比 React 和 Vue 的差异', done: false }
    ]);
  }

  // 计算属性:已完成的数量
  readonly completedCount = computed(() =>
    this.items().filter(t => t.done).length
  );

  // 暴露为只读 signal,组件模板里直接用
  getItems() {
    return this.items.asReadonly();
  }

  addTodo(text: string) {
    if (!text.trim()) return;
    this.items.update(list => [...list, { id: Date.now(), text, done: false }]);
  }

  toggleDone(id: number) {
    this.items.update(list =>
      list.map(item => item.id === id ? { ...item, done: !item.done } : item)
    );
  }

  deleteTodo(id: number) {
    this.items.update(list => list.filter(item => item.id !== id));
  }
}

组件里直接调用:

typescript 复制代码
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TodoItemComponent } from '../todo-item/todo-item';
import { Todo } from '../todo';

@Component({
  selector: 'app-todo',
  standalone: true,
  imports: [CommonModule, FormsModule, TodoItemComponent],
  templateUrl: './todo.html',
  styleUrls: ['./todo.css']
})
export class TodoComponent {
  private todo = inject(Todo); // 注入 Service
  items = this.todo.getItems(); // 获取 Signal
  newTodoText = '';

  toggleDone(id: number) {
    this.todo.toggleDone(id); // 调用服务的方法
  }

  addTodo() {
    this.todo.addTodo(this.newTodoText);
    this.newTodoText = '';
  }

  deleteTodo(id: number) {
    this.todo.deleteTodo(id);
  }
}

模板里使用 signal,不需要 async pipe,直接用 () 取值:

html 复制代码
<ul *ngIf="items().length > 0; else emptyState">
  <app-todo-item
    *ngFor="let item of items()"
    [todo]="item"
    (toggle)="toggleDone($event)"
    (delete)="deleteTodo($event)">
  </app-todo-item>
</ul>

Signal 写法比 RxJS 简洁很多------不需要 Subject、不需要 .next()、不需要 async pipe。调用 items() 就能拿到当前值。每次 update() 时,Angular 自动知道哪些地方依赖了这个 signal,精准更新。

Signals vs Vue / React 响应式方案对比:

能力 Angular Signals Vue 3 React
定义响应式数据 signal(value) ref(value) useState(value)
取值 items() items.value items(闭包内)
改值 items.set(v) items.value = v setItems(v)
更新(基于原值) items.update(fn) items.value = fn(old) setItems(fn)
计算属性 computed(fn) computed(fn) useMemo(fn)
监听变化 effect(fn) watch(fn) useEffect(fn)

从表格能看出来,Angular Signals 和 Vue 的 ref/computed 几乎是对应的 ------都是通过函数调用取值、都有自动追踪依赖的 computed、都有 effect 处理副作用。唯一的区别是 Vue 用 .value 而 Angular 用 () 取值。

React 的 useState 则完全不同:它依赖组件的重新渲染来更新视图,而不是细粒度的响应式追踪。每次 setItems 都会重新执行整个组件函数。Signal 比它精细得多------哪个组件依赖了 signal,就只更新那个组件。

所以 Angular 17+ 的 Signals 可以理解成"把 Vue 的响应式系统搬到了 Angular 里"。如果你用过 Vue 的 ref(),上手 Angular 的 signal() 几乎零成本。

另外,在 Angular 中 Service + Signals 就可实现全局的状态管理,类似 Vuex,Pinia ,Redux这些状态管理库,而 Service 本身又类似 Vue 的 Provide 和 React 的 Context Provide,共享上下文,以及Hooks逻辑等。

6. 事件总线通信方式

你可能会问:"不用 Service + DI,Angular 能像 EventBus 那样通信吗?"

可以,但不推荐。

Angular 里做事件总线很简单------用 Subject:

typescript 复制代码
import { Service } from '@angular/core';
import { Subject, filter } from 'rxjs';

@Service()
export class EventBus {
  private subject = new Subject<any>();

  emit(event: string, data?: any) {
    this.subject.next({ event, data });
  }

  on(event: string) {
    return this.subject.pipe(filter(e => e.event === event));
  }
}

组件 A 发射事件:

typescript 复制代码
constructor(private bus: EventBus) {}

someMethod() {
  this.bus.emit('todo:created', { text: '新待办' });
}

组件 B 监听事件:

typescript 复制代码
constructor(private bus: EventBus) {
  this.bus.on('todo:created').subscribe(data => {
    console.log('有人创建了待办:', data);
  });
}

但为什么 Angular 不推荐 EventBus?

  1. 难以追踪 :到处 emitsubscribe 会让数据流向混乱,跟 React/Vue 里滥用 EventBus 一样问题。
  2. Angular 有更好的方案 :Service 直接注入即可,无需中间人。也就是说,Todo Service 本身就扮演了通信中介的角色。
  3. 内存泄漏风险 :忘了 unsubscribe 会导致组件无法被垃圾回收。

所以 Angular 社区的共识是:能用 Service 直连,就别用 EventBus。 EventBus 只在极少数场景有用(比如用户登录/登出、主题切换等"全局广播"型事件)。

对比其他框架:

  • Vue :有 $emit / $on 的 EventBus 方案,官方不推荐,推荐 Pinia。
  • React:没有 EventBus,用 Redux 或 Context。
  • Angular:可以用 Service + Subject 实现 EventBus,但更推荐 Service 直连 + RxJS。

7.本章总结

这篇内容比较多,来总结一下:

核心知识

  • Service + @Service():把数据逻辑从组件里抽出来,全局单例
  • DI:constructor 参数声明即可注入,Angular 自动管理实例
  • provide/inject :Angular 通过 providers: [] 实现组件级作用域,等价于 Vue 的 provide/inject 和 React 的 Context

状态管理选择(从简单到复杂):

方案 适用场景 类似方案
Service + 属性 简单数据共享 Pinia/Vuex 的基本 store
Service + BehaviorSubject 响应式多组件同步 带响应式的 Pinia store
Service + Signals Angular 17+ 新趋势 Vue 的 ref() / computed()
NgRx 复杂状态管理 Redux
EventBus 全局广播事件 不推荐,了解即可

Service 是 Angular 应用的核心骨架。写好 Service,你的 Angular 代码就有了灵魂。

相关推荐
kidding7231 小时前
BMI 健康测量仪工具类小程序
前端·微信小程序·小程序
KaMeidebaby1 小时前
卡梅德生物技术快报|兔单克隆抗体应用实战:禽源病原 IFA 检测全流程拆解
前端·人工智能·物联网·算法·百度
lulu12165440781 小时前
OpenAI 如何用开源前端生态为 GPT-5.6 铺路? - 微元算力(weytoken)
java·前端·人工智能·python·gpt·开源·ai编程
问心无愧051310 小时前
ctf show web入门160 161
前端·笔记
李小白6610 小时前
第四天-WEB服务器基本原理,IIS服务
运维·服务器·前端
humcomm10 小时前
AI编程时代新前端职位
前端·ai编程
好家伙VCC11 小时前
Web Components主题热切换方案揭秘
java·前端
甲维斯11 小时前
Kimi版超级玛丽效果“惊人”,配额不足5厘米!
前端·人工智能
hboot11 小时前
AI工程师第一课 - Python
前端·后端·python