angular v16 signal api 介绍

318 阅读20分钟

本文介绍 Angular signal库的 API 接口和一些实现细节。

signalAngular中是一种具有明确变更语义的值。在 Angular 中,signal通过一个零参数的getter函数来表示,该函数返回当前signal的值。

在这个getter函数中会使用 SIGNAL 符号进行标记,这样Angular框架就能够识别它是一个signal,并且可以应用一些内部的优化措施来提升性能。

signal是只读的,意味着我们可以获取当前signal的值,但不能直接修改它。我们可以观察signal的变化通知,以便在值发生变化时做出相应的处理。这种只读的特性使得signal在响应式编程中非常有用,因为它们可以用于实现数据的观察和响应。

这个getter函数被用于获取当前signal的值,并在响应式编程的上下文中记录signal的读取操作。这个操作对于构建响应式依赖图来说非常关键。在 Angular 中,响应式依赖图用于追踪数据的依赖关系,当依赖的数据发生变化时,相关的部分会得到更新。这种机制使得 Angular 能够高效地处理数据的变化和重新渲染视图。

在响应式上下文之外,我们仍然可以对signal进行读取操作。这使得非响应式的代码(比如现有的、来自第三方的库)可以随时读取signal的值,而无需了解signal的响应式特性。这种设计使得signal可以在现有的代码中轻松地使用,而不需要对现有代码做任何修改。

interface Signal<T> {
    (): T;
    [SIGNAL]: unknown;
}

可写信号

Angular signals库将提供可写signal的默认实现,可以通过内置的修改方法(setupdatemutate)进行更改:

interface WritableSignal<T> extends Signal<T> {
    /**
     * 我们可以直接使用设置方法(set)将`signal`的值设置为一个新的值。
     * 当`signal`的值发生变化,它会自动通知所有依赖于这个`signal`的部分,从而触发相应的更新。
     * 这种自动通知机制是响应式编程的核心,它确保了数据的一致性和同步,使得我们能够有效地处理数据的变化,并及时反映到界面上。
     *
     * 使用可写`signal`的 set 方法在以下情况下非常有用:
     * 1. 当我们需要更改原始值(例如数字、字符串等)时,直接使用 set 方法可以非常方便地更新`signal`的值。
     * 2. 当新值与旧值之间没有依赖关系时,也就是说,新值不依赖于旧值的任何信息,我们可以直接使用 set 方法替换整个数据结构。
     */
    set(value: T): void;

    /**
     * 使用可写`signal`的 update 方法可以基于当前`signal`的值进行更新。
     * 你可以提供一个更新函数,该函数将根据当前`signal`的值计算新的值,并将新值应用到`signal`上。
     * 当`signal`的值发生变化,它会自动通知所有依赖于这个`signal`的部分,从而触发相应的更新。
     * 使用 update 方法可以非常方便地对`signal`的值进行递增、递减或应用其他自定义的计算逻辑。
     * 这使得数据的更新更加灵活和自由,同时保持了响应式编程的特性,确保数据的一致性和同步。
     * 
     * 1. 当我们需要设置一个新值,而这个新值依赖于旧值时,可以使用 update 方法。
     * 例如,我们要对一个不可变的数据结构进行更新,可以通过提供一个更新函数来计算新的数据结构,并将其应用到`signal`上。
     * 2. 在不可变的数据结构中,我们不能直接修改已有的数据,而是需要创建一个新的数据结构来表示修改后的结果。
     * 使用 update 方法可以方便地进行这样的操作,而不需要手动处理数据的复制和更新。
     */
    update(updateFn: (value: T) => T): void;

    /**
     * 使用可写`signal`的 mutate 方法可以直接在当前值上进行修改。
     * 你可以提供一个修改函数,该函数将在当前值上进行原地修改,并将修改后的值应用到`signal`上。
     * 当`signal`的值发生变化,它会自动通知所有依赖于这个`signal`的部分,从而触发相应的更新。
     * 使用 mutate 方法可以方便地进行直接修改数据的操作,而不需要创建新的数据结构。
     * 这对于某些特定的场景和数据结构的处理非常有帮助。
     * 需要注意的是,这种方法是在原地修改数据,因此在使用时需要谨慎,确保不会导致数据的不一致或错误。
     * 
     * 使用可写`signal`的 mutate 方法在以下情况下非常有用:
     * 1. 当我们需要对`signal`的值进行内部修改,而不改变`signal`本身的引用(即身份)时,可以使用 mutate 方法。
     * 例如,我们可以通过提供一个修改函数,在`signal`中存储的数组上添加新元素,而不需要创建一个新的数组,从而保持`signal`的引用不变。
     * 1. mutate 方法可以用于原地修改`signal`的值,而不需要重新分配内存或创建新的数据结构,
     * 这在一些性能敏感的情况下很有用。
     */
    mutate(mutatorFn: (value: T) => void): void;

    /**
     * 我们可以通过某种方式从一个可写`signal`派生出一个新的`signal`,这个新`signal`是只读的,即不能修改它的值。
     * 这个只读`signal`仍然可以访问原始可写`signal`的值,但不允许对原始`signal`的值进行修改。
     * 
     * 这种操作在某些场景下很有用,比如我们希望将一个可写`signal`暴露给其他部分,但不希望其他部分对该`signal`进行修改,以保持数据的一致性和可控性。
     * 通过返回一个非可写`signal`,我们可以确保原始`signal`的值只能在特定的上下文中被修改,而其他地方只能读取它的值。
     */
    asReadonly(): Signal<T>;
}

我们可以通过调用signal的创建函数来创建一个可写signal实例。 在 Angular 中,这个signal创建函数通常是指从 @angular/core 模块中导入的 createSignal 函数。 通过调用这个函数,我们可以创建一个新的signal实例,这个signal实例可以用于存储和管理数据,并允许在需要时进行设置或更新。 这样的signal实例通常是可写的,我们可以使用 setupdatemutate方法来改变它的值。

function signal<T>(
  initialValue: T,
  options?: {equal?: (a: T, b: T) => boolean}
): WritableSignal<T>;

示例:

// 创建一个可写 signal
const counter = signal(0);

// 为 signal 设置新的值, 完全替换当前值
counter.set(5);

// 基于当前值更新 signal 的值
counter.update(currentValue => currentValue + 1);

在 Angular 中,SignalWritableSignal的接口命名通常遵循以下命名约定:

Signal 作为主要接口的命名,并且这个接口表示一个只读的值随时间变化。为 Signal 这个主要接口选择这个名称是因为其简短、易于发现,并且预计它会成为最常见的被导入和使用的接口。

另一方面,WritableSignal 是一个相对特殊化的接口,它在名称中添加了 writable 表示在这些类型的signal上允许进行额外的操作,即允许对其值进行修改。

这样的命名方案旨在提供清晰简洁的名称,使得开发者在使用该库时能够轻松区分只读signal和可写signal,并根据实际需求选择正确的接口。

判断是否相等

It is possible to, optionally, specify an equality comparator function. If the equality function determines that 2 values are equal, and if not equal, writable signal implementation will:

  • block update of signal’s value
  • skip change propagation.

默认情况下,当signal中的值为原始值(例如数字、字符串等)时,使用 === 来比较它们的相等性。如果新旧值相等,变更通知将被跳过,不会触发变更事件。但是,当signal中的值为对象或数组时,相等比较器函数将始终视它们为不相等,即使它们的内容相同也不会被认为是相等的。

这样的默认行为允许signal存储和传播非原始值(例如对象、数组),即使对象或数组的内容没有实际改变,只要其引用发生了变化,signal仍然可以触发变更通知。这样可以确保signal对非原始值的处理更加灵活和准确,而不仅仅依赖于值的内容是否发生变化。

const todos = signal<Array<Todo>>([{todo: 'Open RFC', done: true}]);

// 我们可以更新列表,即使没有使用不可变数据,仍然触发变更通知。
todos.update(todosList => {
    todosList.push({todo: 'Respond to RFC comments', done: false});
    return todoList;
});

signal概念的实现并不限于特定的方式。无论是Angular还是第三方库,都可以创建定制的signal实现,只要它们保持了与signal相关的底层契约(也就是接口和功能)不变即可。

这种灵活性允许开发者根据自己的需求和场景来创建符合特定要求的signal实现。无论使用哪种实现方式,只要它们遵循signal的基本规则和契约,就能在Angular应用中实现类似的signal功能,并在需要时触发变更通知和响应式处理。这样的设计有助于推动模块化和可扩展性,让开发者能够更好地适应不同的业务需求。

.setsignal的基本操作,.update 是一个方便的方法。

虽然signal的 API 表面上提供了三种不同的方法来改变signal的值,但实际上,.set(newValue) 是库中唯一需要的基本操作。这是设置signal值的主要方法,其他两种方法.update.mutate 只是提供了更方便的方式来进行signal值的更新,其功能可以通过调用 .set 来实现。

使用 .update 方法可以根据当前signal的值计算新的值并进行更新,而使用 .mutate 方法可以在当前值上进行原地修改。但这两种方法在功能上可以等效为使用 .set 方法,因为它们都是对signal的值进行更新。因此,.setsignal库中唯一需要的基本操作,而其他两种方法只是在使用上更加便利和语法上更加简洁的方式。

// 创建一个可写 `signal`
const counter = signal(0);

// 基于当前值更新 `signal` 的值
counter.update(c => c + 1);

// 相同功能可以不使用 .update, 而是 .set
counter.set(counter.get() + 1);

尽管所有的操作都可以仅通过使用 .set 来完成,但在某些特定的使用情况下,使用 .update 会更加方便和简洁,因此它被添加到了公共 API 中。

虽然 .setsignal的基本操作,允许直接设置signal的值,但在某些场景中,使用 .update 更加方便,因为它允许我们提供一个更新函数,根据当前signal的值计算新的值并进行更新,从而使代码更加简洁和灵活。因此,尽管 .set 是必需的基本操作,.update 作为一种方便的操作也被引入到了公共 API 表面,以满足不同的使用需求。

.mutate 用于就地更改值

.mutate 方法的用途。.mutate 方法允许我们通过直接在signal值上进行原地修改来改变signal的值。这种操作主要适用于signal持有的值是非原始 JavaScript 值,例如数组或对象。

例如,我们可以使用 .mutate 方法来对一个数组signal进行原地修改,比如向数组中添加元素、删除元素或对元素进行修改,而不是通过创建新的数组来实现。这样的原地修改在某些情况下可以提供更高的性能和效率,尤其是当signal值较大或需要频繁修改时。

总的来说,.mutate 方法是一种用于在signal中原地修改值的方便方法,适用于非原始值的signal,如数组或对象。

const todos = signal<Todo[]>([{todo: 'Open RFC', done: true}]);

// 我们可以更新列表,即使没有使用不可变数据,仍然触发变更通知。
todos.mutate(todosList => {
    todosList.push({todo: 'Respond to RFC comments', done: false});
});

.mutate 方法的特点。无论signal使用何种自定义的相等性检查,.mutate 方法始终会触发变更通知。这意味着,即使修改了signal的值但未触发自定义相等性检查,仍会发送变更通知,确保其他依赖于signal的部分能够得到更新。

通过使用 .mutate 方法和默认的相等比较器函数,signal库可以同时处理可变和不可变的数据。这使得signal在处理数据时更加灵活,无需局限于特定的数据处理方式。这样的设计决策有助于保持灵活性,让开发者可以根据具体场景选择最合适的数据处理方式,无论是可变数据还是不可变数据。

读/写分离

signal库的设计选择。在该库中,主要的响应式基元是 Signal 类型,而且它是只读的。这意味着可以使用signal传播响应式的值给其他组件或订阅者,但是这些消费者不能直接修改signal的值。

这样的设计可以带来一些好处。首先,它确保了响应式数据的单向流动,防止不必要的数据修改。其次,它增强了代码的可维护性和可预测性,因为只有特定的组件或服务可以修改signal的值,其他消费者只能读取数据。这种封装性使得代码更加健壮,并减少了潜在的副作用和数据冲突。

通过将signal的读写能力分开,signal库鼓励在应用程序中采用良好的数据流架构模式。具体来说,signal库通过将signal设计为只读的 Signal<T> 类型和可写的 WritableSignal<T> 类型,使得对状态的修改必须通过持有 WritableSignal 的所有者来进行,而不能在应用程序的任意地方进行修改。

Getter 方法

Angular 中选择的实现中,signal是通过 getter 函数来表示的。以下是使用这种 API 的一些优点:

  • 它是内置的 JavaScript 结构,这使得signal的读取在 TypeScript 代码和模板表达式之间保持一致。
  • 它明确指示了signal的主要操作是读取(read)。
  • 它清楚地表明了不仅仅是简单的属性访问在发生。
  • 它的语法非常轻量级,因为读取signal是一个非常常见的操作。

getter 函数的缺点

模板中的函数调用

Angular 中,模板中的函数调用会触发组件的变更检测机制,而这个机制在频繁调用时可能会导致性能问题。因此,开发者通常会避免在模板中调用复杂或计算密集型的函数。

但是,signalgetter 函数是高效的访问器,它们执行的计算工作非常少。signalgetter 函数通常只是返回signal的当前值,并不包含复杂的逻辑。因此,频繁调用signalgetter 函数并不会引起性能问题,开发者无需担心在模板中使用signalgetter 函数。

这样的设计使得signalAngular 中成为一种高效和方便的方式来处理数据和状态,并可以在模板中直接使用,而不会导致性能上的问题。

与类型缩窄的交互

在代码中

if (user.name()) {
  console.log(user.name().first); // 由于ts不能知道每次方法调用是否会返回相同的值所以此处会报错
}

可以如下解决

const name = user.name();
if (name) {
  console.log(name.first);
}

但是在模版中没有办法声明中间变量(可以自动创建此类变量来解决此类问题)。

Computed signals

类似于 vue 计算属性

const counter = signal(0);

const isEven = computed(() => counter() % 2 === 0);

const color = computed(() => isEven() ? 'red' : 'blue');

签名类型:

function computed<T>(
  computation: () => T,
  options?: {equal?: (a: T, b: T) => boolean}
): Signal<T>;

与可写signal类似,计算signal可以(可选)指定等式函数。当提供时,如果确定两个值相等,则相等函数可以停止更深层次依赖链的重新计算。示例(默认相等):

const counter = signal(0);

const isEven = computed(() => counter() % 2 === 0);

const color = computed(() => isEven() ? 'red' : 'blue');

// 提供一个不同的复数值给 counter 意味着:
// - isEven 重新计算(因为依赖值发生变化)
// - color 不需要重新计算(因为isEvent()值没有发生变化)
counter.set(2);

signal在计算功能上所做的算法选择,并提供了这种实现的一些强大保证:

  • 延迟执行:计算函数只有在有人读取其值时才会被执行。这样可以避免不必要的计算开销,只有在需要计算值时才进行实际的计算操作。
  • 自动清理:一旦计算signal的引用超出作用域,它会自动成为垃圾回收的对象。这意味着不需要开发者显式地进行计算的清理操作,signal库会自动处理资源的释放。
  • 无故障执行:计算保证在依赖项发生变化时,只会执行最少次数的计算。这样可以避免计算过程中使用过时或中间状态的依赖值,从而确保计算的准确性和一致性。这种无故障执行机制也免除了需要显式执行“事务”或“批处理”操作的需求。

在条件计算中,计算型signal可能根据读取的其他signal的不同值进行不同的计算。当计算型signal的计算过程中读取了其他signal时,它会将这些signal添加到自己的依赖集合中。如果后续这些依赖signal的值发生了变化,计算型signal会相应地知道自己需要重新计算,以确保计算的正确性。

// 如果 `showName` `signal`发生变化,问候语将始终被重新计算,但如果 `showName` 为 `false`,则名称`signal`不是问候语的依赖项,也不会导致其重新计算。
const greeting = computed(() => showName() ? `Hello, ${name()}!` : 'Hello!');

Effects

Effect 是一种带有副作用的操作,它会读取一个或多个signal的值,并在任何这些signal发生变化时自动调度重新运行该操作。

当一个 effect 被创建时,它会读取指定的signal的值,并在这些signal的值发生变化时自动触发重新运行。这样的设计使得 effect 能够对signal的变化作出相应的反应,并执行相应的副作用操作。

例如,在一个 effect 中可以监听用户输入的变化signal和网络请求结果signal,一旦这些signal的值发生变化,effect 就会自动调度重新运行,执行与之相关的副作用操作,如更新界面或发起新的网络请求。

一个 effect 的基本 API 具有以下签名:

function effect(
  effectFn: (onCleanup: (fn: () => void) => void) => void,
  options?: CreateEffectOptions
): EffectRef;

使用:

const firstName = signal('John');
const lastName  = signal('Doe');

// 这个 effect 会打印两个名字,也会在两个 signal 发生变化时自动打印
effect(() => console.log(firstName(), lastName()));

Effects 在应用程序中有多种用途,包括但不限于:

  • 同步多个独立模型之间的数据:当应用程序中存在多个独立的数据模型时,可能需要在它们之间保持数据同步。通过创建适当的effect,可以监听这些模型的变化并在数据发生变化时进行同步操作,从而确保各个模型之间的数据始终保持一致。
  • 触发网络请求:在应用程序中,常常需要根据用户的交互或其他条件触发网络请求,从而获取或更新数据。通过创建一个监听signal变化的 effect,可以在signal满足特定条件时自动发起网络请求,实现数据的获取和更新。
  • 执行渲染操作:在应用程序中,当数据发生变化时,需要将这些变化反映在界面上,以更新用户所见的内容。通过创建监听signal变化的 effect,可以在数据发生变化时自动执行渲染操作,更新界面,从而实现反应式的用户界面。
  • 除了上述的用例,Effect 还可以用于处理其他类型的副作用操作,如日志记录、权限控制、动画触发等。通过将这些副作用操作封装在 effect 中,可以实现代码的模块化和可维护性,并确保副作用操作的执行时机和正确性。

Effect 函数可以选择性地注册一个清理函数。如果注册了清理函数,在下次 effect 运行之前,清理函数将被执行。清理函数可以用于“取消”之前的 effect 运行可能已经开始的任何工作。

effect((onCleanup) => {
    const countValue = this.count();

    let secsFromChange = 0;
    const id = setInterval(() => {
      console.log(
        `${countValue} had its value unchanged for ${++secsFromChange} seconds`
      );
    }, 1000);

    onCleanup(() => {
      console.log('Clearing and re-scheduling effect');
      clearInterval(id);
    });
});

effects 调度时机

在 Angular Signals 中,effect 必须在更改signal的操作完成后执行。

考虑到 effect 的多种用例,存在各种可能的执行时间。因此,实际的 effect 执行时间不能保证,Angular 可能选择不同的策略。应用程序开发者不应依赖于任何观察到的执行时间。唯一可以保证的是:

  1. effect 将至少执行一次;
  2. effect 将在未来某个时刻响应其依赖项的变化而执行;
  3. effect 将最小化执行次数:如果一个 effect 依赖于多个signal,并且其中几个signal同时发生变化,只会调度一次 effect 执行。

effect 执行的不确定性和最小化执行次数的保证。由于 effect 可能用于各种不同的场景,其执行时间可能会有很大的变化。Angular 并不保证 effect 的执行时间,可能会根据不同的情况选择不同的执行策略。

虽然 effect 的执行时间不确定,但是 Angular 保证了上述的三个行为:effect 将至少执行一次,将在未来某个时刻响应其依赖项的变化而执行,并且在多个依赖项同时变化时最小化执行次数,只调度一次 effect 执行。这些保证使得 effect 的行为在合理范围内可预测,并且有助于提高应用程序的性能和可维护性。

停止 effects

Effect 在被创建后,会根据其依赖项的变化被自动调度运行,以响应数据的变化。这使得 effect 始终保持活动状态,并随时准备处理数据的变化。然而,这种“无限”生命周期并不是我们所期望的,因为 effect 应该在合适的时候进行关闭,以避免不必要的计算和资源浪费。

为了解决这个问题,Angular 提供了一种机制来管理 effect 的生命周期,并在适当的时候关闭它们。通常情况下,effect 会在 Angular 组件或服务的生命周期结束时自动关闭。例如,当一个组件被销毁时,与该组件相关的 effect 也会被关闭。这样,我们可以确保在不再需要 effect 时,它们会被及时关闭,避免了不必要的计算和资源占用。

Angulareffect 生命周期与组件或服务的销毁相关联。当一个组件或服务被销毁时,与之相关的 effect 也会被关闭,以确保它们不再执行任何副作用操作。

Angular 中,组件和服务通常都会实现 OnDestroy 接口,该接口包含一个 ngOnDestroy() 方法,用于在组件或服务被销毁时进行一些清理操作。effect 会尝试注入当前组件或服务的 DestroyRef 实例,并在 ngOnDestroy() 方法中注册其停止函数。

通过在 ngOnDestroy() 方法中注册 effect 的停止函数,我们可以确保 effect 在组件或服务被销毁时正确地进行关闭和清理。这样的设计使得 effect 能够与组件或服务的生命周期保持一致,并在合适的时候自动关闭,避免了可能出现的内存泄漏和资源浪费。

对于需要更多对生命周期范围控制的情况,可以选择性地在创建 effect 时传递 manualCleanup 选项:

// 如果设置了此选项,即使创建它的组件/指令被销毁,效果也不会自动销毁。
effect(() => {...}, {manualCleanup?: boolean});

可以使用效果创建函数返回的 EffectRef 实例显式停止/销毁效果:

// 创建 effect 并记录 effectRef
const effectRef = effect(() => {...});

// 显式停止/销毁此 effect
effectRef.destroy();

Effects 写入 signals

不允许从 effect 中直接写入signalEffect 函数用于处理副作用操作和响应式数据流的处理,其主要目的是读取signal并触发副作用操作,而不是用于写入signal的值。

直接从 effect 中写入signal可能导致数据流的不稳定和难以跟踪的问题。例如,如果一个 effect 写入了一个signal的值,而该signal又被其他 effect 读取,可能会导致循环依赖和无限循环的问题。这样的数据流是难以理解和调试的,可能导致应用程序的不稳定性和性能问题。

为了保持数据流的可靠性和可维护性,Angular 的设计决策是阻止在 effect 中直接写入signal。如果开发者尝试在 effect 中写入signalAngular 将报告错误并阻止这样的操作。

这种默认表现可以被配置项的allowSignalWrites覆盖。更建议使用计算signal

const counter = signal(0);
const isBig = signal(false);

effect(() => {
    if (counter() > 5) {
        isBig.set(true);
    } else {
        isBig.set(false);
    }      
}, {allowSignalWrites: true});