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()(操作数据的方法)title、newTodoText(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);
}
}
注意 items 是 private 的,外部只能通过 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.text、item.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);
}
}
注意这里改为不可变更新 ------每次修改都创建新数组(map、filter、展开运算符)。而不是 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 之外执行的),页面就可能不更新。
asyncpipe 没有这个问题------它是 Angular 官方推荐的 Observable 绑定方式,变更检测总能正确触发。而且它自动处理了订阅和取消订阅,不用担心内存泄漏。
这就相当于实现了一个简易的 Pinia store------数据是响应式的,一处修改,多处自动更新。
对比一下:
- Vuex/Pinia :
state+getters+actions,背后是 Vue 的 reactivity。- Redux :
store+reducer+dispatch,不可变数据。- Angular + RxJS :
BehaviorSubject+asObservable(),数据流驱动。Angular 的这种做法比 Redux 简洁,但比纯 Pinia 要多写一点"发布通知"的代码(每次修改都要
.next())。不过 RxJS 的力量远不止于此------你还可以combineLatest、debounce、switchMap处理各种复杂场景。
场景三:复杂状态管理 → 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.valueitems(闭包内)改值 items.set(v)items.value = vsetItems(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?
- 难以追踪 :到处
emit和subscribe会让数据流向混乱,跟 React/Vue 里滥用 EventBus 一样问题。 - Angular 有更好的方案 :Service 直接注入即可,无需中间人。也就是说,
TodoService 本身就扮演了通信中介的角色。 - 内存泄漏风险 :忘了
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 代码就有了灵魂。