0%

angular-signal

What’s Signal?

先看一下官方定义:

1
A signal is a wrapper around a value that notifies interested consumers when that value changes. Signals can contain any value, from primitives to complex data structures.

翻译一下:Signal是一个包装器,它包装了一个值,并在该值发生变化时通知感兴趣的消费者。Signal可以包含任何值,从基本类型到复杂的数据结构。据说signal借鉴了solid.js的思想(看这里),但是我没有考证过。

Signal是Angular中重要的新特性,从xxx版开始引入,至xxx版本稳定,Signal的出现,彻底更改了Angular的变更检测机制,原本基于Zone.js的变更检测机制被Signal + OnPush取代,Signal的出现,使得Angular的性能得到了极大的提升。

今天,我们就来揭开Signal的神秘面纱。

Why Signal?

首先,我们来探讨一下Angular为什么要引入Signal?

在Signal之前,Angular是基于Zone.js做变更检测的,不可否认Zone.js是一个非常强大的库,但是它也有一些缺点,比如:

  1. 性能问题:Zone.js的性能并不是很好,特别是在大型项目中,Zone.js的性能问题会暴露的更加明显。除非你使用了OnPush策略,否则Zone.js会在每次异步操作后都会触发变更检测,这样会导致性能问题。
  2. 由于Zone.js要monkey patch所有的异步操作(在Angular app启动时),所以Angular项目在启动的时候会有一些性能损失。
  3. 有些异步操作无法monkey patch,比如async/await,Angular的解决办法是将其降级到Promise。如今几乎所有浏览器都支持async/await,这种做法显然不太合理。

基于以上原因,Angular Team很早就考虑移除Zone.js,但是Zone.js的变更检测机制是Angular的核心,移除Zone.js,意味着Angular无法自动进行变更检测,也就是说变更检测的触发机制由Angular通过Zone.js自动检测,变成了需要用户手动触发,而Signal就是为此而服务的。

通过Signal定义的数据,当它变化时,Angular会自动进行变更检测,这样就不需要Zone.js了,也就解决了上面提到的问题。

signal 语法

Signal语法非常简单,下面来看看如何定义和使用signal。

定义signal

1
const count = signal(0);

上面是一个简单的signal定义,它的初始值是0。如果要定义复杂的值,可以使用泛型:

1
const user = signal<User>({name: 'zdd', age: 18}); // generic type

读取signal的值

1
2
// Signals are getter functions - calling them reads their value.
console.log('The count is: ' + count());

设置signal的值

1
count.set(1);

更新signal的值(根据前一个值)

1
2
// Signals can also be updated by passing a function that receives the current value and returns the new value.
count.update(value => value + 1);

signal的分类

Signal有两种类型:SignalCompute SignalSignal是指普通的signal,它是可读写的;Compute Signal是指由其他signal计算得到的signal,它是只读的。

上面的count就是普通的Signal,可读写,下面我们看一个compute signal的例子。下面代码中,doubleCount是一个compute signal,它的值是由count的值乘以2得到的。

1
2
const count = signal(0);
const doubleCount = computed(() => count() * 2); //
Signal Compute Signal
读写性 可读写 只读

一个例子

我们以一个todo list为例,来看看Signal的使用。首先我们来定义List组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// list-item.model.ts
import {Component, signal} from '@angular/core';
import {ListItemComponent} from '../list-item/list-item.component';
import {ListItem} from '../list-item/list-item.model';

@Component({
selector: 'app-list',
imports: [ListItemComponent],
templateUrl: './list.component.html',
styleUrl: './list.component.scss'
})
export class ListComponent {
readonly listItems = signal<ListItem[]>([
{id: '1', name: 'item 1'},
{id: '2', name: 'item 2'},
])

addItem() {
const nextId = (this.listItems().length + 1).toString();
this.listItems.set([...this.listItems(), {id: nextId, name: `item ${nextId}`}]);
}

removeItem(id: string) {
this.listItems.set(this.listItems().filter(item => item.id !== id));
}
}

在上面的代码中,我们使用readonly listItems = signal<ListItem[]>([...]);定义了一个signal, 它的初始值是一个包含两个ListItem的数组。我们还定义了一个addItem方法,用来添加一个新的ListItem,以及一个removeItem方法,用来删除一个ListItem。
signal与普通的js变量不同,它的读取和写入需要特殊的语法。

  • 读取signal的值:this.listItems() - 注意不是this.listItems,后面要加括号,有点像函数调用。
  • 写入signal的值:this.listItems.set(newValue)
1
2
3
4
5
6
7
8
9
<!--list.component.html--> 
<p>list works!</p>
<div class="list">
@for (item of listItems(); track item.id) {
<app-list-item [id]="item.id" [name]="item.name" (removeItem)="removeItem($event)" />
}
</div>

<button class="add-button" (click)="addItem()">Add Item</button>

然后我们来定义ListItem组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// list-item.model.ts
import {Component, input, output} from '@angular/core';

@Component({
selector: 'app-list-item',
imports: [],
template: `
<div class="list-item">
<div class="list-item-content">
<div>id: {{id()}}</div>
<div>name: {{name()}}</div>
</div>
<div class="list-item-actions">
<button (click)="onRemoveItem(id())">Remove</button>
</div>
</div>
`,
styleUrl: 'list-item.component.scss'
})
export class ListItemComponent {
id = input.required<string>();
name = input(''); // 注意,这是新的input语法。

removeItem = output<string>(); // 这是新的output语法。

onRemoveItem(id: string) {
this.removeItem.emit(id);
}
}

Compute Signal

Compute Signal是指依赖于其他Signal计算得到的Signal,它是只读的,当依赖的signal值变化时,compute signal的值也会相应变化。下面是一个例子:

1
2
const count: WritableSignal<number> = signal(0);
const doubleCount: Signal<number> = computed(() => count() * 2);

Computed signal有两个重要特性:

  1. lazy load - 直到你第一次读取它的值时,它才会计算。
  2. memoization - 第一次读取后缓存值,当依赖的signal值没有变化时,它不会重新计算,而是直接读取缓存的值。当依赖的signal值变化时,重新计算并缓存。

比较函数

signal是如何比较新旧值的呢?signal使用Object.is来比较新旧值,如果新旧值相等,那么signal不会触发变更检测。对于js对象来说,Object.is相当于引用(地址)比较,也就是说只有两个对象引用相等时,Object.is才会返回true。

1
2
3
4
5
const a = [1, 2, 3];
const b = [1, 2, 3];
const c = a;
console.log(Object.is(a, b)); // false
console.log(Object.is(a, c)); // true

创建signal时,你可以指定一个比较函数来改变默认的比较方式,比如使用lodashisEqual函数来进行深比较,这样当两个对象的值相等时,signal也会触发变更检测。

1
2
3
4
5
6
import _ from 'lodash';
const data = signal(['test'], {equal: _.isEqual});
// Even though this is a different array instance, the deep equality
// function will consider the values to be equal, and the signal won't
// trigger any updates.
data.set(['test']);

References

  1. https://angular.dev/guide/signals
  2. https://fullstackladder.dev/blog/2023/05/07/introduction-angular-signals/