这篇文章来自我们团队的蒋子洋同学分享,他介绍了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 吗?它的依赖图可以这样表示:
图中的叶子节点即为 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
maptrackingVersion
版本号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 读值动作。
通知过程为:
-
遍历
consumers
map,拿到 consumer 引用 -
此时该 consumer 有可能
- 已经完成了生命周期(被垃圾收集)
- node 和 edge 的
trackingVersion
不匹配
这时候需要从依赖关系里删除这个引用。
-
如果不是以上状态,则调用
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;
}
检查过程为:
-
遍历
producers
map,拿到 producer 引用 -
此时该 producer 有可能
- 已经完成了生命周期(被垃圾收集)
- node 和 edge 的
trackingVersion
不匹配
这时候需要从依赖关系里删除这个引用。
-
如果不是以上状态,则调用
producerPollStatus()
检查值是否改变。 -
所有 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。
更新过程为:
- 从
activeConsumer
中获取该 producer 对应的ReactiveEdge
。 - 有可能 edge 还未创建(新的依赖关系),则新建 edge 并更新对应
producers
和consumers
map。 - 有可能 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
都是独立的。
第二阶段读取 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
除了计算值外,还有几种特殊状态,如 UNSET
, COMPUTING
用来标记计算过程。由于 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
。
阶段三,修改 useA
的值。useA
的 valueVersion
自增,触发 producerMayHaveChanged()
通知下游。computed()
会因此将内部变量 this.stale
标记成 true
。
注意,重新计算并未执行。这样做的理由,请阅下一章节。
此时 dynamic
和 连接两者的 edge 中 trackingVersion
都是1,依赖图保持不变。
阶段四,dynamic
的值再一次被读取。重复阶段二的过程,由于 useA
的 valueVersion
和 edge 中不一致,因此 recomputeValue()
再一次被执行。最终形成的依赖图如下:
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);
依赖图如下:
当 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 的性能表现如何,请关注此系列的后续文章🦄