Angular Signal — 下一代 Angular 响应式语言

3,279 阅读12分钟

这篇文章来自我们团队的蒋子洋同学分享,他介绍了Angular的未来——Signal,在响应式大行其道的今天,Angular的未来会是怎样的,来通过这篇文章一探究竟吧

Angular 开发团队在今年早些时候公布了 Angular 的下一代响应式语言 — Signal。目前,这个提案还处于早期阶段,官方团队预计在今年晚些时候正式提交 RFC。尽管距发布还有一定时间,相关原型代码已经上传,本文除了介绍 Signal API 以外,也将借助源码,简要解析 Angular Signal(下文简称 signal)响应式的实现方法。

Why signal?

Signal 作为 fine-grained reactivity 家族的新成员,意味着 Angular 也正式拥抱了响应式编程。在这个体系中,著名的“前辈“包括:Preact,Vue,Solid 等等。Angular 官方认为,响应式的特性允许他们:(仅列举部分)

  • 定义清晰统一的、声明来源式的数据传递模型
  • 仅通知需要更新的UI做更新,UI粒度可以比组件更低(Angular 长久以来的痛点)
  • 消除 zone.js 的开销、陷阱和怪癖(作为 zone.js 的替代)

具体而言,signal 的设计充分考虑了集成进 Angular 体系中,而具有以下特性:

  • 和 RxJS 双向集成
  • 懒计算(只有在被需要的时候才会计算值)
  • 不需要 immutability 的计算模型(与 functional programming 相反)
  • 对 side effect 函数自定义计划执行
  • 利用 WeakRef 隐式管理 signal 的生命周期
  • 允许 Angular 自身利用编译器优化性能

Signal API

本文介绍的 Signal API 截至2023/4,与最终设计可能存在出入,仅供参考

先来看一个场景 demo:

@Component({
  selector: 'my-app',
  standalone: true,
  template: `
    <p>{{ fullName() }}</p>
    <p>{{ signalCounter }}</p>
    <button (click)="changeName()">Increase</button>
  `,
})
export class App {
  firstName = signal('Peter');
  lastName = signal('Parker');

  signalCounter = 0;

  fullName = computed(() => {
    this.signalCounter++;
    console.log('signal name change');
    return `${this.firstName()} ${this.lastName()}`;
  });

  changeName() {
    this.firstName.set('Signal Spider');
    this.lastName.set('Man');
  }
}

可以看到 demo 中定义了两个可变式 signal firstName 和 lastName;一个计算式 signal fullName;一个点击事件回调,用来改变 signal 的值。最后在模板中依赖了 fullName 的值。

预期效果显而易见,点击事件改变了 firstName 和 lastName 的值,进而改变了 fullName 的值,最终触发了 DOM 更新。

signal()

用来定义可变式 signal,它是响应式依赖中的原点。

const counter = signal(0)
console.log(counter()) // 0

具有三个改变函数:set()update()mutate()

counter.set(2)
counter.update(oldVal => oldVal + 1)

const list = signal([])
list.mutate(val => {
    val.push({key: 'value'})
})

三者的区别在于:

  • set() 直接传入新值
  • update() 传入一个改变函数,入参为当前值,返回改变值
  • mutate() 同样传入改变函数,但没有返回值,直接改变入参的对象(一般用于非 primitive type)

相等判断

相等判断是响应式依赖更新的关键,signal 允许自定义相等判断函数,作为 signal() 可选参数:

const users = signal({}, (a, b) => {
	return a.id === b.id
})

users.set({ id: 1, name: 'John' })
users.set({ id: 1, name: "Bob" })

第二次 set() 会被视为值不变,从而跳过依赖更新。

默认的相等判断函数,从源码可以看出用到了 Object.is():

export function defaultEquals<T>(a: T, b: T) {
  return (a === null || typeof a !== 'object') && Object.is(a, b);
}

两个 reference 即使指向同个 object,也会被视为不相等。

computed()

定义计算式 signal,一般位于响应式依赖的中间层。它不自身改变值,而是通过其他 signal 来响应式地改变。

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

在传入的计算函数中,signal 对象会被收集,在它们值改变的时候通知 computed signal 重新计算。

effect()

定义 signal side effect 函数,位于响应式依赖的终点。

const counter = signal(0);
effect(() => console.log('The counter is:', counter()));
// The counter is: 0

counter.set(1);
// The counter is: 1

与 computed() 类似,signal对象会被收集从而在每次改变的时候触发函数执行。

Signal 实现

Signal API 背后的实现,是本文撰写的初衷。这个章节将解答以下问题:

  • computed() 和 effect() 中依赖如何被收集?
  • signal() 变化后,如何通知其下游?
  • Signal 如何管理生命周期?

The Dependency Graph

Fine-grained reactivity 通过依赖图来管理响应式元素之间的关系。还记得前文提到的 demo 吗?它的依赖图可以这样表示:

1_Snipaste_2023-04-30_21-32-54_1683202711336.png

图中的叶子节点即为 Angular 的渲染函数,可以将它看作特殊的 effect()。每个节点在 signal 中都被定义成 ReactiveNode,并且会进一步被细分成 Producer 和 Consumer。当改变发生时,前者负责通知后者。这一层依赖关系,在图中用 ReactiveEdge 表示。在例子中,firstName 和 lastName 属于 producer,渲染函数属于 consumer,fullName 既是 producer,又是 consumer。

ReactiveNode

ReactiveNode 是 producer 和 consumer 的抽象类,因此它囊括了二者的属性和 API。按照依赖关系,先来看 producer 相关的定义:

export abstract class ReactiveNode {
  private readonly id = _nextReactiveId++;

  private readonly ref = newWeakRef(this);

  private readonly consumers = new Map<number, ReactiveEdge>();

  protected valueVersion = 0;

  protected abstract onProducerUpdateValueVersion(): void;

  protected producerMayHaveChanged(): void {};

  protected producerAccessed(): void {};

  protected get producerUpdatesAllowed(): boolean {};

首先一些基本属性:

  • id:用一个全局的变量 _nextReactiveId 自增来定义。
  • ref:记录自身引用,WeakRef 实现。它在 ReactiveEdge 中会被用到。
  • consumers:记录该 producer 关联的 consumer,用 Map 映射 id 和 ReactiveEdge 。
  • valueVersion:自增的版本号,每当值改变的时候+1,每次 consumer 触发更新时,都会检查这个属性,判断值是否真的发生改变。

额外介绍一下 ReactiveEdge:

interface ReactiveEdge {
  readonly producerNode: WeakRef<ReactiveNode>;

  readonly consumerNode: WeakRef<ReactiveNode>;

  atTrackingVersion: number;

  seenValueVersion: number;
}

作用是用 WeakRef 建立 producer 和 consumer 之间的联系,atTrackingVersion 和 seenValueVersion 会用来和 ReactiveNode 的版本号做对比判断值是否需要更新

Producer 拥有的方法:

  • onProducerUpdateValueVersion():修改 valueVersion 的方法,允许子类自行实现修改逻辑。
  • producerMayHaveChanged():值改变时,通知 consumer 的方法。
  • producerAccessed():值被读取时的回调。这是依赖收集的关键。
  • producerUpdatesAllowed():是否允许 producer 值发生改变。
  • producerPollStatus():检查值是否改变的方法,被 consumerPollProducersForChange() 调用。

对应地,consumer 除了公共的属性 id, ref 外,会有:

  • producers map
  • trackingVersion 版本号
  • consumerAllowSignalWrites flag 是否允许 signal 改值。
  • onConsumerDependencyMayHaveChanged():上游 producers 值改变后的回调。
  • consumerPollProducersForChange():检查 producers 是否值改变的方法。
  • hasProducers 是否有 producer 的 helper function。

producerMayHaveChanged()

值改变时,通知 consumer 的方法。注意函数名中用了 may 这个词,说明值有可能不变,这取决于上文提到的相等判断。

protected producerMayHaveChanged(): void {
    // Prevent signal reads when we're updating the graph
    const prev = inNotificationPhase;
    inNotificationPhase = true;
    try {
      for (const [consumerId, edge] of this.consumers) {
        const consumer = edge.consumerNode.deref();
        if (
          consumer === undefined ||
          consumer.trackingVersion !== edge.atTrackingVersion
        ) {
          this.consumers.delete(consumerId);
          consumer?.producers.delete(this.id);
          continue;
        }

        consumer.onConsumerDependencyMayHaveChanged();
      }
    } finally {
      inNotificationPhase = prev;
    }
  }

inNotificationPhase 是一个全局变量,它的作用是防止改变通知过程中,有额外的 signal 读值动作。

通知过程为:

  1. 遍历 consumers map,拿到 consumer 引用

  2. 此时该 consumer 有可能

    1. 已经完成了生命周期(被垃圾收集)
    2. node 和 edge 的 trackingVersion 不匹配

    这时候需要从依赖关系里删除这个引用。

  3. 如果不是以上状态,则调用 onConsumerDependencyMayHaveChanged() 触发 consumer 后续的动作。

consumerPollProducersForChange()

检查 producers 的值是否改变。只要该 consumer 依赖的任一 producer 改变了,函数就返回 true

protected consumerPollProducersForChange(): boolean {
    for (const [producerId, edge] of this.producers) {
      const producer = edge.producerNode.deref();

      if (
        producer === undefined ||
        edge.atTrackingVersion !== this.trackingVersion
      ) {
        // This dependency edge is stale, so remove it.
        this.producers.delete(producerId);
        producer?.consumers.delete(this.id);
        continue;
      }

      if (producer.producerPollStatus(edge.seenValueVersion)) {
        // One of the dependencies reports a real value change.
        return true;
      }
    }

    // No dependency reported a real value change, so the `Consumer` has also not been
    // impacted.
    return false;
  }

检查过程为:

  1. 遍历 producers map,拿到 producer 引用

  2. 此时该 producer 有可能

    1. 已经完成了生命周期(被垃圾收集)
    2. node 和 edge 的 trackingVersion 不匹配

    这时候需要从依赖关系里删除这个引用。

  3. 如果不是以上状态,则调用 producerPollStatus() 检查值是否改变。

  4. 所有 producer 都没改变的话,则返回 false

producerAccessed()

Producer 值被读取时的回调,当 computed() 和 effect() 中调用取值函数时,依赖就被收集了。

  protected producerAccessed(): void {
    if (inNotificationPhase) {
      throw new Error(
        typeof ngDevMode !== 'undefined' && ngDevMode
          ? `Assertion error: signal read during notification phase`
          : ''
      );
    }

    if (activeConsumer === null) {
      return;
    }

    // Either create or update the dependency `Edge` in both directions.
    let edge = activeConsumer.producers.get(this.id);
    if (edge === undefined) {
      edge = {
        consumerNode: activeConsumer.ref,
        producerNode: this.ref,
        seenValueVersion: this.valueVersion,
        atTrackingVersion: activeConsumer.trackingVersion,
      };
      activeConsumer.producers.set(this.id, edge);
      this.consumers.set(activeConsumer.id, edge);
    } else {
      edge.seenValueVersion = this.valueVersion;
      edge.atTrackingVersion = activeConsumer.trackingVersion;
    }
  }

在这个方法中,用到了 activeConsumer。它是一个全局变量,用来指向当前的 consumer。producer 新建时,不知道它会被哪些 consumer 依赖,因此就通过 activeConsumer 来指向,同时在这个方法里更新其 consumers map。

更新过程为:

  1. 从 activeConsumer 中获取该 producer 对应的 ReactiveEdge
  2. 有可能 edge 还未创建(新的依赖关系),则新建 edge 并更新对应 producers 和 consumers map。
  3. 有可能 edge 已经创建(之前就存在的依赖关系),则更新 edge 的 seenValueVersion 和 atTrackingVersion

动态依赖

上文中我们提到的依赖收集,对于静态关系来说够用了。但如果依赖关系是动态的,例如:

const dynamic = computed(() => useA() ? dataA() : dataB());

依赖 dataA 还是 dataB 取决于 useA。这时 trackingVersion 的作用就体现出来了。

举例来说:

// Phase 1
const useA = signal(true)
const dataA = signal('A')
const dataB = signal('B')
const dynamic = computed(() => useA() ? dataA() : dataB())

// Phase 2
console.log(dynamic())

// Phase 3
useA.set(false)

// Phase 4
console.log(dynamic())

第一阶段是变量的定义,注意此时 dynamic 还没有做依赖收集。依赖图中,各个 ReactiveNode 都是独立的。

2_Snipaste_2023-05-03_16-07-46_1683202778146.png

第二阶段读取 dynamic 的值,对应 computed() 中的源码:

signal(): T {
    // Check if the value needs updating before returning it.
    this.onProducerUpdateValueVersion();

    // Record that someone looked at this signal.
    this.producerAccessed();

    if (this.value === ERRORED) {
      throw this.error;
    }

    return this.value;
  }

首先调用 onProducerUpdateValueVersion() 方法更新值,其次调用 producerAccessed() 更新依赖关系,因为其自身也是 producer。最后返回值。

查看 computed() 对 onProducerUpdateValueVersion() 的实现:

protected override onProducerUpdateValueVersion(): void {
    if (!this.stale) {
      // The current value and its version are already up to date.
      return;
    }

    // The current value is stale. Check whether we need to produce a new one.

    if (
      this.value !== UNSET &&
      this.value !== COMPUTING &&
      !this.consumerPollProducersForChange()
    ) {
      // Even though we were previously notified of a potential dependency update, all of
      // our dependencies report that they have not actually changed in value, so we can
      // resolve the stale state without needing to recompute the current value.
      this.stale = false;
      return;
    }

    // The current value is stale, and needs to be recomputed. It still may not change -
    // that depends on whether the newly computed value is equal to the old.
    this.recomputeValue();
  }

可以看到有一个内部变量 stale 来辅助判断值是否需要更新。this.value 除了计算值外,还有几种特殊状态,如 UNSETCOMPUTING 用来标记计算过程。由于 dynamic 第一次被读值,此时 this.value 还是初始值 UNSET, 因此会调用 this.recomputeValue() 计算值:

private recomputeValue(): void {
    if (this.value === COMPUTING) {
      // Our computation somehow led to a cyclic read of itself.
      throw new Error('Detected cycle in computations.');
    }

    const oldValue = this.value;
    this.value = COMPUTING;

    // As we're re-running the computation, update our dependent tracking version number.
    this.trackingVersion++;
    const prevConsumer = setActiveConsumer(this);
    let newValue: T;
    try {
      newValue = this.computation();
    } catch (err) {
      newValue = ERRORED;
      this.error = err;
    } finally {
      setActiveConsumer(prevConsumer);
    }

    this.stale = false;

    if (
      oldValue !== UNSET &&
      oldValue !== ERRORED &&
      newValue !== ERRORED &&
      this.equal(oldValue, newValue)
    ) {
      // No change to `valueVersion` - old and new values are
      // semantically equivalent.
      this.value = oldValue;
      return;
    }

    this.value = newValue;
    this.valueVersion++;
  }

几个关键点:

  • 每次重新计算值前,trackingVersion 会自增。
  • 设置全局的 activeConsumer 为自身。
  • 调用 this.computation() 计算新值,即定义 computed() 时传入的函数。
  • 调用 this.equal() 判断新值是否和旧值相等,从这一步开始是作为 producer 的工作。
  • 如果值真的发生改变,则更新 valueVersion

因此,在阶段二,useA 和 dataA 的值会被读取,依赖图中就相应新增了两条 ReactiveEdge

3_Snipaste_2023-05-03_16-36-51_1683202800094.png

阶段三,修改 useA 的值。useA 的 valueVersion 自增,触发 producerMayHaveChanged() 通知下游。computed() 会因此将内部变量 this.stale 标记成 true

注意,重新计算并未执行。这样做的理由,请阅下一章节。

此时 dynamic 和 连接两者的 edge 中 trackingVersion 都是1,依赖图保持不变。

阶段四,dynamic 的值再一次被读取。重复阶段二的过程,由于 useA 的 valueVersion 和 edge 中不一致,因此 recomputeValue() 再一次被执行。最终形成的依赖图如下:

4_Snipaste_2023-05-03_17-38-19_1683202818724.png

dataA 的 trackingVersion 已经落后,此时如果尝试修改 dataA 的值

dataA.set('newA')

就会从 map 中删除 edge,dynamic 也不会得到通知。

if (
    consumer === undefined ||
    consumer.trackingVersion !== edge.atTrackingVersion
) {
    this.consumers.delete(consumerId);
    consumer?.producers.delete(this.id);
    continue;
}

Diamond problem

响应式编程中的有一个经典问题 (diamond problem) ——当一个 consumer 的依赖项内部也存在依赖关系,此时当上游节点改变时,怎样保证 consumer 能适时作出响应?

例如:

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter() + ' is ' + evenOrOdd()));

counter.set(1);

依赖图如下:

5_Snipaste_2023-05-03_17-59-27_1683202833286.png

当 counter 值改变时,我们不希望 effect 内的函数执行两次,有可能产生 1 is odd 这种中间态。

Signal 将数据更新分为 push 和 pull 两个阶段。push 阶段发生于 producer 值改变时,通知其下游 consumer。在这个阶段 side-effect function 或者 computed 的重新计算不会立马发生。因此上文例子中的中间态就不存在了。

等到 pull 阶段,例子中即 side-effect function 执行的时候,才会触发读取值,有可能的重新计算等动作。这也是本文最初介绍的 signal 特性之一:懒计算。

Watch

effect() 的底层实现依赖于 Watch 类。全局所有的 effect() 会被放到 EffectManager 中统一管理。

export class Watch extends ReactiveNode {
  protected override readonly consumerAllowSignalWrites: boolean;
  private dirty = false;
  private cleanupFn = NOOP_CLEANUP_FN;
  private registerOnCleanup = (cleanupFn: WatchCleanupFn) => {
    this.cleanupFn = cleanupFn;
  };

  constructor(
    private watch: (onCleanup: WatchCleanupRegisterFn) => void,
    private schedule: (watch: Watch) => void,
    allowSignalWrites: boolean
  ) {
    super();
    this.consumerAllowSignalWrites = allowSignalWrites;
  }

  notify(): void {
    if (!this.dirty) {
      this.schedule(this);
    }
    this.dirty = true;
  }

  protected override onConsumerDependencyMayHaveChanged(): void {
    this.notify();
  }

  protected override onProducerUpdateValueVersion(): void {
    // Watches are not producers.
  }

  /**
   * Execute the reactive expression in the context of this `Watch` consumer.
   *
   * Should be called by the user scheduling algorithm when the provided
   * `schedule` hook is called by `Watch`.
   */
  run(): void {
    this.dirty = false;
    if (this.trackingVersion !== 0 && !this.consumerPollProducersForChange()) {
      return;
    }

    const prevConsumer = setActiveConsumer(this);
    this.trackingVersion++;
    try {
      this.cleanupFn();
      this.cleanupFn = NOOP_CLEANUP_FN;
      this.watch(this.registerOnCleanup);
    } finally {
      setActiveConsumer(prevConsumer);
    }
  }

  cleanup() {
    this.cleanupFn();
  }
}

关键点:

  • dirty:内部变量,类似于 computed() 中的 stale,标记是否需要重新执行 side-effect function。
  • cleanupFn:可以定义一个清理函数,它会在每次执行 side-effect function 前执行一次。
  • notify():被通知 producer 有更新时会执行,具体就是标记 dirty,执行constructor 中的 schedule 函数。它和 run() 分开执行,构成了 push/pull 两阶段。
  • run():具体执行 side-effect function 的方法。类似于 computed() 的重新计算函数。

总结

Signal 给 Angular 带来的改变,犹如 Composition API 让 Vue 升级一个大版本号。从官方在讨论贴中的展望,我们可以预见到未来可能诞生的显著变化:

  • zone.js 被允许不再需要了。
  • 全新状态管理系统。
  • 独立的 signal 创建环境。这会极大扩展生态的可能性,类似于 VueUse 的三方库就在眼前。

当然,这些改变官方承诺向后兼容,有助于后续的项目迁移。

Signal 的性能表现如何,请关注此系列的后续文章🦄