Angular 中的信号(Signals):给忙碌开发者的深入剖析
构建复杂的用户界面是一件难事。在现代 Web 应用里,UI 状态很少由简单的独立值组成,它更像是一个复杂的计算状态,依赖于其他值或计算状态的复杂层级。管理这种状态需要大量工作:开发者必须存储、计算、失效并同步这些值。
多年来,Web 开发中引入了各种框架和原语来简化这项工作。它们大多有一个共同主题——响应式编程,它为管理应用状态提供了基础设施,让开发者把精力放在业务逻辑上,而不是重复性的状态管理工作。
最新加入的是信号(signals),一种「响应式」原语,它表示一个动态变化的值,并能在值变化时通知感兴趣的消费者。后者进而可以触发重新计算或各种副作用,例如创建/销毁组件、发起网络请求、更新 DOM 等等。
我们能在不同框架中看到不同的信号实现。现在甚至有人在推动信号的标准化:
……这项工作着眼于统一 JavaScript 生态。几位框架作者正在这里协作,打造一个可以支撑各自响应式内核的通用模型。当前草案基于 Angular、Bubble、Ember、FAST、MobX、Preact、Qwik、RxJS、Solid、Starbeam、Svelte、Vue、Wiz 等项目作者/维护者的设计意见……
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);
...
}这个调用栈也清楚地展示了这一实现:

正因如此,在 computed 的回调执行期间,凡是在该消费者处于活跃状态时被查询的生产者,都会知道自己是在响应式上下文中执行的。所有在某个特定消费者的响应式上下文中执行的生产者,都会被添加为该消费者的依赖。 这就构成了响应式图。
Angular 中大部分早已存在的功能都是在非响应式上下文中执行的。只要搜索一下 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 消费者:
它们的定义如下:
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);
});
}
}会产生下面这个图:

通过活跃消费者实现的响应式上下文带来了动态依赖追踪。当某个消费者被设为活跃时,被求值的生产者集合是由这些生产者的调用顺序动态决定的。每当在某个消费者的响应式上下文中访问一个生产者,ActiveConsumer 的依赖列表都可能被重新排列。
为此,消费者的依赖被记录在 producerNode 数组中:
interface ConsumerNode extends ReactiveNode {
producerNode: NonNullable<ReactiveNode['producerNode']>;
producerIndexOfThis: NonNullable<ReactiveNode['producerIndexOfThis']>;
producerLastReadVersion: NonNullable<ReactiveNode['producerLastReadVersion']>;当某个消费者的计算被重新运行时,指向该数组的指针(索引)nextProducerIndex 会被初始化为索引 0,每次读取到的依赖都会与上一轮在指针当前位置上的依赖做比较。如果不匹配,说明依赖自上次运行以来发生了变化,旧依赖就可以被丢弃并替换为新的。运行结束时,所有仍未匹配上的依赖都可以丢弃。
这意味着:如果某个依赖只在其中一个分支里需要,而上一次计算走的是另一个分支,那么改变这个暂时未被使用的值不会导致计算信号重新计算,哪怕它的值被拉取。由此就有了不同次执行访问不同信号集合的可能。
例如,这个计算信号 dynamic 会根据 useA 信号的值来读取 dataA 或 dataB:
const dynamic = computed(() => useA() ? dataA() : dataB());在任意时刻,它的依赖集合要么是 [useA, dataA],要么是 [useA, dataB],绝不可能同时依赖 dataA 和 dataB。
下面这段代码,类似 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 -> D 和 C,然后因为 C 变了又重新求值 D。把 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 消费者的通知在起作用:

节点 b 和 c 是节点 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 依赖:

这个依赖保证了:一旦生产者 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(用户提供的回调)调度起来运行(在变更检测之后):

这就是全部故事了。