Angular 快速入门:Signals 响应式编程与生命周期
Angular 快速入门:Signals 响应式编程与生命周期
前四篇文章我们从零搭了一个 Todo 应用,做了组件拆分,用 Service 管理数据,甚至还试了 RxJS 和 Signal 两种响应式方案。
但之前提到 Signals 时,只是把它作为"状态管理的另一种选择"快速带过。这一篇我们彻底搞明白:Signal 到底是什么?它解决了什么问题?怎么用好它?
顺便把另一个绕不开的话题也讲了——组件的生命周期。这两个东西放在一起讲是有原因的。你一会儿就明白了。
1. 为什么需要 Signals?
Zone.js 的"笨办法"
在讲 Signal 之前,得先知道 Angular 原来是怎么检测变化的。
Angular 用的是一套叫 Zone.js 的机制。它的工作方式很简单粗暴:
Zone.js "猴子补丁"了所有浏览器 API——
setTimeout、addEventListener、XMLHttpRequest……只要这些东西一执行,Zone.js 就通知 Angular:“嘿,可能发生了点什么,你去检查一下吧!”
Angular 收到通知后就从根组件开始遍历整个组件树,检查每个属性的值变了没有。
这个过程叫变更检测(Change Detection)。
用户点击按钮
↓
Zone.js 捕获到点击事件
↓
Angular 遍历整个组件树,检查所有绑定
↓
发现有变化,更新 DOM
这个方案的好处是开发者几乎不用操心——你只管改数据,Angular 帮你搞定后续一切。这也是 Angular 一直以来的核心卖点:"magic"一样的变化检测。
坏处也很明显——做了很多无用功。
你的应用有 100 个组件,用户就点了一下按钮,Angular 却要检查全部 100 个组件有没有变化。大多数时候什么都没变,但还是检查了一遍。
打个比方:Zone.js 的变更检测就像你每隔几分钟就去检查冰箱里的菜新不新鲜。你打开门、看一眼、关上。确实能发现坏了的菜,但大多数时候啥也没变,白费功夫。
Signals 的"巧办法"
Signals 换了个思路:
不主动检查了。谁变了,谁主动通知。
signal 的值变了
↓
signal 自动通知所有"依赖它"的地方
↓
只有依赖了这个 signal 的组件才更新
这就从"全量检查"变成了"精准推送"。
对小程序来说,两者区别不大。但对大型应用来说,Signals 能省掉大量无意义的变更检测。
继续刚才那个比方:
- Zone.js 模式:你每隔几分钟打开冰箱检查。
- Signals 模式:冰箱里的菜坏了,菜自己喊你:“快来!我不行了!”
跨框架视角
- React:每次
setState重新执行整个组件函数(以及所有子组件),除非你用memo/useMemo手动优化。React 18+ 的useMemo其实跟computed有点像,但机制不同。 - Vue 3:Vue 3 的
ref()和reactive()本身就是 Signal 的近亲。事实上 Angular 的 Signal 设计在很大程度上借鉴了 Vue 的响应式系统。所以如果你用过 Vue 3 的ref(),下面这些你会感觉很眼熟。 - Angular Zone.js:你不用手动声明"哪些数据是响应式的",所有属性默认都被追踪。省事,但代价是每次都要全量检查。
严格来说,Angular 也一直在优化 Zone.js 变更检测——比如
OnPush策略可以跳过部分检查。但 Signals 是从根本上换了一种更高效的方式。
好,理论讲完了。接下来看怎么用。
2. signal() — 响应式数据的基石
创建和读取
import { signal } from '@angular/core';
// 创建
const count = signal(0);
// 读取——调用它!
console.log(count()); // 0
就这么简单。signal(0) 创建了一个初始值为 0 的信号。count() 就是读取当前值。
注意是函数调用:
count()不是count。Signal 是一个函数,读取值需要调用它。这跟 Vue 的ref用.value读取不一样,但跟 Solid.js 一样。
修改值
// 直接赋值
count.set(5);
console.log(count()); // 5
// 基于原值更新
count.update(v => v + 1);
console.log(count()); // 6
set 是你知道新值是什么的时候用。update 是你需要基于旧值算出新值的时候用——传一个函数,函数的参数就是当前值,返回值是新值。
对比一下:
- Angular Signal:
count.set(5)/count.update(v => v + 1)- Vue
ref:count.value = 5/count.value++- React
useState:setCount(5)/setCount(v => v + 1)Angular 和 React 都明确区分"设置新值"和"基于旧值更新"两种操作。Vue 因为依赖 Proxy 的赋值操作,看起来更"自然"——但底层原理完全不同。
只读版本
有时候你希望外部能读取数据,但不能修改它:
private items = signal<TodoItem[]>([]);
// 暴露只读版本
readonlyItems = this.items.asReadonly();
外部通过 readonlyItems() 能读到值,但不能调用 .set() 或 .update()。这是封装的好习惯。
在模板中使用
在组件里定义一个 signal:
export class TodoComponent {
count = signal(0);
increment() {
this.count.update(v => v + 1);
}
}
在模板里,直接用 () 取值:
<p>当前计数:{{ count() }}</p>
<button (click)="increment()">+1</button>
看到没有?不需要 async pipe,不需要 .subscribe。Signal 比 RxJS Observable 更"原生"——Angular 的模板系统本身就认识 Signal,它会自动追踪依赖,当 signal 变化时只更新这个组件。
这一点跟 Vue 的
ref在模板中自动解包非常像。Vue 里你写{{ count }},Angular 里你写{{ count() }}——就差一对括号。
在 Service 中使用
Signal 不只是组件里能用,在 Service 里用更常见:
@Service()
export class TodoService {
private items = signal<TodoItem[]>([]);
getItems() {
return this.items.asReadonly();
}
addTodo(text: string) {
this.items.update(list => [...list, { id: Date.now(), text, done: false }]);
}
}
组件注入后,直接调用 service.getItems() 拿到只读 signal,模板里用 items() 取值。
前面第 4 篇我们已经写过类似的代码了,现在你应该明白:为什么 Service 里的 signal 变了,组件能自动更新?
因为组件的模板里用了
items(),Angular 知道"这个组件依赖了那个 signal"。signal 变了,Angular 精准更新这个组件,而不是检查所有组件。
3. computed() — 计算属性
基本用法
computed 是基于其他 signal 自动计算的值:
import { signal, computed } from '@angular/core';
const price = signal(100);
const quantity = signal(2);
const total = computed(() => price() * quantity());
console.log(total()); // 200
price.set(50);
console.log(total()); // 150 —— 自动重新计算
total 依赖 price 和 quantity 两个 signal。只要任何一个变了,total() 的值就会自动更新。
这就是"计算属性"的含义——你声明它怎么算,剩下的 Angular 帮你搞定。
惰性求值
computed 是惰性求值的。意思是:
- 如果没人读
total(),就算price变了,computed也不会重新计算。 - 只有当有人读取
total()时,它才检查依赖有没有变。变了就重算,没变就返回缓存的值。
这跟 Vue 的
computed一模一样——也是惰性的。React 的useMemo虽然也是缓存计算结果,但它是在组件渲染时主动计算的,不是真正的"惰性"。
实用的例子
在 Todo 应用里:
import { signal, computed, Service } from '@angular/core';
@Service()
export class TodoService {
private items = signal<TodoItem[]>([
{ id: 1, text: '学习 Angular 基础', done: true },
{ id: 2, text: '写一个 Todo 应用', done: true },
{ id: 3, text: '对比 React 和 Vue 的差异', done: false },
]);
// 计算属性:已完成数量
completedCount = computed(() =>
this.items().filter(t => t.done).length
);
// 计算属性:未完成数量
pendingCount = computed(() =>
this.items().filter(t => !t.done).length
);
// 计算属性:进度百分比
progress = computed(() => {
const total = this.items().length;
if (total === 0) return 0;
return Math.round((this.completedCount() / total) * 100);
});
}
在某个组件注入该全局服务:
@Component(...)
export class TodoComponent {
todoService = inject(TodoService);
}
在模板里直接用:
<p>进度:{{ todoService.progress() }}% ({{ todoService.completedCount() }}/{{ todoService.items().length }})</p>
每次 items 变化时,completedCount、pendingCount、progress 都会自动更新。不需要手动调用任何方法。
对比 Vue 的
computed(() => ...)和 React 的useMemo(() => ..., [...]),Angular 的computed跟 Vue 的几乎一样——都是自动收集依赖,不需要你手动声明依赖数组。React 的useMemo则需要你手动列出依赖,漏了或多了都可能出 bug。
4. effect() — 当数据变化时干点啥
基本用法
signal 存数据,computed 算数据。那数据变了之后想执行一些操作(比如存本地缓存、打日志、发请求)怎么办?
用 effect:
import { signal, effect } from '@angular/core';
const count = signal(0);
effect(() => {
console.log(`count 变成了: ${count()}`);
});
// 立即执行一次,打印: count 变成了: 0
count.set(1);
// 打印: count 变成了: 1
count.set(2);
// 打印: count 变成了: 2
effect 自动追踪它里面用到的所有 signal。任何一个 signal 变了,它自动重新执行。
而且要注意:effect 会立即执行一次(用来收集依赖)。第一次执行时,Angular 记下了"这个 effect 依赖了 count",后面 count 变化时才会触发。
这就是"副作用"(side effect)的意思——它不是计算数据,而是做跟数据变化相关的额外操作。类似于:
- Vue 的
watchEffect/watch- React 的
useEffectVue 的
watchEffect跟 Angular 的effect行为最像——也是自动收集依赖、立即执行一次。React 的useEffect则不同:它不是在"数据变化时执行",而是在"组件渲染后执行",并且依赖数组需要手动指定。
使用场景
@Service()
export class TodoService {
private items = signal<TodoItem[]>([]);
constructor() {
// 场景1:数据持久化——变化时存到 localStorage
effect(() => {
localStorage.setItem('todos', JSON.stringify(this.items()));
});
}
addTodo(text: string) {
this.items.update(list => [...list, { id: Date.now(), text, done: false }]);
}
}
其他常见场景:
- 日志追踪:数据变了打日志,用于调试或分析
- 数据同步:变化时自动同步到后端
- 本地缓存:如上面的 localStorage
- UI 联动:数据变化时触发一些 UI 操作(非模板绑定的场景)
清理
如果 effect 里有定时器或者订阅,需要手动清理:
effect(() => {
const id = setInterval(() => {
console.log('当前数据:', this.items());
}, 5000);
// 返回清理函数
return () => {
clearInterval(id);
};
});
返回的清理函数会在两种情况下执行:
- effect 重新运行前(清理旧依赖的副作用)
- 组件/Service 销毁时(防止内存泄漏)
这跟 React
useEffect的 cleanup 函数是一个道理。
需要注意的地方
effect 必须在注入上下文中调用。什么意思呢?
// ✅ 正确:在 constructor 里
@Service()
export class TodoService {
constructor() {
effect(() => { /* ... */ });
}
}
// ✅ 正确:在组件初始化时
export class TodoComponent {
constructor() {
effect(() => { /* ... */ });
}
}
// ❌ 错误:在任意函数里
function someFunction() {
effect(() => { /* ... */ }); // 报错!没有注入上下文
}
如果需要在外部的函数里创建 effect,可以用 inject(EffectRef) 或者把创建逻辑放到类的初始化过程中。
这一点跟 Vue 完全一样——
watchEffect也需要在setup()或script setup中调用。React 的useEffect更严格,只能在组件顶层调用。
5. 用 Signal 升级 Todo 组件
前面第 3 篇我们写了 TodoItemComponent,用的还是 @Input / @Output。现在用 Signal 的方式重新写一遍。
input() — 替代 @Input
import { Component, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TodoItem } from '../types';
@Component({
selector: 'app-todo-item',
standalone: true,
imports: [CommonModule],
templateUrl: './todo-item.html',
styleUrls: ['./todo-item.css']
})
export class TodoItemComponent {
// 旧写法:@Input() todo!: TodoItem;
// 新写法:input() 返回一个 signal
todo = input.required<TodoItem>();
// 旧写法:@Output() toggle = new EventEmitter<number>();
toggle = output<number>();
// 带默认值的 input
// 旧写法:@Input() label = '默认标签';
label = input('默认标签');
onToggle() {
this.toggle.emit(this.todo().id);
}
}
关键区别:
input()返回一个信号(signal),所以在模板里用todo()而不是todoinput.required<T>()表示"这个输入是必须的,不给就报编译错误"output()替代@Output+EventEmitter
你可能会问:“这跟
@Input有啥本质区别?”区别不在用法,在底层机制。
@Input的值变了,Zone.js 需要跑一轮变更检测才知道。而input()返回的是 signal,变了之后精准通知依赖这个输入的组件。如果你的应用大量使用input(),变更检测的开销会大幅降低。
模板里的变化
模板几乎一样,只是 todo 变成了 todo():
<li (click)="onToggle()" [class.done]="todo().done">
{{ todo().text }} - {{ todo().done ? '已完成' : '未完成' }}
</li>
注意 todo().done 和 [class.done]="todo().done"——todo 是一个 signal,所以需要调用它拿到值,再访问属性。
父组件不变
父组件传值的方式没有变化:
<app-todo-item
*ngFor="let item of items(); trackBy: trackById"
[todo]="item"
(toggle)="toggleDone($event)">
</app-todo-item>
[todo]="item"——[todo] 绑定到的是子组件的 input(),而 item 是父组件遍历 signal 数组拿到的普通对象。
trackBy — 列表渲染优化
看到上面代码里多了一个 trackBy: trackById 吗?
用 signal 加 *ngFor 时,推荐加上 trackBy。因为 signal 每次 update 都创建新数组,没有 trackBy 的话,Angular 会销毁所有 DOM 元素重新创建。加上 trackBy,它就知道"哦,id 为 2 的元素还是那个元素,只是数据变了,更新内容就行"。
export class TodoComponent {
trackById = (index: number, item: TodoItem) => item.id;
}
这跟 React 的
key属性是同一个道理。Vue 的v-for也推荐加:key。
完整代码一览
整合一下 Signal 版 Todo 应用的核心部分:
todo.ts:
import { Service, signal, computed } from '@angular/core';
import { TodoItem } from './types';
@Service()
export class TodoService {
private items = signal<TodoItem[]>([]);
readonly completedCount = computed(() =>
this.items().filter(t => t.done).length
);
constructor() {
this.items.set([
{ id: 1, text: '学习 Angular Signal', done: false },
{ id: 2, text: '理解 computed', done: false },
{ id: 3, text: '学会 effect', done: true }
]);
}
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));
}
}
todo.ts:
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TodoItemComponent } from '../todo-item/todo-item';
import { TodoService } from '../todo.service';
import { TodoItem } from '../types';
@Component({
selector: 'app-todo',
standalone: true,
imports: [CommonModule, FormsModule, TodoItemComponent],
templateUrl: './todo.html',
styleUrls: ['./todo.css']
})
export class TodoComponent {
private todoService = inject(TodoService);
items = this.todoService.getItems();
newTodoText = '';
trackById = (index: number, item: TodoItem) => item.id;
toggleDone(id: number) {
this.todoService.toggleDone(id);
}
addTodo() {
this.todoService.addTodo(this.newTodoText);
this.newTodoText = '';
}
deleteTodo(id: number) {
this.todoService.deleteTodo(id);
}
}
todo.html:
<h2>Todo 列表 ({{ todoService.completedCount() }}/{{ items().length }})</h2>
<div>
<input [(ngModel)]="newTodoText" placeholder="输入新的待办...">
<button (click)="addTodo()">添加</button>
</div>
<ul *ngIf="items().length > 0; else emptyState">
<app-todo-item
*ngFor="let item of items(); trackBy: trackById"
[todo]="item"
(toggle)="toggleDone($event)">
</app-todo-item>
</ul>
<ng-template #emptyState>
<p>还没有待办,输入一条开始吧</p>
</ng-template>
注意模板里的 items()——signal 在模板里需要调用,另外 for 循环时,给每个Item添加了trackBy(类似key)。
跟第 4 篇 RxJS 版本的对比:
- RxJS 版本:
items$是 Observable,模板用items$ | async- Signal 版本:
items是 signal,模板用items()- RxJS 每次修改要手动
.next(),Signal 每次修改用.set()/.update()- Signal 版本少导入一个
AsyncPipe,代码更少
6. 组件生命周期
终于到了生命周期。什么是生命周期?
一个组件从"出生"到"死亡"的过程。
具体来说:
组件类被创建 → 属性初始化 → 模板渲染 → 数据变化 → 组件销毁
Angular 在这个过程的几个关键节点上安了"钩子"(hooks),你可以在这些节点上插入自己的逻辑。
有哪些钩子?
按执行顺序排列:
| 钩子 | 触发时机 | 常用场景 |
|---|---|---|
ngOnChanges |
@Input 属性变化时(包括第一次) |
响应输入变化 |
ngOnInit |
第一次 ngOnChanges 之后 |
初始化数据、调用 Service |
ngDoCheck |
每次变更检测时 | 自定义变更检测 |
ngAfterContentInit |
内容投影初始化后 | 处理投影内容 |
ngAfterContentChecked |
每次投影内容变更检测后 | — |
ngAfterViewInit |
组件视图初始化后 | 操作 DOM、@ViewChild |
ngAfterViewChecked |
每次视图变更检测后 | — |
ngOnDestroy |
组件销毁前 | 清理订阅、定时器 |
看起来挺多,但常用的只有四个:ngOnChanges、ngOnInit、ngAfterViewInit、ngOnDestroy。其他的了解就行。
使用方式
先从@angular/core库中导入生命周期的接口(如下面代码导入了 OnInit 和 OnDestroy 接口),然后在组件中实现这两个接口及其方法即可。
import { Component, OnInit, OnDestroy } from '@angular/core';
@Component({
selector: 'app-demo',
standalone: true,
template: `<p>{{ message }}</p>`
})
export class DemoComponent implements OnInit, OnDestroy {
message = '';
// 构造函数:最先执行,此时组件还没完成输入绑定
constructor() {
console.log('1. 构造函数');
// 注意:这里还不能依赖 @Input 的值
}
// 第一次 ngOnChanges 之后执行
ngOnInit() {
console.log('2. ngOnInit');
// 可以安全地使用 @Input 属性的值了
this.message = '组件已初始化';
}
// 组件即将销毁
ngOnDestroy() {
console.log('3. ngOnDestroy');
// 清理工作:取消订阅、清除定时器
}
}
注意:Angular 的生命周期钩子和 React/Vue 的 Hooks 完全是两回事。
- React Hooks(
useState、useEffect):是用来"在函数组件中使用状态和副作用"的函数。它们是响应式的工具。- Vue 组合式 API(
onMounted、onUnmounted):是组件生命周期的回调。- Angular 生命周期钩子:是组件类可以实现的可选接口。它们是组件生命周期的回调。
Angular 没有
useState这种东西——类属性的变化由变更检测自动追踪。Signals 出现后,Angular 的响应式方式跟 Vue 3 更接近了,但生命周期钩子的机制没变。
执行顺序图解
假设组件树是 AppComponent → ChildComponent:
AppComponent 构造函数
AppComponent.ngOnInit
ChildComponent 构造函数
ChildComponent.ngOnInit
ChildComponent.ngAfterViewInit
AppComponent.ngAfterViewInit ← 最后才执行!
注意这里的细节:父组件的 ngOnInit 在子组件的 constructor 之前执行。Angular 不是"先把所有组件构建完再初始化",而是父组件先执行自己的初始化,在渲染模板的过程中才去创建子组件。所以父 ngOnInit 在前,子 constructor 在后。
顺序的规律很简单:
- 从上到下(父先→子后):构造函数、
ngOnInit——父组件先初始化,再初始化子组件 - 从下到上(子先→父后):
ngAfterViewInit——子组件的视图先就绪,父组件等所有子组件都就绪了才算数;ngOnDestroy也是子先销毁,父后销毁
你只需要记住:ngAfterViewInit 是子组件先执行,父组件最后执行。因为父组件的视图包含了子组件,所以必须等所有子组件视图就绪,父组件才有资格说"我的视图初始化完毕了"。
这跟 Vue 的
onMounted执行顺序类似——子组件先 mounted,父组件后 mounted。React 的useEffect则相反:父组件先执行 effect,子组件后执行。
用生命周期的实际例子
在 Todo 应用中,用 ngOnInit 加载数据,用 ngOnDestroy 清理:
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { TodoService } from './todo.service';
@Component({
selector: 'app-stats',
standalone: true,
template: `<p>{{ message }}</p>`
})
export class StatsComponent implements OnInit, OnDestroy {
private todoService = inject(TodoService);
private intervalId: any;
message = '';
ngOnInit() {
// ✅ 初始化时加载数据
this.updateStats();
// 定时刷新统计(示例:每 30 秒)
this.intervalId = setInterval(() => {
this.updateStats();
}, 30000);
}
private updateStats() {
const items = this.todoService.getItems()();
this.message = `总计: ${items.length}, 已完成: ${items.filter(i => i.done).length}`;
}
ngOnDestroy() {
// ✅ 组件销毁时清理定时器,防止内存泄漏
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
}
另一个常见场景:在 ngAfterViewInit 中操作 DOM:
@Component({
selector: 'app-todo',
standalone: true,
template: `<input #inputRef />`
})
export class TodoComponent implements AfterViewInit {
@ViewChild('inputRef') inputEl!: ElementRef<HTMLInputElement>;
ngAfterViewInit() {
// 视图初始化后,自动聚焦输入框
this.inputEl.nativeElement.focus();
}
}
为什么不能在 ngOnInit 里操作 DOM? 因为 ngOnInit 执行时,模板还没渲染到 DOM 上,@ViewChild 还是 undefined。必须等到 ngAfterViewInit。
Vue 里等价的场景:
onMounted(() => { /* DOM 可用 */ })。React 里:useEffect(() => { /* DOM 可用 */ }, [])。Angular 的ngAfterViewInit跟 Vue 的onMounted最像。
effect 和 ngOnDestroy 的配合
前面我们用了 effect 来自动监控数据变化。如果 effect 里有额外的清理逻辑,ngOnDestroy 里还需要做什么吗?
@Component({...})
export class TodoComponent implements OnDestroy {
constructor() {
// effect 会自动在组件销毁时清理
effect(() => {
console.log('数据变了:', this.items());
});
}
ngOnDestroy() {
// 手动清理非 signal 相关的东西,比如 setTimeout
// effect 自己会处理自己的清理
}
}
effect 会自己清理(它返回的清理函数在组件销毁时自动执行)。ngOnDestroy 主要用于清理非 Angular 资源——比如上面例子里的 setInterval、第三方库的实例、WebSocket 连接等。
生命周期的跨框架对比
| 操作 | Angular | Vue 3 | React |
|---|---|---|---|
| 组件创建 | constructor |
setup() |
函数体 |
| 输入属性变化 | ngOnChanges |
watch(() => props.x) |
useEffect + deps |
| 初始化 | ngOnInit |
onMounted |
useEffect([], []) |
| DOM 可用 | ngAfterViewInit |
onMounted |
useEffect([], []) |
| 数据变化 | effect |
watch / watchEffect |
useEffect + deps |
| 销毁清理 | ngOnDestroy |
onUnmounted |
useEffect cleanup |
7. 本章总结
这一篇信息量不小,来串一下:
Signals 核心三件套:
signal(value):创建响应式数据,用()读取,set/update修改computed(fn):计算属性,自动追踪依赖,惰性求值effect(fn):监听信号变化执行副作用,自动清理
组件通信的 Signal 升级:
input()/input.required():替代@Inputoutput():替代@Output+EventEmitter
生命周期(记四个就够了):
| 钩子 | 时机 | 干吗用 |
|---|---|---|
ngOnInit |
组件初始化完成 | 调 Service 拿数据(Http) |
ngAfterViewInit |
视图渲染完毕 | 操作 DOM、操作子组件 |
ngOnChanges |
@Input 变化时 |
响应父组件传值变化 |
ngOnDestroy |
组件销毁前 | 清理定时器/订阅/监听器 |
下一篇我们来路由——让你的 Angular 应用从"一个页面"变成"多个页面",并且在页面之间自由切换。
更多推荐


所有评论(0)