Angular Signals:响应式编程的新思维模式,不仅是新API

本文深入探讨Angular Signals的核心概念,将其视为响应式变量而非数据流,分析其与RxJS的优劣对比,揭示计算信号的缓存机制陷阱,并提供扁平化状态建模和表单标签定制等实战技巧。

Angular Signals:响应式编程的新思维模式,不仅是新API

Angular Signals不仅仅是另一个功能。它们代表了一种不同的数据流思考方式。如果你来自RxJS或标准的@Input()/@Output()绑定,可能会将Signals视为可观察值的更简单语法。然而,这就像说小提琴只是更小的大提琴。乐器的形状影响你创作的音乐类型。

在本文中,我不会重复文档或引导你完成另一个计数器示例。相反,我将介绍Signals启用的一种新思维方式,以及我在生产中使用它们时遇到的一些实际挑战。

Signals作为响应式变量,而非流

将Signals视为响应式变量,而不是数据流。这是视角的关键变化。在RxJS中,我们通常将值推送到流中,组合它们,并通过subscribe()响应副作用。Signals扭转了这个概念。你像变量一样读取它们。Angular自动跟踪依赖关系并触发反应。

以下是我在代码中解释Signals的最佳方式:

1
2
3
const firstName = signal('John');
const lastName = signal('Doe');
const fullName = computed(() => `${firstName()} ${lastName()}`);

在这个例子中,当firstName或lastName更改时,fullName会自动重新计算。你不需要考虑map、combineLatest或拆卸逻辑。你只需声明关系。

如果这感觉像Vue或SolidJS,那并非巧合。

陷阱1:隐式依赖可能适得其反

当你在computed()或effect()中读取Signal时,Angular会将该读取作为依赖关系进行跟踪。但当你没有意识到这些读取时,这可能会很快出错。

1
2
3
4
5
let counter = signal(0);
const doubled = computed(() => {
  console.log('Recomputing...');
  return counter() * 2;
});

你可能期望这仅在计数器更改时运行,但如果你在同一函数中意外读取另一个Signal(例如日志标志),它也会成为依赖关系。突然之间,切换调试模式标志开始重新计算你的数学逻辑。

提示:保持computed和effect逻辑狭窄且确定。否则,你将无法调试幽灵更新。

Signals与RxJS:Signals的闪光点与不足

让我们明确一点:Signals不会取代RxJS。它们设计为与RxJS协同工作。但理解何时使用每个至关重要。

使用案例 首选Signals 首选RxJS
本地组件状态 X
派生UI数据 X
事件流(例如用户输入) X
跨模块共享状态(通过服务Signals)
具有重试的复杂异步流 X

Signals擅长建模随时间变化的值。RxJS擅长建模随时间变化的事件。

陷阱2:计算信号的缓存不如你所想

我发现的一个令人惊讶的事情:computed()不会像React的useMemo()那样记忆化,甚至不像你从getter中期望的那样。

每次你从computed()信号读取时,如果其输入已更改,逻辑会重新运行。但如果你在模板中多次调用它(例如在*ngIf中并在{{ }}中再次调用),你可能会多次支付成本。

提示:如果计算成本高昂,请考虑将其存储在组件类的本地const中,并在模板中仅引用该const。或者将其包装在另一个信号中。

重新思考状态形状:Signals喜欢扁平,而非深层

在使用服务和RxJS的经典Angular中,通常这样建模状态:

1
2
3
4
5
const state$ = new BehaviorSubject({
  user: null,
  settings: {},
  isLoading: false
});

在Signals中,深度嵌套的响应式对象很尴尬。你不能说user().settings().theme()——那是对读取的读取的读取。相反,你想要扁平化:

1
2
const user = signal<User | null>(null);
const settings = signal<Settings>({});

提示:用其自己的信号建模每个状态片段。你将获得灵活性和更轻松的响应式控制。

实际场景:表单标签定制

假设你有一个SearchSidebarComponent,并且想要从父级自定义其标签。以下是天真的方式:

1
labels = input<MapSidebarLabels | undefined>(undefined);

如果你尝试派生计算值会发生什么?

1
2
3
4
labelsFinal = computed(() => {
  const raw = this.labels();
  return { ...raw, title: raw.title.toUpperCase() };
});

现在,假设在父级中你写:

1
<search-sidebar [labels]="labelsFinal()" />

这有效,但你在信号内调用信号。如果你将模板更改为<search-sidebar [labels]="labelsFinal" />,它会因类型不匹配而失败。

提示:Angular的输入系统尚未完全信号原生。在此之前,在传递输入之前扁平化值。

陷阱3:effect()立即运行——可能再次运行

与仅在发出内容时触发的RxJS subscribe()不同,effect()在创建时触发一次,即使信号尚未更改。

1
2
3
effect(() => {
  console.log("API call for", userId());
});

即使userId尚未更改,这也会立即运行一次。在effect()中放置HTTP调用或分析跟踪等副作用时要小心。

提示:如果需要,使用null检查或提前返回来保护effect()逻辑。

最终思考

Angular中的Signals不仅仅是新语法,它们是思维模式的转变。一旦你停止思考可观察对象,开始思考响应的变量,你会发现你的组件更小、更快、更易于推理。

但像任何新工具一样,Signals带有锐利的边缘。了解权衡,学习模式,最重要的是,不要将Signals视为花哨的getter。它们比那强大得多,但前提是你理解模型。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计