Angular Signals 实施计划

Angular Signals 实施计划

该目录包含了 Angular的响应式原语的代码,它是 "Signal"概念的实现。Signal 是一个响应式的值,意味着它能够在变化时通知对它感兴趣的消费者。针对这个概念有许多不同的实现方式,包括不同的设计在如何订阅和传播这些通知,如何清理/取消订阅,如何跟踪依赖关系等等。该文档描述了 Signal 模式具体实现背后的算法。

表层概念

Angular Signals 是零参数函数 (() => DeepReadonly<T>)。当执行时,它们会返回 signal 的当前值。执行 signal 不会触发副作用,尽管它可能会惰性地重新计算中间值(惰性记忆)。

特定的上下文(例如模板表达式)可以是_reactive_的。在这样的上下文中,执行 singal 将返回值,但也会将 singal 注册为相关上下文的依赖项。如果其任何 singal 依赖项产生新值(通常,这会导致重新执行这些表达式以使用新值),上下文的所有者将收到通知。

这种上下文和 getter 函数机制允许 automaticallyimplicitly 跟踪上下文的 singal 依赖性。用户不需要声明依赖项数组,特定上下文的依赖项集也不需要在执行过程中保持静态。

可写 signal:signal()

signal() 函数产生一种特定类型的 signal ,称为 WritableSignal 。 除了作为 getter 函数之外,WritableSignal 还有一个额外的 API 用于更改 signal 的值(以及通知任何依赖项更改)。其中包括用于替换 signal 值的 .set 操作、用于导出新值的 .update 操作以及用于执行当前值内部变异的 .mutate 操作。这些作为 signal 获取器本身的函数公开。

const counter = signal(0);
counter.set(2);
counter.update(count => count + 1);

signal 值也可以就地更新,使用专用的 .mutate 方法:

const todoList = signal<Todo[]>([]);
todoList.mutate(list => {
  list.push({title: 'One more task', completed: false});
});
相等

signal 创建函数可以选择指定一个相等比较器函数。比较器用于确定新提供的值与当前 signal 值相比是相同还是不同。

如果相等函数确定 2 个值相等,它将:

  • 阻止 signal 值的更新;
  • 跳过更改传播

声明派生值:computed()

computed() 创建一个记忆 signal,它根据一些输入 signal 的数值计算它的值。

const counter = signal(0);

// 当 `counter` 改变时自动更新:
const isEven = computed(() => counter() % 2 === 0);

因为用于创建 computed 的计算函数是在反应式上下文中执行的,所以该计算读取的任何 signal 都将作为依赖项进行跟踪,并且只要这些依赖项中的任何一个发生变化,计算 signal 的值就会重新计算。

与 signals 类似,computed 可以(可选)指定一个相等比较器函数。

副作用:effect()

effect() 在反应上下文中调度并运行一个副作用函数。捕获此函数的 signal 依赖项,并且只要其任何依赖项产生新值,就会重新执行副作用。

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

counter.set(1);
// counter值为 1

效果不会与集合同步执行(请参阅下面有关无故障执行的部分),而是由框架安排和解决。效果的确切时间未指定。

生产者和消费者

在内部,signals 实现是根据两个抽象定义的,即生产者和消费者。生产者表示可以传递更改通知的值,例如各种风格的 Signal。消费者代表一个反应性上下文,它可能依赖于一定数量的生产者。换句话说,生产者生产反应性,消费者消费它。

任一抽象的实现者都派生自 ReactiveNode 基类,该基类对反应图的参与进行建模。通过与 ReactiveNode 上适当的 API 子集交互,任何 ReactiveNode 都可以扮演生产者、消费者或两者的角色。例如,WritableSignal 扩展了 ReactiveNode 但仅针对生产者 API 进行操作,因为 WritableSignal 不使用其他 signal 的值。

有些概念既是生产者又是消费者。例如,派生的 computed 表达式会消耗其他 signals 以产生新的反应值。

在本文档的其余部分,“生产者” 和 “消费者” 用于描述以该身份运行的 ReactiveNode

依赖图

ReactiveNode 跟踪相互依赖的 ReactiveEdge 。生产者知道哪些消费者依赖于他们的值,而消费者知道他们所依赖的所有生产者。这些引用始终是双向的。

Angular Signals 的一个主要设计特征是使用弱引用 ( WeakRef ) 跟踪依赖边 ( ReactiveEdge )。在任何时候,消费者节点都可能超出范围并被垃圾收集,即使它仍被生产者节点引用(反之亦然)。这消除了对显式清理操作的需要,这些清理操作将消除 signals “超出范围” 的这些依赖边缘。 signals 的生命周期管理因此大大简化,并且不会因为依赖跟踪而发生内存泄漏。

为了通过 WeakRef 简化跟踪 ReactiveEdgeReactiveNode 在创建时会生成数字 ID。这些 ID 用作 Map 键而不是被跟踪的节点对象,后者作为 WeakRef 存储在 ReactiveEdge 中。

在读取或写入 signal 值的不同时间点,这些 WeakRef 被取消引用。如果一个引用被证明是 undefined(也就是说,依赖边缘的另一边被垃圾收集回收),那么依赖 ReactiveEdge 就可以被清理掉。

“Glitch Free” 属性

考虑以下设置:

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

counter.set(1);

当首次创建时,它会按预期打印 “0 is even”,并记录 counterevenOrOdd 都是日志效果的依赖项。

counter 设置为 1 时,这会使 evenOrOdd 和日志记录效果无效。如果 counter.set() 遍历 counter 的依赖项并首先触发日志记录效果,然后再通知 evenOrOdd 更改,我们可能会观察到不一致的日志记录语句 “1 is even”。最终会通知 evenOrOdd,这将再次触发记录效果,记录正确的语句 “1 is odd”。

在这种情况下,日志记录效果观察到的不一致状态 “1 is even” 被称为 glitch(故障)。反应式系统设计的一个主要目标是防止观察到此类中间状态,并确保 glitch-free execution(无故障执行)。

Push/Pull 算法

Angular Signals 通过将对 ReactiveNode 图的更新分为两个阶段来保证无故障执行。当生产者的值改变时,第一阶段急切地执行。此更改通知通过依赖图传播,通知依赖于潜在更新生产者的消费者。这些消费者中的一些可能是派生值,因此也是生产者,它们使缓存值无效,然后继续将更改通知传播给自己的消费者,等等。其他消费者可能是效果,它们安排自己重新执行。

至关重要的是,在第一阶段,没有运行副作用,也没有执行中间值或派生值的重新计算,只是使缓存值失效。这允许更改通知到达图中所有受影响的节点,而无需观察中间或故障状态。

一旦此更改传播完成(同步),第二阶段就可以开始了。在第二阶段中,应用程序或框架可以读取 signal 值,从而触发重新计算之前无效的任何所需派生值。

我们将此称为 “push/pull” 算法:当源 signal 发生变化时, “脏值” 会急切地通过依赖图 “pusj”,但只有在通过读取他们的 signals 来 “pull” 值时才惰性地重新执行计算。

动态跟踪依赖

当执行反应式上下文操作(例如,effect 的副作用函数)时,signals 读取的 signal 将作为依赖项进行跟踪。但是,这可能不是从一次执行到下一次执行的同一组 signal 。例如,这个计算 signal :

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

根据 useA signal 的值读取 dataAdataB。在任何给定的点,它都会有一个依赖集 [useA, dataA][useA, dataB] ,并且它永远不能同时依赖 dataAdataB

反应上下文的潜在依赖性是无限的。Signals 可以存储在变量或其他数据结构中,并时常与其他 signals 交换。因此,signals 实现必须处理消费者对每次执行的依赖集的潜在变化。

一种朴素的方法是在重新执行反应性操作之前简单地删除所有旧的依赖项,或者预先将它们全部标记为陈旧并删除那些没有被读取的项。这在概念上很简单,但计算量很大,特别是对于具有基本不变的依赖集的反应式上下文。

依赖边缘版本控制

相反,我们的实现使用一种更轻量级的依赖失效方法,它依赖于由消费者维护的单调版本计数器,称为 trackingVersion。在执行消费者的反应性操作之前,其 trackingVersion 会递增。当读取 signal 时,消费者的 trackingVersion 存储在依赖项 ReactiveEdge 中,可供生产者使用。

当生产者有更新的值时,它会通过其传出边迭代到任何感兴趣的消费者,以通知他们更改。此时,生产者可以通过将消费者当前的 trackingVersion 与存储在依赖项 ReactiveEdge 上的版本进行比较来检查依赖项是最新的还是过时的。不匹配意味着消费者的依赖项已更改并且不再包括该生产者,因此消费者不会收到通知并且过时的边缘将被删除。

相等语义

生产者可能会惰性地产生他们的值(比如一个 computed,它只会在被拉取时重新计算它的价值)。然而,生产者也可以选择对它产生的值进行相等性检查,并确定新计算的值在语义上与前一个值 “相等”。在这种情况下,不应重新执行依赖该值的消费者。例如下面的效果:

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

isEven 的值从 true 切换到 false 时,如果 counter 更新为 1 应该运行。但是,如果 counter 然后设置为 3isEven 将重新计算相同的值:false。因此,日志记录效果不应运行。

这是我们实现中要保证的一个棘手属性,因为在更改传播的推送阶段不会重新计算值。isEvencounter 改变时失效,这导致日志记录 effect 也失效并被调度。另外,在日志记录效果实际运行并尝试读取它的值之前,不会重新计算 isEven,这已经太晚了,根本不需要运行。

值版本控制

为了解决这个问题,我们的实现使用了类似的技术来跟踪依赖陈旧性。生产者跟踪单调递增的 valueVersion,表示其值的语义标识。当生产者产生语义上的新值时,valueVersion 会递增。当消费者从生产者那里读取时,当前的 valueVersion 被保存到依赖的 ReactiveEdge 结构中。

在消费者触发他们的反应性操作(例如 effect 的副作用函数,或 computed 的重新计算)之前,他们会轮询他们的依赖项并在需要时要求刷新 valueVersion。对于 computed,如果值是陈旧的,这将触发值的重新计算和随后的相等性检查(这使得这个轮询成为一个递归过程,因为 computed 也是一个消费者,它将轮询自己的生产者)。如果此重新计算产生语义更改的值,则 valueVersion 会增加。

然后,消费者可以将新值的 valueVersion 与其依赖项 ReactiveEdge 中缓存的值进行比较,以确定该特定依赖项是否确实发生了变化。通过对所有生产者执行此操作,消费者可以确定,如果所有 valueVersion 都匹配,则没有对任何依赖项的_际更改发生,并且它可以跳过对该更改的反应(例如跳过运行副作用函数)。

Watch 原语

Watch 是用于构建不同类型效果的原语。Watch 是在其反应上下文中运行副作用函数的消费者,但副作用的调度委托给实现者。Watch 会在收到它过时的通知时调用此调度操作。

译源: Angular Signals 实施计划

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值