信号:该做什么和不该做什么
原始封面照片由Shyam在 Unsplash 上拍摄。
如果我们问任何一位 Angular 开发者“现在 Angular 中最热门的话题是什么?”,几乎 100% 肯定每个人都会回答“信号!”。而且他们说得没错。信号确实是Angular 目前最热门的话题。
当然,现在有大量的文章、视频、博客文章等等,深入细致地描述了信号是什么、它们的工作原理以及不同的用例,因此我们可以放心地假设 Angular 社区中的几乎每个人都了解信号,甚至可能至少尝试过它们。但最近一个显而易见的事实是,社区需要一套特定的规则来使用信号,或者说,一套“该做什么”和“不该做什么”。在今天的文章中,我们将深入探索信号的世界,找出一些不适合以特定方式使用信号的用例,并看看我们应该怎么做。让我们开始吧!
不要使用 setter 将输入转换为信号
当信号在 v16 中首次被放弃时,许多功能的支持都受到了限制(因为信号本身还处于实验阶段)。例如,无法将信号用作组件的输入。因此,社区想出了一个变通方案:使用 setter 将输入转换为信号。这个想法很简单:我们为一个输入和一个单独的信号创建一个 setter,并在 setter 中将该信号的值设置为输入的值。然后,我们就可以将该信号用作组件的输入。
@Component({
selector: 'app-my-component',
template: `
<div>
{{ inputSignal() }}
</div>
`
})
export class SomeComponent {
inputSignal = signal<string>();
@Input() set input(value: string) {
this.inputSignal.set(value);
}
}
显然,这相当冗长,尤其是在输入量很大的情况下。这确实有很多样板代码,所以让我们看看能做些什么。
请使用信号输入
从 Angular v17.1 开始,出现了一种为组件和指令声明输入属性的新方法:input
函数。该函数声明一个输入,但其值是一个信号,而不是一个普通的属性。我们现在可以简化示例:
@Component({
selector: 'app-my-component',
template: `
<div>
{{ inputSignal() }}
</div>
`
})
export class SomeComponent {
inputSignal = input<string>('default value');
}
注意:从 v17.1 开始,输入信号不稳定,但很快就会稳定。
当然,这要好得多。它还支持常规输入的所有功能,例如,要创建一个required
输入,我们可以这样做:
export class SomeComponent {
inputSignal = input.required<string>(); // we do not need to provide a default value here
}
我们还可以使用变压器:
export class SomeComponent {
booleanSignal = input(true, {transform: booleanAttribute});
}
和别名:
export class SomeComponent {
inputSignal = input.required({alias: 'condition'});
}
警告:信号输入是只读的,无法在子组件中为其设置其他值
当然,之前的方法只是为了弥补缺失的功能,随着时间的推移,所有 Angular 开发者都会转向信号输入。现在,让我们关注一些更严重的问题,这些问题在任何代码库中都可能发生。
不要通过 HTTP 调用来检索数据
这是一个在任何代码库中都可能发生的常见错误。假设我们的模板中有一个输入框,我们希望在输入框发生变化时发出 HTTP 调用。我们可以这样做:
@Component({
selector: 'app-my-component',
template: `
<input (input)="query.set($event.target.value)" />
<ul>
@for (item of items) {
<li>{{ item.name }}</li>
}
</ul>
`
})
export class SomeComponent {
query = signal<string>();
items: Item[] = [];
constructor(private http: HttpClient) {
effect(() => {
this.http.get<Item[]>(`/api/items?q=${this.query()}`).subscribe(items => {
this.items = items;
});
});
}
}
然而,这带来了几个问题。首先,该items
集合不是信号(我们可以将其变成信号,但这样就必须{allowSignalWrites: true}
在 effect 上进行设置,这是一种更糟糕的做法),这将使将来难以使用无区域变化检测。其次, 的声明items
与其实际设置值的位置是分开的,这使得理解其工作原理变得更加困难;最后, effect 的触发与 RxJS 解耦,这意味着我们无法真正利用计时操作符的功能(例如,debounceTime
为了防止不必要的 HTTP 调用),因为每次对信号的更改query
都会创建一个单独的Observable
。
请使用toSignal
+toObservable
然而,利用信号和 RxJS 之间的互操作性,有一个简单的方法可以规避所有这些问题。让我们来创建一个更简洁、更强大的方法来实现这一点:
@Component({
selector: 'app-my-component',
template: `
<input (input)="query.set($event.target.value)" />
<ul>
@for (item of items()) {
<li>{{ item.name }}</li>
}
</ul>
`
})
export class SomeComponent {
http = inject(HttpClient);
query = signal<string>();
items = toSignal(
toObservable(this.query).pipe(
debounceTime(500),
switchMap(query => this.http.get<Item[]>(`/api/items?q=${query}`))
),
);
}
在这里,我们首先使用 进入 RxJS 的世界toObservable
,在那里实现异步操作,然后再使用 回到信号处理toSignal
。这样,我们既能充分利用 RxJS 的强大功能,又能最终获得信号。此外,我们还解决了用户快速输入时发出多个请求的时序问题。
让我们来看一个更模糊的例子。
别忘了untracked
假设我们有一个CompanyDetailsComponent
页面,显示公司数据及其员工列表,并允许进行常规编辑,例如,我们可以更改公司的描述文本。由于员工数量可能很多,因此我们希望能够像上例一样通过查询来搜索所有员工。但是,我们并非搜索所有员工,而是仅搜索在特定公司工作的员工,这意味着每次搜索员工时,我们都必须使用公司 ID。我们可以这样做:
@Component({
selector: 'app-company-details',
template: `
<div>
<h2>{{ company().title }}</h2>
<input placeholder="Company description"
[ngModel]="company().description"
(ngModelChange)="updateCompanyDescription($event)" />
</div>
<input (input)="query.set($event.target.value)" />
<ul>
@for (employee of companyEmployees()) {
<li>{{ item.name }}</li>
}
</ul>
`
})
export class CompanyDetailsComponent {
http = inject(HttpClient);
query = signal<string>();
company = input.required<Company>();
employees = input.required<Employee>();
companyEmployees = computed(() => {
return this.employees().filter(employee => employee.companyId === this.company().id && employee.name.includes(this.query()));
});
@Output() companyUpdated = new EventEmitter<Company>();
updateCompanyDescription(description: string) {
this.companyUpdated.emit({
...this.company(),
description
});
}
}
我们可以看到这个例子有些复杂。让我们将其分解以简化:
- 该组件接收所有员工列表和公司详细信息
- 我们使用计算信号来获取在这家特定公司工作的员工名单并匹配查询
- 我们可以更新公司描述,并在发生更新时发出事件,以便父级发出必要的 HTTP 请求
- 我们在模板中使用计算信号
这将正常工作,但是,我们忽略了一些东西:当描述更新时,它会被发送到父组件,然后父组件会向company
信号输入发送一个新的引用,并且因为它也用于计算信号,这将不必要地companyEmployees
触发计算,因为我们知道不能改变,我们并不真正关心,或任何其他属性。id
description
如果我们使用效果来触发 HTTP 调用来搜索员工,情况可能会变得更糟,导致每次用户在不相关的description
输入中输入某些内容时都会产生大量不必要的 HTTP 调用。
请使用untracked
值得庆幸的是,这个问题有一个非常简单的解决方案:使用untracked
函数读取信号的值,而不是像通常那样调用它。untracked
这将返回信号的当前值,而不会将其标记为当前计算信号/效果的依赖项,这意味着当该信号的值发生变化时,计算不会被触发。开始吧:
companyEmployees = computed(() => {
return this.employees().filter(employee => employee.companyId === untracked(this.company).id && employee.name.includes(this.query()));
});
注意:一般来说,在
computed
或effect
函数回调中调用信号时要小心,严格检查它们是否真的需要依赖,untracked
如果不需要则使用。
问题解决了!现在,让我们来看一个非常复杂的例子。大多数人除非明确意识到这个问题,否则都会忽略它。
不要toSignal
在服务中使用将可观察对象公开为信号。
许多 Angular 应用程序不使用 NgRx 或 Akita 等专用的状态管理库,而是使用服务来管理应用程序的状态。这是一种非常有效的方法,并且效果非常好。大多数此类服务使用 RxJS 的Observable
-s(更具体地说是Subject
-s 或BehaviorSubject
-s)将状态更改传播到整个应用程序。当然,现在,将toSignal
暴露Observable
为信号可能很诱人,以便应用程序的其余部分可以直接使用它。然而,这可能会导致一系列问题。一个明显的问题是,当使用它的组件被销毁时,信号不会自动被释放,并且Observable
会继续发出值,这可能不是预期的结果。此外,Observable
-s 通常更加通用,某些组件可能会选择以略有不同的方式处理这些值,如果将值暴露为信号,则无法实现这一点。让我们看一个例子:
@Injectable({
providedIn: 'root'
})
export class MessagesStore {
private messagesService = inject(MessagesService);
private messages$ = this.messagesService.getMessages();
messages = toSignal(this.messages$);
unreadMessages = computed(() => {
return this.messages().filter(message => !message.read);
});
addMessage(message: Message) {
this.messagesService.addMessage(message);
}
}
在这个场景中,我们正在从某个外部服务(FireBase、WebSocket 等)实时读取消息流,源会在第一个订阅者出现时连接到服务,并在最后一个订阅者消失时断开连接。这对于 来说是一个完美的用例Observable
,但对于信号来说却并非如此。虽然我们获得了一些好处,例如使用computed
,但我们也确保连接在服务创建后立即启动,并且实际上永远不会停止,即使使用这些信号的组件被销毁,因为 才是MessagesStore
订阅者,并且直到应用程序关闭才会被销毁。
在组件中使用或toSignal
通过连接方法公开状态
那么,我们该如何解决这个问题呢?一种简单的方法是完全不暴露来自服务的信号,只调用toSignal
那些消费状态的组件。这样,我们就可以确保仅在组件创建时才建立与源的连接,并在所有消费组件销毁时销毁连接。对于大多数应用来说,这是一种常用的方法,然而,它可能有点繁琐,因为我们可能需要到处复制粘贴计算信号(例如,unreadMessages
在前面的例子中)。
因此,我们可以选择使用特定的方法将组件“连接”到Observable
,同时仍然返回位于组件注入上下文(而不是服务注入上下文)中的信号。让我们看看如何修改前面的示例以使用这种方法:
@Injectable({
providedIn: 'root'
})
export class MessagesStore {
private messagesService = inject(MessagesService);
private messages$ = this.messagesService.getMessages();
addMessage(message: Message) {
this.messagesService.addMessage(message);
}
messages() {
return toSignal(this.messages$);
}
unreadMessages(messages: Signal<Message[]>) {
return computed(() => {
return messages().filter(message => !message.read);
});
}
}
这样,我们就可以使用该messages
方法来实际获取整体消息,而对于其他计算属性,我们可以使用其他返回计算属性的方法。在组件中,我们可以简单地这样做:
@Component({
selector: 'app-messages',
template: `
<ul>
@for (message of unreadMessages(messages())) {
<li>{{ message.text }}</li>
}
</ul>
`
})
export class MessagesComponent {
private readonly messages = inject(MessagesStore);
messages = this.messagesStore.messages();
unreadMessages = this.messagesStore.unreadMessages(this.messages);
}
这样,我们就可以确保在组件消失时,组件中订阅的流会被安全地处理掉。当然,还有其他方法可以实现这一点,但我们这里列出的是最简单的方法。您可以尝试找到最适合您的项目和代码库的其他方法,但请记住不要toSignal
在共享服务中使用该函数。
结论
本文旨在为 Angular 开发者提供处理信号的一套小规则。信号确实令人兴奋,现在越来越多的团队在项目中采用它,但它也存在一些需要注意的问题,务必谨慎使用。如果您知道更多类似的问题,欢迎留言——让我们一起看看如何解决!
小促销
你可能已经知道,去年我忙着写一本书。这本书名为《现代 Angular》,全面介绍了我们在最新版本(v14-v17)中所有令人惊叹的新功能,包括独立功能、改进的输入、信号(当然!)、更好的 RxJS 互操作性、服务器端渲染等等。如果你对此感兴趣,可以在这里找到。手稿已经全部完成,还剩下一些润色工作,因此目前处于抢先体验阶段,前五章已经发布,更多内容即将发布。如果你想随时了解最新进展,可以在Twitter或LinkedIn上关注我,我会在那里发布任何新章节或促销活动。
鏂囩珷鏉ユ簮锛�https://dev.to/this-is-angular/signals-the-do-s-and-the-dont-s-40fk