Angular 中的信号(Signals):给忙碌开发者的深入剖析

构建复杂的用户界面是一件难事。在现代 Web 应用里,UI 状态很少由简单的独立值组成,它更像是一个复杂的计算状态,依赖于其他值或计算状态的复杂层级。管理这种状态需要大量工作:开发者必须存储、计算、失效并同步这些值。

多年来,Web 开发中引入了各种框架和原语来简化这项工作。它们大多有一个共同主题——响应式编程,它为管理应用状态提供了基础设施,让开发者把精力放在业务逻辑上,而不是重复性的状态管理工作。

最新加入的是信号(signals),一种「响应式」原语,它表示一个动态变化的值,并能在值变化时通知感兴趣的消费者。后者进而可以触发重新计算或各种副作用,例如创建/销毁组件、发起网络请求、更新 DOM 等等。

我们能在不同框架中看到不同的信号实现。现在甚至有人在推动信号的标准化

……这项工作着眼于统一 JavaScript 生态。几位框架作者正在这里协作,打造一个可以支撑各自响应式内核的通用模型。当前草案基于 AngularBubbleEmberFASTMobXPreactQwikRxJSSolidStarbeamSvelteVueWiz 等项目作者/维护者的设计意见……

Angular 中信号的实现与该提案给出的实现非常相似,所以本文中我可能会在两者之间做一些交叉对照。

信号作为原语

一个信号表示一个可能随时间变化的数据单元。信号要么是「状态」(一个手动设置的值),要么是「计算」出来的(可以想成一个基于其他信号的公式)。

计算信号的工作方式是:自动追踪在其求值过程中读取了哪些其他信号。当一个计算信号被读取时,它会检查此前记录的依赖是否有变化,若有则重新求值。

例如,这里我们有一个状态信号 counter 和一个计算信号 isEven。我们把 counter 的初始值设为 0,之后改成 1。可以看到计算信号 isEven 对变化做出了响应,在 counter 更新前后给出了两个不同的值:

import { computed, signal } from '@angular/core';

// 状态信号/可写信号
const counter = signal(0);

// 计算信号
const isEven = computed(() => (counter() & 1) == 0);

counter() // 0
isEven() // true

counter.set(1)

counter() // 1
isEven() // false

还要注意,上面的例子中 isEven 信号并没有显式订阅源信号 counter,而只是在它的计算函数内部通过 counter() 调用了源信号。这就足以把两个信号关联起来。于是,每当源信号 counter 更新为新值,派生信号也会自动跟着更新。

状态信号和计算信号都被视为值的生产者(producer)。生产者就是那些产出值并能投递变更通知的信号。

状态信号在通过 API 调用更新值时改变(产出)自己的值,而计算信号则在回调中用到的依赖发生变化时自动生成新值。

计算信号也可以是消费者(consumer),因为它们可能依赖(消费)若干生产者。在其他响应式实现中,例如 Rx,消费者也被称为 sink。

当生产者信号的值发生变化时,依赖它的消费者(例如计算信号)的值并不会立刻更新。当一个计算信号被读取时,它会检查此前记录的依赖是否有变化,必要时才重新求值。

这让计算信号成为惰性的,或者说基于拉取(pull-based)的:只有在被访问时才会求值,即使底层状态更早就已改变。在上面的例子中,计算信号的值只在我们调用 isEven() 时才求值,尽管底层依赖 counter 的更新发生得更早,在我们执行 counter.set() 的时候。

除了普通的可写信号和计算信号,还有一个观察者(watcher,即 effect)的概念。与计算信号基于拉取的求值方式相反,改变一个生产者信号会立即通知观察者,同步调用观察者的通知回调,实际上是把通知「推送」出去。框架把观察者封装成暴露给用户的 effect。effect 会通过调度(scheduling)延后对用户代码的通知。

与 Promise 不同,信号中的一切都是同步运行的:

  • 把信号设为新值是同步的,之后读取任何依赖它的计算信号都会立即反映这一变化。这个变更没有内置的批处理(batching)。
  • 读取计算信号是同步的——它们的值总是可用的。
  • 观察者是被同步通知的,但封装它们的 effect 可以选择通过调度来批处理并延后通知。

实现细节

在内部,信号的实现定义了若干概念,我想在本文中逐一解释:响应式上下文、依赖图,以及 effect(观察者)。我们从响应式上下文开始。

要讨论响应式上下文,可以想想栈帧(执行帧),它定义了 JavaScript 代码求值和执行所处的环境。具体来说,它定义了哪些对象(变量)对该函数可用。可以说这些对象的可用性就定义了一个上下文。例如,在 web worker 上下文中运行的函数无法访问全局对象 document

响应式上下文定义了一个活跃的消费者对象,它依赖若干生产者,并且在这些生产者的值被读取时对它们的访问器函数可见。例如这里我们有一个消费者 isEvent,它依赖生产者 counter(消费它的值)。这个依赖关系是通过在 computed 回调内部访问 counter 的值来建立的:

isEvent = computed(() => (counter() & 1) === 0)

当 computed 回调运行时,它会自动执行 counter 信号的访问器函数来取值。可以说,这种情况下 counter 信号是在消费者 isEvent 的响应式上下文中执行的。 所以,如果存在一个依赖某生产者值的活跃消费者,那么该生产者就是在响应式上下文中执行的。

要实现这套响应式上下文机制,每次访问某个消费者的值时,但在它被重新计算之前(在 computed 回调运行之前),我们可以把这个消费者设为活跃消费者。做法很简单:把该消费者对象赋给一个全局变量,并在回调执行期间一直保留在那里。这个全局变量对 computed 回调执行期间被查询的所有生产者都是可见的,它为该消费者所依赖的全部生产者定义了响应式上下文。

Angular 做的正是这件事。当 computed 回调执行时,它会先在 producerRecomputeValue 中把当前节点设为活跃消费者:

function producerRecomputeValue(node: ComputedNode<unknown>): void {
  ...
  const prevConsumer = consumerBeforeComputation(node);
  let newValue: unknown;
  try {
    newValue = node.computation();
  } catch (err) {...} finally {...}

function consumerBeforeComputation(node: ReactiveNode | null) {
  node && (node.nextProducerIndex = 0);
  return setActiveConsumer(node);
}

Angular 是从工厂函数 createComputed 内的 producerUpdateValueVersion 走到这里的:

function createComputed<T>(computation: () => T): ComputedGetter<T> {
  ...
  const computed = () => {
    producerUpdateValueVersion(node);
    ...
  };
}

function producerUpdateValueVersion(node: ReactiveNode): void {
  ...
  node.producerRecomputeValue(node);
  ...
}

这个调用栈也清楚地展示了这一实现:

显示 producerRecomputeValue 和 setActiveConsumer 的调用栈

正因如此,在 computed 的回调执行期间,凡是在该消费者处于活跃状态时被查询的生产者,都会知道自己是在响应式上下文中执行的。所有在某个特定消费者的响应式上下文中执行的生产者,都会被添加为该消费者的依赖。 这就构成了响应式图。

Angular 中大部分早已存在的功能都是在非响应式上下文中执行的。只要搜索一下 setActiveConsumer 传入 null 的用法就能看出来:

setActiveConsumer(null) 的搜索结果

例如,在运行生命周期钩子之前,Angular 会清空响应式上下文:

/**
 * Executes a single lifecycle hook, making sure that:
 * - it is called in the non-reactive context;
 * - profiling data are registered.
 */
function callHookInternal(directive: any, hook: () => void) {
  profiler(ProfilerEvent.LifecycleHookStart, directive, hook);
  const prevConsumer = setActiveConsumer(null);
  try {
    hook.call(directive);
  } finally {
    setActiveConsumer(prevConsumer);
    profiler(ProfilerEvent.LifecycleHookEnd, directive, hook);
  }
}

Angular 的模板函数(组件视图)和 effect 则是在响应式上下文中运行的。

响应式图

响应式图是通过消费者与生产者之间的依赖关系构建起来的。用值访问器实现的响应式上下文,使得信号依赖可以被自动、隐式地追踪。用户不需要声明依赖数组,某个上下文的依赖集合也不必在多次执行之间保持不变。

当一个生产者被执行时,它会把自己加入当前活跃消费者(定义当前响应式上下文的那个消费者)的依赖中。这发生在 producerAccessed 函数内部:

export function producerAccessed(node: ReactiveNode): void {
  ...
  // This producer is the `idx`th dependency of `activeConsumer`.
    const idx = activeConsumer.nextProducerIndex++;
    if (activeConsumer.producerNode[idx] !== node) {
      // We're a new dependency of the consumer (at `idx`).
      activeConsumer.producerNode[idx] = node;
      // If the active consumer is live, then add it as a live consumer. If not, then use 0 as a
      // placeholder value.
      activeConsumer.producerIndexOfThis[idx] = consumerIsLive(activeConsumer)
        ? producerAddLiveConsumer(node, activeConsumer, idx)
        : 0;
    }

生产者和消费者都参与响应式图。这个依赖图是双向的,但两个方向上追踪的依赖并不相同。

生产者通过 producerNode 属性被追踪为消费者的依赖,形成从消费者指向生产者的边:

interface ConsumerNode extends ReactiveNode {
  producerNode: NonNullable<ReactiveNode['producerNode']>;
  producerIndexOfThis: NonNullable<ReactiveNode['producerIndexOfThis']>;
  producerLastReadVersion: NonNullable<ReactiveNode['producerLastReadVersion']>;

某些消费者还会被追踪为「活跃」(live)消费者,并形成另一个方向的边,从生产者指向消费者。这些边用于在生产者的值更新时传播变更通知:

interface ProducerNode extends ReactiveNode {
  liveConsumerNode: NonNullable<ReactiveNode['liveConsumerNode']>;
  liveConsumerIndexOfThis: NonNullable<ReactiveNode['liveConsumerIndexOfThis']>;
}

消费者总是会追踪自己依赖的生产者。生产者只追踪那些被认为是「live」的消费者的依赖。一个消费者是「live」的,条件是它的 consumerIsAlwaysLive 属性被设为 true,或者它本身是一个被某个 live 消费者所依赖的生产者。

在 Angular 中,有两类节点被定义为 live 消费者:

  • watch 节点(用于 effect)
  • 响应式 LView 节点(用于变更检测)

它们的定义如下:

const WATCH_NODE: Partial<WatchNode> = /* @__PURE__ */ (() => {
  return {
    ...REACTIVE_NODE,
    consumerIsAlwaysLive: true,
    consumerAllowSignalWrites: false,
    consumerMarkedDirty: (node: WatchNode) => {
      if (node.schedule !== null) {
        node.schedule(node.ref);
      }
    },
    hasRun: false,
    cleanupFn: NOOP_CLEANUP_FN,
  };
})();

const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = {
  ...REACTIVE_NODE,
  consumerIsAlwaysLive: true,
  consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
    markAncestorsForTraversal(node.lView!);
  },
  consumerOnSignalRead(this: ReactiveLViewConsumer): void {
    this.lView![REACTIVE_TEMPLATE_CONSUMER] = this;
  },
};

在某些上下文中,computed 信号也可能变成「live」消费者,例如当它被用在 effect 回调里时。

下面这段代码:

import { ChangeDetectorRef, Component, computed, effect, signal } from '@angular/core';
import { SIGNAL } from '@angular/core/primitives/signals';

@Component({
  standalone: true,
  selector: 'app-root',
  template: 'Angular Love',
  styles: []
})
export class AppComponent {
  constructor(private cdRef: ChangeDetectorRef) {
    const a = signal(0);

    const b = computed(() => a() + 'b');
    const c = computed(() => a() + 'c');
    const d = computed(() => b() + c() + 'd');

    const nodes = [a[SIGNAL], b[SIGNAL], c[SIGNAL], d[SIGNAL]] as any[];

    d();

    const A = 0, B = 1, C = 2, D = 3;

    const depBToA = nodes[B].producerNode[0] === nodes[A];
    const depCToA = nodes[C].producerNode[0] === nodes[A];
    const depDToB = nodes[D].producerNode[0] === nodes[B];
    const depDToC = nodes[D].producerNode[1] === nodes[C];

    console.log(depBToA, depCToA, depDToB, depDToC);

    const e = effect(() => b()) as any;

    // 需要等变更检测来通知这个 effect
    setTimeout(() => {
      // effect 依赖 B
      const depEToB = e.watcher[SIGNAL].producerNode[0] === nodes[B];

      // live 消费者的连线从生产者 A 指向 B,
      // 再从 B 指向 E,因为 E(effect)是 live 消费者
      const depLiveAToB = nodes[A].liveConsumerNode[0] === nodes[B];
      const depLiveBToE = nodes[B].liveConsumerNode[0] === e.watcher[SIGNAL];

      console.log(depLiveAToB, depLiveBToE, depEToB);
    });
  }
}

会产生下面这个图:

响应式依赖图,显示节点 A、B、C、D、E 以及 producer 与 live consumer 连线

通过活跃消费者实现的响应式上下文带来了动态依赖追踪。当某个消费者被设为活跃时,被求值的生产者集合是由这些生产者的调用顺序动态决定的。每当在某个消费者的响应式上下文中访问一个生产者,ActiveConsumer 的依赖列表都可能被重新排列。

为此,消费者的依赖被记录在 producerNode 数组中:

interface ConsumerNode extends ReactiveNode {
  producerNode: NonNullable<ReactiveNode['producerNode']>;
  producerIndexOfThis: NonNullable<ReactiveNode['producerIndexOfThis']>;
  producerLastReadVersion: NonNullable<ReactiveNode['producerLastReadVersion']>;

当某个消费者的计算被重新运行时,指向该数组的指针(索引)nextProducerIndex 会被初始化为索引 0,每次读取到的依赖都会与上一轮在指针当前位置上的依赖做比较。如果不匹配,说明依赖自上次运行以来发生了变化,旧依赖就可以被丢弃并替换为新的。运行结束时,所有仍未匹配上的依赖都可以丢弃。

这意味着:如果某个依赖只在其中一个分支里需要,而上一次计算走的是另一个分支,那么改变这个暂时未被使用的值不会导致计算信号重新计算,哪怕它的值被拉取。由此就有了不同次执行访问不同信号集合的可能。

例如,这个计算信号 dynamic 会根据 useA 信号的值来读取 dataAdataB

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

在任意时刻,它的依赖集合要么是 [useA, dataA],要么是 [useA, dataB],绝不可能同时依赖 dataAdataB

下面这段代码,类似 Angular 中这个测试用例,清楚地展示了这一点:

import { computed, signal } from '@angular/core';
import { SIGNAL} from '@angular/core/primitives/signals';

const states = Array.from('abcdefgh').map((s) => signal(s));
const sources = signal(states);

const vComputed = computed(() => {
  let str = '';
  for (const state of sources()) str += state();
  return str;
});

const n = vComputed[SIGNAL] as any;
expectEqual(vComputed(), 'abcdefgh');
expectEqualArrayElements(n.producerNode.slice(1), states.map(s => s[SIGNAL]));

sources.set(states.slice(0, 5));
expectEqual(vComputed(), 'abcde');
expectEqualArrayElements(n.producerNode.slice(1), states.slice(0, 5).map(s => s[SIGNAL]));

sources.set(states.slice(3));
expectEqual(vComputed(), 'defgh');
expectEqualArrayElements(n.producerNode.slice(1), states.slice(3).map(s => s[SIGNAL]));

function expectEqual(v1, v2): any {
  if (v1 !== v2) throw new Error(`Expected ${v1} to equal ${v2}`);
}
function expectEqualArrayElements(v1, v2): any {
  if (v1.length !== v2.length) throw new Error(`Expected ${v1} to equal ${v2}`);
  for (let i = 0; i < v1.length; i++) {
    if (v1[i] !== v2[i]) throw new Error(`Expected ${v1} to equal ${v2}`);
  }
}

如你所见,这个图没有唯一的起始顶点。既然每个消费者都持有一份依赖生产者的列表,而这些生产者又可能有自己的依赖(例如计算信号),那么可以说每个消费者在被访问的那一刻都是图的根顶点。

两阶段更新

早期基于推送的响应式模型面临着冗余计算的问题:如果对状态信号的更新会让计算信号立即运行,最终可能把更新推送到 UI。但这次写入 UI 可能为时过早——如果在下一帧之前,源状态信号还会再变一次。

例如对于下面这样的图,问题在于我们会无意中先求值 A -> B -> DC,然后因为 C 变了又重新求值 D。把 D 求值两次既低效,又可能给用户带来可察觉的画面异常。

菱形问题图:A -> B -> D 以及 A -> C -> D

这就是所谓的菱形问题(diamond problem)。

有时由于这类异常(glitch),甚至会把不准确的中间值展示给最终用户。信号通过采用基于拉取(惰性)而非基于推送的方式避开了这种动态:在框架调度 UI 渲染的那一刻,它会拉取合适的更新,从而在计算和写入 DOM 两方面都避免了无用功。

看这个例子:

const a = signal(0);

const b = computed(() => a() + 'b');
const c = computed(() => a() + 'c');
const d = computed(() => b() + c() + 'd');

// 运行 computed 回调以建立依赖
d();

// 更新图顶端的信号
setTimeout(() => a.set(1), 2000);

a 更新之后不会发生任何传播。只有节点的值和版本被更新了:

function signalSetFn(node, newValue) {
  ...
  if (!node.equal(node.value, newValue)) {
    node.value = newValue;
    signalValueChanged(node);
  }
}

function signalValueChanged(node) {
  node.version++;
  ...
}

当我们之后访问 d() 的值时,信号实现会通过 consumerPollProducersForChange 向上轮询 d 的依赖,以判断是否需要重新计算。

为了高效处理,所有响应式节点都会记录依赖节点的版本。要判断是否有变化,只需把保存下来的生产者节点版本与该节点上的实际版本做比较:

interface ConsumerNode extends ReactiveNode {
...
producerLastReadVersion: NonNullable<ReactiveNode['producerLastReadVersion']>;
}

function consumerPollProducersForChange(node) {
...
// Poll producers for change.
for (let i = 0; i < node.producerNode.length; i++) {
const producer = node.producerNode[i];
const seenVersion = node.producerLastReadVersion[i];
// First check the versions. A mismatch means that the producer's value is known to have
// changed since the last time we read it.
if (seenVersion !== producer.version) {
return true;
}

如果两者不同,说明生产者发生了变化,实现会通过 producerRecomputeValue 重新运行 computed 回调:

export function producerUpdateValueVersion(node: ReactiveNode): void {
  ...

  if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) {
    // None of our producers report a change since the last time they were read, so no
    // recomputation of our value is necessary, and we can consider ourselves clean.
    node.dirty = false;
    node.lastCleanEpoch = epoch;
    return;
  }

  node.producerRecomputeValue(node);

  // After recomputing the value, we're no longer dirty.
  node.dirty = false;
  node.lastCleanEpoch = epoch;
}

这会对 C 的依赖重复同样的过程。这样它就会到达节点 A,此时便会对分支 D->C->A 求值。但由于 D 也依赖生产者 B,所以在计算 D 之前它会先重新求值 B。这样一来,D 就不存在重复计算的问题。

不过有时候,你可能需要立即通知某些消费者。你大概已经猜到了,它们就是「live」消费者。这种情况下,只要生产者的值一更新,变更通知就会沿着图传播,通知依赖该生产者的 live 消费者。

其中一些消费者可能本身是派生值,因而也是生产者:它们会让自己缓存的值失效,然后继续把变更通知传播给自己的 live 消费者,如此往下。最终这个通知会抵达 effect,而 effect 会把自己调度为重新执行。

关键在于,在这个阶段不会运行任何副作用,也不会对任何中间值或派生值做重新计算,只做缓存值的失效。这让变更通知能够抵达图中所有受影响的节点,而不会让人观察到中间状态或异常状态。

如有需要,等这轮变更传播(同步地)完成之后,紧接着可以进入我们上面看到的惰性求值阶段。

要看到这个通知阶段的实际效果,我们给示例加一个 live 消费者,例如一个观察者。当 a 更新时,更新会传播到依赖它的 live 消费者:

import { computed, signal } from '@angular/core';
import { createWatch } from '@angular/core/primitives/signals';

const a = signal(0);
const b = computed(() => a() + 'b');
const c = computed(() => a() + 'c');
const d = computed(() => b() + c() + 'd');

setTimeout(() => a.set(1), 3000);

// 观察者会建立对 `d` 的依赖
const watcher = createWatch(
  () => console.log(d()),
  () => setTimeout(watcher.run, 1000),
  false
);

watcher.notify();

只要我们通过 a.set(1) 更新值,就能看到 live 消费者的通知在起作用:

调试器展示 producerNotifyConsumers 沿 live 消费者节点的遍历

节点 bc 是节点 a 的 live 消费者,因此在为 a 执行更新时,Angular 会遍历 node.liveConsumerNode 并把变化通知给这些节点。

但如前所述,这里其实什么也没真正发生。节点只是被标记为脏(dirty),并通过 producerNotifyConsumers 把通知传播给它的 live 消费者:

function consumerMarkDirty(node) {
  node.dirty = true;
  producerNotifyConsumers(node);
  node.consumerMarkedDirty?.(node);
}

这一路一直传到依赖 d 的那个观察者(effect)。与普通响应式节点不同,watch 节点在它的 consumerMarkedDirty 方法中实现了调度:

const WATCH_NODE: Partial<WatchNode> = (() => {
  return {
    ...REACTIVE_NODE,
    consumerIsAlwaysLive: true,
    consumerAllowSignalWrites: false,
    consumerMarkedDirty: (node: WatchNode) => {
      if (node.schedule !== null) {
        node.schedule(node.ref);
      }
    },
    hasRun: false,
    cleanupFn: NOOP_CLEANUP_FN,
  };
})();

通知阶段和图的遍历到此为止。

这个两阶段过程有时被称为「push/pull」算法:源信号变化时,「脏」状态会被急切地推送到整张图,但重新计算是惰性的,只在通过读取信号来拉取值时才发生。

变更检测

为了把基于信号的通知接入变更检测流程,Angular 依赖 live 消费者机制。组件模板会被编译成模板表达式(JS 代码),并在该组件视图的响应式上下文中执行。在这样的上下文里,执行一个信号会返回它的值,同时也会把这个信号注册为该组件视图的依赖。

由于模板表达式是 live 消费者,Angular 会创建一条从生产者到模板表达式节点的连线。一旦生产者的值更新,该生产者会立即、同步地通知模板节点。收到通知后,Angular 会把该组件及其所有祖先标记为待检查。

正如你可能已经从我的其他文章中了解到的,每个组件的模板在内部都表示为一个 LView 对象。对下面这个组件来说是这样的:

@Component({...})
export class AppComponent {
  value = signal(0);
}

编译之后它就是一个普通的 JS 函数 AppComponent_Template,会在该组件的变更检测期间执行:

this.ɵcmp = defineComponent({
  type: AppComponent,
  ...
  template: function AppComponent_Template(rf, ctx) {
    if (rf & 1) {
      ɵɵtext(0);
    }
    if (rf & 2) {
      ɵɵtextInterpolate1("", ctx.value(), "\n");
    }
  },
});

当 Angular 把信号加入其变更检测实现时,它把所有组件视图(模板函数)都包进了一个 ReactiveLViewConsumer 节点:

export interface ReactiveLViewConsumer extends ReactiveNode {
  lView: LView | null;
}

这个接口由 REACTIVE_LVIEW_CONSUMER_NODE 节点实现:

const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = {
  ...REACTIVE_NODE,
  consumerIsAlwaysLive: true,
  consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
    markAncestorsForTraversal(node.lView!);
  },
  consumerOnSignalRead(this: ReactiveLViewConsumer): void {
    this.lView![REACTIVE_TEMPLATE_CONSUMER] = this;
  },
};

你可以这样理解这个过程:每个视图都拿到自己的 ReactiveLViewConsumer 消费者节点,它为模板函数内部访问到的所有信号定义了响应式上下文。

在我们的例子里,每当模板函数作为变更检测的一部分运行时,它都会在模板函数节点的上下文中执行生产者 ctx.value(),而此时该节点就是 ActiveConsumer

模板函数在响应式上下文中执行信号

结果就是模板表达式节点(消费者)被添加为生产者 value() 的一个 live 依赖:

从生产者到模板节点的 live 消费者依赖

这个依赖保证了:一旦生产者 counter 的值发生变化,它会立即通知消费者节点(模板表达式)。

live 消费者实现了 consumerMarkDirty 方法,生产者的值变化时会同步调用它:

/**
 * Propagate a dirty notification to live consumers of this producer.
 */
function producerNotifyConsumers(node: ReactiveNode): void {
  ...
  try {
    for (const consumer of node.liveConsumerNode) {
      if (!consumer.dirty) {
        consumerMarkDirty(consumer);
      }
    }
  } finally {
    inNotificationPhase = prev;
  }
}

function consumerMarkDirty(node: ReactiveNode): void {
  node.dirty = true;
  producerNotifyConsumers(node);
  node.consumerMarkedDirty?.(node);
}

consumerMarkedDirty 内部,模板表达式节点会用 markAncestorsForTraversal 把祖先标记为需要刷新,方式与以前 markForCheck() 的做法类似:

const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = {
  ...
  consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
    markAncestorsForTraversal(node.lView!);
  },
};

function markAncestorsForTraversal(lView: LView) {
  let parent = getLViewParent(lView);
  while (parent !== null) {
    ...
    parent[FLAGS] |= LViewFlags.HasChildViewsToRefresh;
    parent = getLViewParent(parent);
  }
}

最后一个问题是:Angular 什么时候把当前的 LView 消费者节点设为 ActiveConsumer?这些都发生在 refreshView 函数内部,你可能已经从我之前的文章中认识它了。

这个函数为每个 LView 运行变更检测,并执行常见的变更检测操作:执行模板函数、执行钩子、刷新 queries 以及设置 host bindings。基本上,处理响应式的一整段代码就被加在 Angular 执行这些操作之前。

它长这样:

function refreshView<T>(tView, lView, templateFn, context) {
  ...

  // Start component reactive context
  enterView(lView);
  let returnConsumerToPool = true;
  let prevConsumer: ReactiveNode | null = null;
  let currentConsumer: ReactiveLViewConsumer | null = null;
  if (!isInCheckNoChangesPass) {
    if (viewShouldHaveReactiveConsumer(tView)) {
      currentConsumer = getOrBorrowReactiveLViewConsumer(lView);
      prevConsumer = consumerBeforeComputation(currentConsumer);
    } else {... }

    ...

    try {
      ...
      if (templateFn !== null) {
        executeTemplate(tView, lView, templateFn, RenderFlags.Update, context);
      }
  }

由于这段代码在 Angular 于 executeTemplate 中运行组件模板函数之前执行,所以当组件模板中所用信号的访问器函数被执行时,响应式上下文已经准备好了。

effect 与观察者

effect 是一种专门的工具,用来基于应用状态执行带副作用的操作。effect 是用一个在响应式上下文中执行的回调来定义的 live 消费者。这个函数的信号依赖会被捕获,而只要它的任何依赖产出新值,effect 就会被通知。

在大多数应用代码里很少需要 effect,但在特定场合它会有用。以下是 Angular 文档中给出的一些用法示例:

  • 记录数据日志,或让数据与 window.localStorage 保持同步
  • 添加无法用模板语法表达的自定义 DOM 行为,例如向 <canvas> 元素做自定义渲染

Angular 并不在变更检测机制中用 effect 来触发组件的 UI 更新。正如变更检测一节所述,这项功能靠的是 live 消费者机制。

虽然信号算法是标准化的,但 effect 该如何表现的细节并没有被定义,各框架之间会有差异。这是因为 effect 的调度性质微妙,往往要与框架的渲染周期以及其他 JavaScript 无法触及的高层、框架特有的状态或策略相结合。

不过,信号提案定义了一套原语,即 watch API,框架作者可以用它来创建自己的 effect。Watcher 接口用于观察一个响应式函数,并在该函数的依赖发生变化时收到通知。

在 Angular 中,effect 是对 watcher 的一层封装。我们先看观察者是怎么工作的,然后就能看到它们是如何用来搭建 effect 原语的。

首先,我们从 Angular 原语中导入 watcher,并用它实现一套通知机制:

import { createWatch } from '@angular/core/primitives/signals';

const counter = signal(0);

const watcher = createWatch(
  // 运行用户提供的回调并建立追踪
  // 它会被执行 2 次:
  // 第 1 次在 `watcher.notify()` 之后,第 2 次在 `this.counter.set(1)` 之后
  () => counter(),
  // 这个由 `notify` 方法调用,
  // 或者由消费者自己通过 consumerMarkDirty 方法调用,
  // 它把用户提供的回调调度到 1000 毫秒后运行
  () => setTimeout(watcher.run, 1000),
  false
);

// 把观察者标记为脏(过期),以强制运行用户提供的回调
// 并为 `counter` 信号建立追踪
// `notify` 方法在底层会调用 `consumerMarkDirty`
watcher.notify();

// 值变化时会执行 consumerMarkDirty,
// 它会调度用户提供的回调去运行
setTimeout(() => this.counter.set(1), 3000);

当我们运行 watcher.notify() 时,Angular 会同步调用观察者节点上的 consumerMarkDirty 方法。但用户定义的通知回调并不会在收到通知时立即执行,而是通过 watcher.run 被调度到将来某个时刻运行。watch 在收到「markDirty」通知时,只会去调用这个调度操作。

这里可以看到它的实际效果:

观察者通知与调度

当我们运行 this.counter.set(1) 时,同一条调用链会导致用户提供的回调被调度。

为了构建 effect() 函数,Angular 把观察者包进了 EffectHandle 类:

export function effect(effectFn,options): EffectRef {
  const handle = new EffectHandle();
  ...
  return handle;
}

class EffectHandle implements EffectRef, SchedulableEffect {
  unregisterOnDestroy: (() => void) | undefined;
  readonly watcher: Watch;

  constructor(...) {
    this.watcher = createWatch(
      (onCleanup) => this.runEffect(onCleanup),
      () => this.schedule(),
      allowSignalWrites,
    );
    this.unregisterOnDestroy = destroyRef?.onDestroy(() => this.destroy());
  }

可以看到 EffectHandle 类就是设置观察者的地方。对于上面那个直接使用观察者的例子,改用 effect 函数会让写法大大简化:

import { Component, effect, signal } from '@angular/core';

@Component({...})
export class AppComponent {
  counter = null;

  constructor() {
    this.counter = signal(0);

    // 这会执行 2 次
    effect(() => this.counter());

    setTimeout(() => this.counter.set(1), 3000);
  }
}

直接使用 effect 函数时,我们只传一个回调。这就是用户定义的回调:它建立依赖,并在依赖更新时由 Angular 调度运行。

Angular 的 effect 目前使用的调度器是 ZoneAwareEffectScheduler,它把更新作为微任务队列的一部分,在变更检测周期之后执行:

export class ZoneAwareEffectScheduler implements EffectScheduler {
  private queuedEffectCount = 0;
  private queues = new Map<Zone | null, Set<SchedulableEffect>>();
  private readonly pendingTasks = inject(PendingTasks);
  private taskId: number | null = null;

  scheduleEffect(handle: SchedulableEffect): void {
      this.enqueue(handle);
      if (this.taskId === null) {
        const taskId = (this.taskId = this.pendingTasks.add());
        queueMicrotask(() => {
          this.flush();
          this.pendingTasks.remove(taskId);
          this.taskId = null;
        });
      }
    }

为了「初始化」effect,Angular 还必须实现一个有趣的小机关。正如我们在用观察者的实现中看到的,我们需要手动调用一次 watcher.notify() 才能启动追踪。Angular 也需要这么做,它把这件事放在第一轮变更检测里完成。

实现方式如下。

当你在组件的注入上下文中执行 effect 函数时,Angular 会把通知回调加到组件的视图对象 LView[EFFECTS_TO_SCHEDULE] 上:

export function effect(
  effectFn: (onCleanup: EffectCleanupRegisterFn) => void,
  options?: CreateEffectOptions,
): EffectRef {
  ...
  const handle = new EffectHandle();

  // Effects need to be marked dirty manually to trigger their initial run. The timing of this
  // marking matters, because the effects may read signals that track component inputs, which are
  // only available after those components have had their first update pass.
  // ...
  const cdr = injector.get(ChangeDetectorRef, null, {optional: true}) as ViewRef<unknown> | null;
  if (!cdr || !(cdr._lView[FLAGS] & LViewFlags.FirstLViewPass)) {
    // This effect is either not running in a view injector, or the view has already
    // undergone its first change detection pass, which is necessary for any required inputs to be
    // set.
    handle.watcher.notify();
  } else {
    // Delay the initialization of the effect until the view is fully initialized.
    (cdr._lView[EFFECTS_TO_SCHEDULE] ??= []).push(handle.watcher.notify);
  }

  return handle;
}

以这种方式加入的通知函数,会在该组件视图第一轮变更检测期间、在 refreshView 函数内部被执行一次:

export function refreshView<T>(tView,lView,templateFn,context) {
   ...

   // Schedule any effects that are waiting on the update pass of this view.
    if (lView[EFFECTS_TO_SCHEDULE]) {
      for (const notifyEffect of lView[EFFECTS_TO_SCHEDULE]) {
        notifyEffect();
      }

      // Once they've been run, we can drop the array.
      lView[EFFECTS_TO_SCHEDULE] = null;
    }
}

调用 notifyEffect 会触发底层观察者的 consumerMarkDirty 通知回调,后者又会用现有调度器把 effect(用户提供的回调)调度起来运行(在变更检测之后):

通过 refreshView 中的 notifyEffect 初始化 effect

这就是全部故事了。