Skip to content

Signals • 概览 • Angular

Published: at 12:00 AM

提示:在深入了解本综合指南之前,查看 Angular 的基础知识

什么是 signals?

一个 signal 是一个围绕值的包装器,当该值改变时会通知感兴趣的消费者。Signals 可以包含任何值,从原始值到复杂的数据结构。

您通过调用 signal 的 getter 函数来读取 signal 的值,这允许 Angular 跟踪 signal 的使用位置。

Signals 可能是 可写的只读的

可写的 signals

可写的 signals 提供了一个直接更新它们值的 API。您通过调用带有 signal 初始值的 signal 函数来创建可写的 signals:

const count = signal(0);

// Signals 是 getter 函数 - 调用它们读取它们的值。
console.log("计数是: " + count());

要改变可写 signal 的值,可以直接 .set()

count.set(3);

或者使用 .update() 操作从前一个值计算新值:

// 将计数增加 1。
count.update(value => value + 1);

可写的 signals 有 WritableSignal 类型。

计算 signals

计算 signal 是从其他 signals 派生其值的只读 signals。您使用 computed 函数并指定一个派生来定义计算 signals:

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

doubleCount signal 依赖于 count signal。每当 count 更新时,Angular 知道 doubleCount 也需要更新。

计算 signals 是懒计算且被记忆的

doubleCount 的派生函数直到您第一次读取 doubleCount 时才运行以计算其值。然后缓存计算出来的值,如果您再次读取 doubleCount,它将返回缓存的值而不重新计算。

如果您之后改变了 count,Angular 知道 doubleCount 的缓存值不再有效,下次您读取 doubleCount 时,它的新值将被计算。

因此,您可以在计算 signals 中安全地执行计算成本高的派生,例如过滤数组。

计算 signals 不是可写的 signals

您不能直接给计算 signal 赋值。也就是说,

doubleCount.set(3);

会产生一个编译错误,因为 doubleCount 不是 WritableSignal

计算 signal 的依赖项是动态的

只有在派生过程中实际读取的 signals 被跟踪。例如,在这个计算中,只有当 showCount signal 为 true 时,count signal 才被读取:

const showCount = signal(false);
const count = signal(0);
const conditionalCount = computed(() => {
  if (showCount()) {
    return `计数是 ${count()}。`;
  } else {
    return "这里没有什么可看的!";
  }
});

当您读取 conditionalCount 时,如果 showCountfalse,则返回 “这里没有什么可看的!” 消息,不会 读取 count signal。这意味着如果您稍后更新 count,它将 不会 导致 conditionalCount 的重新计算。

如果您将 showCount 设置为 true 然后再次读取 conditionalCount,派生将重新执行并取 showCounttrue 的分支,返回显示 count 值的消息。改变 count 将使 conditionalCount 的缓存值失效。

注意,依赖项在派生过程中可以被移除和添加。如果您稍后将 showCount 再次设置为 false,那么 count 将不再被认为是 conditionalCount 的依赖项。

OnPush 组件中读取 signals

当您在 OnPush 组件模板中读取 signal 时,Angular 跟踪该 signal 作为该组件的依赖项。当该 signal 的值改变时,Angular 自动标记该组件,确保下次变更检测运行时更新它。有关 OnPush 组件的更多信息,请参考跳过组件子树指南。

Effects

Signals 之所以有用,是因为它们在更改时会通知感兴趣的消费者。effect 是一个操作,当一个或多个 signal 值变化时就会运行。您可以使用 effect 函数创建一个 effect:

effect(() => {
  console.log(`当前计数是: ${count()}`);
});

Effects 总是会至少运行一次。 当一个 effect 运行时,它跟踪任何 signal 值读取。每当这些 signal 值中的任何一个改变时,effect 就会再次运行。与计算 signals 类似,effects 动态地跟踪它们的依赖项,并且只跟踪最近执行中读取的 signals。

Effects 总是以异步方式执行,在变更检测过程中。

Effects 的用例

在大多数应用程序代码中很少需要 effects,但在特定情况下可能会很有用。这里有一些情况,其中 effect 可能是一个好的解决方案:

[!WARNING]

何时不使用 effects

避免使用 effects 来传播状态变化。这可能导致 ExpressionChangedAfterItHasBeenChecked 错误、无限循环更新或不必要的变更检测周期。

因为这些风险,Angular 默认情况下阻止您在 effects 中设置 signals。如果绝对必要,可以通过在创建 effect 时设置 allowSignalWrites 标志来启用它。

相反地,使用 computed signals 来模型化依赖于其他状态的状态。

注入上下文

默认情况下,您只能在依赖注入上下文中创建一个 effect()(在有 inject 函数的地方)。满足此要求的最简单方法是在组件、指令或服务的 constructor 中调用 effect

@Component({...})
export class EffectiveCounterComponent {
  readonly count = signal(0);
  constructor() {
    // 注册一个新的 effect。
    effect(() => {
      console.log(`计数是: ${this.count()}`);
    });
  }
}

或者,可以将 effect 分配给一个字段(这也给它提供了一个描述性名称)。

@Component({...})
export class EffectiveCounterComponent {
  readonly count = signal(0);

  private loggingEffect = effect(() => {
    console.log(`计数是: ${this.count()}`);
  });
}

要在构造函数之外创建一个 effect,您可以通过其选项将一个 Injector 传递给 effect

@Component({...})
export class EffectiveCounterComponent {
  readonly count = signal(0);
  constructor(private injector: Injector) {}

  initializeLogging(): void {
    effect(() => {
      console.log(`计数是: ${this.count()}`);
    }, {injector: this.injector});
  }
}

销毁 effects

创建一个 effect 时,它会在其封闭上下文被销毁时自动销毁。这意味着在组件中创建的 effects 会在组件被销毁时销毁。指令、服务等中的 effects 也是如此。

Effects 返回一个 EffectRef,您可以使用它来手动销毁它们,通过调用 .destroy() 方法。您可以将此与 manualCleanup 选项结合使用,以创建一个持续到手动销毁为止的 effect。一定要小心清理这些不再需要的 effects。

高级主题

Signal 等值函数

创建 signal 时,您可以选择提供一个等值函数,该函数用于检查新值是否实际上与前一个值不同。

import _ from "lodash";

const data = signal(["test"], { equal: _.isEqual });

// 即使这是一个不同的数组实例,深度等值函数将认为这些值是相等的,signal 不会触发任何更新。
data.set(["test"]);

等值函数可以提供给可写和计算 signals。

HELPFUL: 默认情况下,signals 使用引用等值(=== 比较)。

不跟踪依赖项的读操作

极少数情况下,您可能希望在像 computedeffect 这样的反应式函数中执行可能读取 signals 的代码,而不 创建依赖项。

例如,假设当 currentUser 更改时,应该记录 counter 的值。您可以创建一个 effect,它读取两个 signals:

effect(() => {
  console.log(`用户设置为 ${currentUser()},计数器是 ${counter()}`);
});

这个示例将在 任一 currentUsercounter 更改时记录一条消息。然而,如果 effect 只应在 currentUser 更改时运行,则读取 counter 只是偶然的,而 counter 的更改不应记录新消息。

您可以通过调用带有 untracked 的 getter 来防止 signal 读取被跟踪:

effect(() => {
  console.log(`用户设置为 ${currentUser()},计数器是 ${untracked(counter)}`);
});

当一个 effect 需要调用一些不应该作为依赖项的外部代码时,untracked 也很有用:

effect(() => {
  const user = currentUser();
  untracked(() => {
    // 如果 `loggingService` 读取 signals,它们不会被计算为此 effect 的依赖项。
    this.loggingService.log(`用户设置为 ${user}`);
  });
});

Effect 清理函数

Effects 可能会启动长时间运行的操作,如果 effect 在第一个操作完成之前再次运行或被销毁,您应该取消这些操作。当您创建一个 effect 时,您的函数可以选择接受 onCleanup 函数作为其第一个参数。这个 onCleanup 函数让您注册一个在 effect 的下一次运行开始之前或 effect 被销毁时调用的回调。

effect(onCleanup => {
  const user = currentUser();

  const timer = setTimeout(() => {
    console.log(`1秒前,用户变成了 ${user}`);
  }, 1000);

  onCleanup(() => {
    clearTimeout(timer);
  });
});