为了账号安全,请及时绑定邮箱和手机立即绑定

Angular中的信号:深度解析(忙碌开发者向)

构建复杂的用户界面是一项艰巨的任务。在现代 web 应用程序中,UI 状态很少是由简单的独立值组成的。它更像是一种复杂的计算状态,依赖于复杂层级的其他值或计算状态。管理这种状态需要很多工作:开发人员需要存储、计算、失效和同步这些值的状态。

多年来,各种框架和基础组件被引入到 web 开发中以简化这个过程。其中大多数的核心理念是响应式编程,它提供了管理应用程序状态的基础设施,使开发人员可以将精力集中在业务逻辑上,而不是重复的状态管理任务上。

最近的新增功能是 信号量 ,这是一种“响应式”的原始数据类型,它表示一个动态变化的值,并且在值发生变化时通知相关的订阅者。订阅者可以执行重新计算或各种副作用,例如创建或销毁组件、发起网络请求、更新DOM等。

我们可以在不同的框架中找到各种信号的实现。他们现在甚至有一个努力来统一信号标准:他们正在尝试标准化信号

…这项工作专注于统一JavaScript生态圈。这里的一些框架作者和维护者正在合作制定一个共同的模型,以支持他们的响应式内核。目前的草案基于_ Angular ,Bubble ,Ember ,FAST ,MobX ,Preact ,Qwik ,RxJS ,Solid ,Starbeam ,Svelte ,Vue ,Wiz _等的设计输入……

Angular 中的信号实现与提案中提供的实现非常相似,因此在本文中,我可能会在这两者之间做些交叉引用。

原始文章 以及更多深入的文章可以在Angular Love_博客上找到。

信号作为最基本单位

一个信号代表一个数据单元格,其值可能随时间变化而改变。信号可能是“状态”类型的(即手动设置的值)或“计算”类型的(即基于其他信号计算得出的值)。例如,“计算”类型的信号,例如基于其他信号的公式计算得出的值。

计算信号通过自动跟踪在其评估过程中读取到的其他信号来运作。当读取计算信号时,它会检查其依赖信号是否已更改,并在发现更改后重新评估。

例如,这里我们有一个状态信号 counter 和一个计算信号 isEven。我们将 counter 的初始值设为 0,稍后我们将它改为 1。你可以看到,在 counter0 改为 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) // 将计数器设置为1  

    counter() // 获取计数器值: 1  
    isEven() // 判断是否为偶数: false

另外要注意的是,在上面的例子中,isEven 信号并没有显式地订阅源 counter 信号,而是在其计算逻辑里直接调用了 counter()。这样就足以把这两个信号关联起来了。因此,每当 counter 信号更新了新值时,派生的 isEven 信号也会自动更新。

状态信号和计算信号都被视为值的生产者。生产者代表产生值的信号,它们能够发送变更通知。

状态信号在通过 API 调用更新其值时会改变(产生)其值,而计算信号则会在回调中所用依赖项发生变化时自动更新为新值。

计算产生的信号也可以是消费者,因为它们可能依赖于一些生产者提供的信号(消费)。在其他反应式系统中,例如 Rx,消费者也被称为接收端。

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

这使得计算信号变得懒惰,或者说是以拉取为基础的,意味着它们只有在被访问时才会进行计算,即使底层状态在之前已经改变。在上面的例子中,计算信号的值仅在我们调用 isEven() 的时候才会被计算,尽管 counter 在我们执行 counter.set() 的时候已经更新过。

除了可写的普通信号和计算信号之外,还有一个称为观察者(效应)的概念。与基于拉取的计算信号评估不同,当生产者信号发生变化时,会立即同步调用观察者的回调函数,从而有效地“推送”通知。框架将观察者包装成效应,并提供给用户。效应通过调度来延迟通知用户代码。

与 Promises 不同,信号中的所有东西都是同步操作的。

  • 将信号设置为新值是同步的,这意味着在之后读取依赖于它的任何计算信号时,这种变化会立即反映出来。这种变更没有内建的批处理功能。
  • 读取计算信号的值是同步的——它们的值总是可以立即获取。
  • 当观察者被同步通知时,但是包裹这些观察者的效应可以选择批量处理并通过调度延迟通知。
#实现的细节

内部实现信号定义了一些我想在这篇文章中解释的概念:反应性上下文、依赖图和效应(监视器)。我们先从反应性上下文开始吧。

为了讨论响应式上下文,可以将栈帧(执行帧)理解为定义了JavaScript代码执行的环境。特别是,它定义了哪些对象(变量)可以被函数访问。可以说,这些对象的可用性构成了一个上下文。比如说,在Web Worker上下文中运行的函数,比如无法访问全局的document对象。

反应式上下文定义了一个活跃的消费者对象,它依赖于生产者,并且当其值被读取时,可以被访问器函数使用。 比如说,我们有一个消费者 isEvent,它依赖于 counter 生产者(消费它的值)。这种依赖关系是通过在 computed 回调中访问 counter 的值来定义的。

isEvent = computed(() => counter() % 2 === 0)

当计算出的回调运行时,它会自动执行counter信号的访问器函数以获取其值,在这种情况下,counter信号是在isEvent消费者的响应式上下文中执行的。因此,如果有依赖该生产者值的活跃消费者,该生产者就会在这个响应式上下文中被执行。

为了实现这种响应式上下文机制,每当访问消费者值但尚未重新计算时(在执行 computed 回调之前),我们可以将其设为当前活动的消费者。这可以通过将该消费者对象赋值给一个全局变量并在执行回调期间保持该值来实现。这个全局变量在执行 computed 回调期间对所有被查询的生产者来说是可用的,并且它将为该消费者依赖的所有生产者定义响应式上下文。

这正是 Angular 的做法。当计算回调执行时,它首先将当前节点设置为活跃消费者,然后再 [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](https://github.com/angular/angular/blob/a5b5b7d5ef84b9852d2115dd7a764f4ab3299379/packages/core/primitives/signals/src/computed.ts#L53) 工厂函数中的 producerUpdateValueVersion 实现这一功能:

function createComputed<T>(computation: () => T): ComputedGetter<T> {  
  ...  
  const computed = () => {  
    // 更新节点的值版本
    producerUpdateValueVersion(node);  
    ...  
  };  
}  

function producerUpdateValueVersion(node: 响应式节点): void {  
  ...  
  node.producerRecomputeValue(node);  // 生产者重新计算值(node)  
  ...  
}

这个调用栈也清晰地展示了这种实现方式,这进一步表明了...

由于这个原因,当计算的回调被执行时,每个在该消费者活跃期间被查询的生产者都知道自己是在响应式上下文中运行的。所有这些生产者都会被添加为该消费者的依赖项。 这形成了一个响应式图。

大多数现有的 Angular 功能都在非响应式环境中运行。你可以通过搜索带有 null 值的 setActiveConsumer 的用法来观察这一点。

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

    /**

* 调用单个生命周期钩子,确保在非响应环境执行并记录性能数据。
     */
    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](https://github.com/angular/angular/blob/1081c8d6233ba1ff09187b95a09b0644e130cdf8/packages/core/primitives/signals/src/graph.ts#L238)(生产者访问函数)内部:

export function 生产者被访问(node: ReactiveNode): void {  
  ...  
  // 这个生产者是活跃消费者的第 `idx` 个依赖。  
  const idx = activeConsumer.nextProducerIndex++;  
  if (activeConsumer.producerNode[idx] !== node) {  
    // 我们是 `idx` 位置上的新依赖项。  
    activeConsumer.producerNode[idx] = node;  
    // 如果活跃消费者是活动的,则将其添加为活跃消费者。否则,使用 0 作为占位符。  
    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']>;
      // 当前节点的生产者最后读取版本

某些消费者也被视为“实时”消费者,并且会在相反的方向上建立连接,**从生产者指向消费者**。这些连接用于当生产者的值更新时传播变更通知。
interface 生产者节点接口 extends 反应式节点 {  
  实时消费者节点: 非空值<反应式节点的实时消费者节点>;  
  实时消费者索引: 非空值<反应式节点的实时消费者索引>;  
}
或简化为:
interface 生产者节点接口 extends 反应式节点 {  
  实时消费者节点: 生产者节点接口的实时消费者节点;  
  实时消费者索引: 生产者节点接口的实时消费者索引;
}

消费者总是关注他们依赖的生产者。生产者只会追踪那些被认为是“始终活跃”的消费者。当一个消费者的 `consumerIsAlwaysLive` 属性被设置为 `true` 时,或者它自己是一个被某个始终活跃的消费者依赖的生产者,那么它就被视为“始终活跃”。

在 Angular 中,包括两种类型的节点作为活跃消费者。

* [监视](https://github.com/angular/angular/blob/a5b5b7d5ef84b9852d2115dd7a764f4ab3299379/packages/core/primitives/signals/src/watch.ts#L137) 节点(用于效果钩子)
* 响应式 [LView](https://github.com/angular/angular/blob/4c7d5d8acd8a714fe89366f76dc69f91356f0a06/packages/core/src/render3/reactive_lview_consumer.ts#L51) 节点(用于变化侦测)

它们的定义如下:
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!);  // 确保这里的感叹号表示 lView 不可能为 null
  },  
  consumerOnSignalRead(this: ReactiveLViewConsumer): void {  
    this.lView![REACTIVE_TEMPLATE_CONSUMER] = this;  
  },  
};

在某些情况下,`computed` 信号会变成“动态”消费者,例如,当在 `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; // 创建一个依赖于 b 的效果

    // 需要等待变更检测通知,然后触发效果
    setTimeout(() => {  
      // 效果依赖于 B  
      const depEToB = e.watcher[SIGNAL].producerNode[0] === nodes[B];  

      // 实时消费者链从 A 到 B,再从 B 到 E,因为 E 是一个实时消费效果
      const depLiveAToB = nodes[A].liveConsumerNode[0] === nodes[B];  
      const depLiveBToE = nodes[B].liveConsumerNode[0] === e.watcher[SIGNAL];  

      console.log(depLiveAToB, depLiveBToE, depEToB); // 打印依赖关系
    });  
  }  
}  

如下所示的图表将被生成

![](https://imgapi.imooc.com/672c5d55093ede1514000573.jpg)

通过活跃消费者实现反应式上下文,从而启用**动态追踪依赖**。当某个消费者设为活跃时,这些生产者的调用顺序动态定义了被评估的生产者。每当在该消费者反应式上下文中访问一个生产者时,对于`ActiveConsumer`,依赖列表可能重新排列。

为了实现这个目标,消费者的依赖会被记录在 `producerNode` 数组中。
接口ConsumerNode是ReactiveNode的特化版本。  
producerNode: 消费节点关联的非空生产节点;  
producerIndexOfThis: 消费节点关联的非空生产节点索引;  
producerLastReadVersion: 消费节点关联的非空生产节点最新读取版本;

当为某个消费者重新运行计算时,初始化一个指针(索引)`producerIndexOfThis` 为数组中的索引 `0`。每个依赖项读取都会与指针当前位置处上一次运行的依赖项进行比较。如果发现不一致,则表示自上次运行以来依赖项已发生变化,旧依赖项可以被替换为新依赖项。在运行结束时,任何未匹配的依赖项都可以被丢弃。

这意味着如果你有一个仅在一个分支上需要的依赖项,并且之前的计算选择了另一个分支的话,那么该暂时未用到的值的任何更改都不会导致这些计算出的信号被重新计算,即使被拉取也不会重新计算。这会导致不同执行中访问到的信号集可能不同。

例如,计算信号 `dynamic` 会读取 `dataA` 或 `dataB`,根据 `useA` 信号的值。

const dynamic = computed(() => useA() ? dataA() : dataB());
// 使用useA()判断,如果为真则返回dataA()的结果,否则返回dataB()的结果


在任何时候,它的依赖集都是`[useA, dataA]`或`[useA, dataB]`,但绝不能同时依赖`dataA`和`dataB`。

此代码类似于该测试用例(https://github.com/proposal-signals/signal-polyfill/blob/4cf87cef28aa89e938f079e4d82e9bf10f6d0a4c/tests/behaviors/dynamic-dependencies.test.ts#L4。),清楚地表明了:
import { 计算, 信号 } from '@angular/core';  
import { SIGNAL } from '@angular/core/primitives/信号s';  

const 状态 = Array.from('abcdefgh').map((s) => 信号(s));  
const 源 = 信号(状态);  

const vComputed = 计算(() => {  
  let str = '';  
  for (const state of 源()) str += state();  
  return str;  
});  

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

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

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

function expectEqual(v1, v2): any {  
  if (v1 !== v2) throw new Error(`期望 ${v1} 等于 ${v2}`);  
}  
function expectEqualArrayElements(v1, v2): any {  
  if (v1.length !== v2.length) throw new Error(`期望两个数组的长度相等`);  
  for (let i = 0; i < v1.length; i++) {  
    if (v1[i] !== v2[i]) throw new Error(`期望 ${v1[i]} 等于 ${v2[i]}`);  
  }  
}

正如你所见,这个图并没有单一的起点顶点。每个消费者都维护着一个依赖生产者的列表,而这些生产者可能又有自己的依赖,例如计算信号。因此,每次访问时每个消费者都可以被视为图的根节点。

## 两个阶段的更新

早期的基于推送的模型面临冗余计算的挑战:如果状态信号更新导致计算信号立即触发,最终可能会导致一次UI更新。但这种写入UI的操作可能是过早的,因为在下一帧刷新之前,源状态信号可能再次变更。

例如对于这样的图,这个问题包括评估了 `A -> B -> D`,以及 `C`,然后由于 `C` 的变化,需要重新评估 `D`。重复评估 `D` 是低效的,可能导致用户注意到明显的延迟。

![](https://imgapi.imooc.com/672c5d5609eb78fa01250233.jpg)

这被称作钻石难题。

有时,由于这样的[ glitches(故障)],有时不准确的中间值甚至会被最终用户直接看到。信号采用拉取式(惰性)而非推送式的方式来避免这种情况,从而当框架需要渲染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` 的依赖项,来决定是否需要重新计算 `d()` 的值。

所有响应式节点都记录依赖节点的版本,以实现高效处理。要确定变化,只需比较生产者节点保存的版本与实际版本即可。
interface ConsumerNode extends ReactiveNode {  
  ...  
  producerLastReadVersion: NonNullable<ReactiveNode['producerLastReadVersion']>;  
}  

function consumerPollProducersForChange(node) {  
  ...  
  // 轮询生产者是否有变化。  
  for (let i = 0; i < node.producerNode.length; i++) {  
    const producer = node.producerNode[i];  
    const seenVersion = node.producerLastReadVersion[i];  
    // 首先检查版本。如果版本不同,则表明自上次读取以来生产者的值已发生变化。  
    if (seenVersion !== producer.version) {  
      return true;  
    }

如果有所不同,说明生产者发生了变化,实现将通过 `producerRecomputeValue` 重新计算回调值。
export function producerUpdateValueVersion(node: ReactiveNode): void {  
  ...  

  if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) {  
    // 由于我们的生产者自上次读取后没有报告任何变化,所以无需重新计算值,可以认为当前状态是干净的。  
    node.dirty = false;  
    node.lastCleanEpoch = epoch;  
    return;  
  }  

  node.producerRecomputeValue(node);  

  // 重新计算值后,我们就不再处于脏状态了。  
  node.dirty = false;  
  node.lastCleanEpoch = epoch;  
}

这将对 `C` 的依赖关系重复执行该过程。这样它将评估分支 `D->C->A`。但由于 `D` 还依赖于 `B`,它将在计算 `D` 之前重新计算 `B`。这样,就避免了 `D` 的重复计算问题。

有时候,你可能需要积极通知某些消费者。正如你可能猜到的那样,这些被称为“活跃的”消费者。在这种情况下,一旦生产者值被更新,变更通知会立即通过图传播,通知那些依赖该生产者的活跃消费者。

其中一些消费者既是派生值,因此它们也是生产者,这将导致它们的缓存值失效,并继续将变更通知给它们的消费者,以此类推。最终,这些通知会到达效应,效应将安排重执行。

**关键的是,在这个阶段,不会有任何副作用被执行,也不会重新计算中间或衍生值,只会使缓存值失效。这使得变更通知能够传递到所有受影响的节点,而不会观察到中间或异常的状态。**

如果需要,一旦这个变更同步传播完成,可以接着进行我们上面讨论过的延迟求值阶段。

要看到这个通知阶段的运作,让我们向我们的设置中添加一个实时订阅者,例如一个订阅者。当 `a` 更新时,更新会被传播到所有依赖的实时订阅者。
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 监听器 = createWatch(  
  () => console.log(d()),  
  () => setTimeout(监听器.run, 1000),  
  false  
);  

监听器通知();

一旦我们更新了 `a.set(1)`,实时消费者就能收到更新的通知。

![](https://imgapi.imooc.com/672c5d58093d09d912730769.jpg)

节点 `b` 和 `c` 是节点 `a` 的活消费者,因此当更新 `a` 时,Angular 会遍历 `node.liveConsumerNode` 列表并告知这些节点变化。

但如前所述,这里实际上并没有真正发生什么事情。节点只是被标记为脏,并通过 `producerNotifyConsumers` 通知其活跃消费者:

function 标记消费节点为脏数据(node) {
节点的脏数据状态设置为真;
生产者通知消费者节点;
节点的消费标记脏数据处理器存在则调用(node);
}


这一切都依赖于监视器(效果),该监视器依赖于 `d`。与常规反应式节点不同,监视节点在其 `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, // 清理函数
};
})();


到这里,通知阶段结束,图的遍历也停止了。

这个两阶段的过程有时被称为“推/拉”算法:当源信号发生变化时,“脏度”会积极地传播,但只有在读取信号值时才会懒惰地重新计算。“脏度”指的是不干净的程度或污染程度。

## 变更检测

为了将基于信号的提醒集成到更改检测过程中,Angular 依赖于实时订阅机制。组件模板会被编译为模板表达式(JS 代码),并在该组件的视图中以响应式方式执行。在这种情况下,执行信号不仅会返回其值,还会将其注册为该组件视图的依赖关系。因此,执行信号会将该信号作为该组件视图的依赖。

**由于模板表达式是活跃的消费者,Angular 会在生产者和模板表达式节点之间建立一个链接。一旦生产者的值被更新,生产者会立即同步通知相关的模板节点。接到通知后,Angular 会标记组件及其所有祖先节点进行检查。**

正如你可能已经知道的[在我的其他文章中](http://df),每个组件的模板在内部,表示成`LView`对象。组件的模板在内部看起来像这样:

@Component({...})
导出类 AppComponent {
值 = 信号(0);
}

解释:这里的代码定义了一个名为 AppComponent 的组件,它有一个名为值的属性,该属性使用信号函数初始化为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](https://github.com/angular/angular/blob/4c7d5d8acd8a714fe89366f76dc69f91356f0a06/packages/core/src/render3/reactive_lview_consumer.ts#L51)` 实现的:
const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = {  
  ...REACTIVE_NODE,  
  consumerIsAlwaysLive: true,  
  consumerMarkedDirty: (node: ReactiveLViewConsumer) => {  
    markAncestorsForTraversal(node.lView!);  
  },  
  // 当信号被读取时,将当前消费者设置到对应的lView中
  consumerOnSignalRead(this: ReactiveLViewConsumer): void {  
    this.lView![REACTIVE_TEMPLATE_CONSUMER] = this;  
  },  
};

你可以把这个过程想成每个视图都获得了自己的 `ReactiveLViewConsumer`,定义了模板函数内使用的所有信号的反应式上下文。

在我们的案例中,每当模板函数运行作为变更检测的一部分时,它将在该模板函数节点的上下文中执行 `ctx.value()`,该节点是 `ActiveConsumer`。

![](https://imgapi.imooc.com/672c5d5909696c9a12590856.jpg)

这将把模板表达式节点(消费者)作为**动态**依赖项添加到生产者`value()`中。

![](https://imgapi.imooc.com/672c5d5c09bb9a0e13410772.jpg)

此依赖确保一旦生产者 `counter` 的值发生变化,会马上通知消费者节点(模板中的表达式)。

实时消费者实现名为 `consumerMarkDirty` 的方法,当生产者值发生变化时,该方法会被同步调用。
/**
  • 将脏数据通知传播给该生产者的实时消费者。
    */
    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);
    // 如果有consumerMarkedDirty函数,则调用它
    node.consumerMarkedDirty?.(node);
    }

consumerMarkedDirty 时,模板表达式节点会通过 markAncestorsForTraversal 标记其祖先节点以刷新,方式类似于之前 markForCheck() 的做法。

    const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = {  
      ...  
      consumerMarkedDirty: (node: ReactiveLViewConsumer) => {  
        标记该节点的祖先需要刷新(node.lView!);  
      },  
    };  

    function 标记祖先进行遍历(lView: LView) {  
      let parent = 获取LView父级(lView);  
      while (parent !== null) {  
        ...  
        parent[FLAGS] |= LViewFlags.HasChildViewsToRefresh; // 父级节点标记为需要刷新子视图  
        parent = 获取LView父级(parent);  
      }  
    }

最后想问的是什么时候 Angular 会将当前的 LView 消费节点设置为 ActiveConsumer?这一切都发生在你可能已经了解的 [refreshView](https://github.com/angular/angular/blob/4c7d5d8acd8a714fe89366f76dc69f91356f0a06/packages/core/src/render3/instructions/change_detection.ts#L192) 函数内部。

此功能为每个 LView 执行变更检测任务,并运行常见的变更检测操作:执行模板函数,运行钩子,刷新查询,并设置宿主绑定。在 Angular 执行这些操作之前,已经添加了一整套用于处理反应性的代码。

它看起来就像这样:

    function refreshView<T>(tView, lView, templateFn, 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 之前就已经运行了,所以在执行组件模板中使用信号的 accessor 函数时,响应式上下文已经设置好了。

混合型变化识别技术

在 v18 版本的 Angular 中,采用了混合变更检测机制,这使得信号值发生变化时,可以实现无区(Zone)的变更检测。实现这一行为的部分功能添加到了 markAncestorsForTraversal 函数调用中:

请参阅:```ts
https://github.com/angular/angular/blob/31fdf0fbea6b89c8d3d141b2ef8e79c2737287cb/packages/core/src/render3/util/view_utils.ts#L253

export function 标记祖先进行遍历(lView: LView) {
lView[ENVIRONMENT].changeDetectionScheduler?.notify(
NotificationSource.标记祖先进行遍历
);

let 父视图 = getLViewParent(lView);
while (父视图 !== null) { ... }
}


`changeDetectionScheduler` 服务提供了一个实现 `notify` 方法,该方法会触发变更检测的运行(即 `tick`)。具体而言,它会通过微任务或宏任务调度器来安排变更检测的运行。
notify(source: NotificationSource): void {  
    ...  
    // 根据是否使用微任务调度器来选择调度回调函数
    const scheduleCallback = this.useMicrotaskScheduler  
      ? scheduleCallbackWithMicrotask  
      : scheduleCallbackWithRafRace;  

    // 添加待渲染任务ID
    this.pendingRenderTaskId = this.taskService.add();  
    if (this.zoneIsDefined) {  
      // 在根区执行回调
      Zone.root.run(() => {  
        // 调度回调并设置取消回调
        this.cancelScheduledCallback = scheduleCallback(() => {  
          this.tick(this.shouldRefreshViews);  
        });  
      });  
    } else {  
      // 直接调度回调
      this.cancelScheduledCallback = scheduleCallback(() => {  
        this.tick(this.shouldRefreshViews);  
      });  
    }  
  }

## 效果与监听器:

效果是一种特别设计的工具,用于执行基于应用程序状态的副作用操作。效果是由回调函数定义的实时消费者,在反应式上下文中运行。该函数的信号依赖会被捕获,一旦其任何依赖项产生新值,效果会得到通知。

效果在大多数应用代码中并不常见,但在某些特定情况下可能会很有帮助。下面是一些Angular官方文档中提供的使用示例。

* 记录数据,或让数据与 `window.localStorage` 保持同步
* 添加无法通过模板语法实现的自定义 DOM 行为,比如对 `<canvas>` 元素执行自定义渲染

**Angular 在更改检测机制中不使用效果来触发组件的 UI 更新。如前所述,在更改检测部分,它依赖于实时订阅者机制来实现这一功能。**

虽然信号的算法已经标准化,但效果应该如何行为的细节还没有定义,并且会因框架而异。这是由于效果调度的微妙性质,通常与框架的渲染周期及其他高级框架特定状态或策略集成,而这些都是JavaScript无法访问的。

然而,信号提议定义了一组原语,即 [watch](https://github.com/angular/angular/blob/main/packages/core/primitives/signals/README.md#side-effects-createwatch) API,框架作者可以利用这些原语来创建自己的效应。`Watcher`接口用于监视一个反应式函数的变化,并在依赖项发生变化时接收通知。

在 Angular 中,`effect` 是对 `watcher` 的一个封装。首先,我们来看看 `watcher` 是怎么工作的,然后我们将看到它们是如何被用来构建 `effect` 原型的。

首先,我们将从 Angular 的原始模块中导入 `watcher`,并使用它来实现通知机制:

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

const counter = signal(0);

const watcher = createWatch(
// 执行用户提供的回调,并设置跟踪
// 这将执行 2 次
// 第一次在 watcher.notify() 之后,第二次在 this.counter.set(1) 之后
() => counter(),
// 由 notify 方法调用
// 或者通过调用消费者本身的 consumerMarkDirty 方法
// 安排用户提供的回调在 1000 毫秒后运行
() => setTimeout(watcher.run, 1000),
false
);

// 将 watcher 标记为脏数据以强制执行用户提供的回调
// 并为 counter 信号设置跟踪
// notify 方法会在内部调用 consumerMarkDirty
watcher.notify();

// 当值发生变化时,会执行 consumerMarkDirty
// 这将安排用户提供的回调在 3 秒后运行
setTimeout(() => this.counter.set(1), 3000); // 设置计数器为 1


当我们调用 `watcher.notify()` 方法时,Angular 会同步调用 `consumerMarkDirty` 方法于 watcher 节点上。然而,用户定义的通知回调不会立即被执行,即使接收到通知,而是通过 `watcher.run` 安排在未来的某个时间执行。当 `watch` 收到 “markDirty” 通知时,它会简单地执行这个调度操作。

这里你可以看到实际演示:

![](https://imgapi.imooc.com/672c5d5e0955f24214000448.jpg)

当我们运行 `this.counter.set(1)` 时,同样的调用链会安排用户提供的回调函数执行。

为了创建 `effect()` 函数,Angular 将监听器封装在 `EffectHandle` 的类里:

/**

  • 导出函数effect,该函数接收effectFn和options参数,返回EffectRef对象。

  • @param effectFn 效果函数

  • @param options 选项
    */
    export function effect(effectFn, options): EffectRef {
    const handle = new EffectHandle();
    ...
    return handle;
    }

/**

  • EffectHandle类实现EffectRef和SchedulableEffect接口,包含unregisterOnDestroy和watcher属性。
    */
    class EffectHandle implements EffectRef, SchedulableEffect {
    /**

  • unregisterOnDestroy是一个可能未定义的函数,用于在销毁时执行清理操作。
    */
    unregisterOnDestroy: (() => void) | undefined;
    /**

  • watcher是一个只读的观察者对象。
    */
    readonly watcher: Watch;

    constructor(...) {
    /**

  • 创建一个观察者对象watcher,该对象在清理和调度时调用runEffect和schedule方法。

  • allowSignalWrites是一个布尔值,表示是否允许信号写入。
    */
    this.watcher = createWatch(
    (onCleanup) => this.runEffect(onCleanup),
    () => this.schedule(),
    allowSignalWrites,
    );
    /**

  • 设置unregisterOnDestroy为destroyRef的onDestroy方法,如果destroyRef存在,则在销毁时调用destroy方法。

  • destroyRef是一个销毁引用对象,其中onDestroy方法用于执行销毁操作。
    */
    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);  

        // 这个效果将会运行两次  
        effect(() => this.counter());  

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

当我们直接使用effect函数时,我们只传递一个回调。这是用户定义的一个回调,它设置依赖,并在依赖更新时由Angular调度执行。

当前 Angular 效果使用的调度器是 ZoneAwareEffectScheduler,它会在更改检测周期之后在微任务队列中运行更新操作。

export class 区域感知效果调度器 implements 效果调度器接口 {  
  private 排队效果计数 = 0;  
  private 队列 = new Map<区域 | null, Set<可调度效果>>();  
  private readonly 待处理任务 = inject(待处理任务);  
  private 任务Id: number | null = null;  

  调度效果(handle: 可调度效果): void {  
      this.入队(handle);  
      if (this.任务Id === null) {  
        const 新任务Id = (this.任务Id = this.待处理任务.add());  
        queueMicrotask(() => {  
          this.刷新效果();  
          this.待处理任务.remove(新任务Id);  
          this.任务Id = null;  
        });  
      }  
    }

有一个有趣的特点,Angular 必须实现来“初始化”这个效果。正如我们在实现中使用观察者时看到的,需要手动调用 watcher.notify() 一次来开始跟踪。Angular 也需要这样做,并在第一次变更检测时完成此步骤。

就是这样做的。

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

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

      // 需要手动标记效果为脏以触发其初始运行。效果可能会读取组件输入的信号,这些信号在组件经历第一次更新后才可用。
      // ...
      const cdr = injector.get(ChangeDetectorRef, null, {optional: true}) as ViewRef<unknown> | null;  
      if (!cdr || !(cdr._lView[FLAGS] & LViewFlags.FirstLViewPass)) {  
        // 这个效果要么不在视图提供器中运行,要么视图已经完成了它的第一次变更检测,这是设置必要输入所必需的。
        handle.watcher.notify();  
      } else {  
        // 在视图完全初始化后再初始化效果。
        (cdr._lView[EFFECTS_TO_SCHEDULE] ??= []).push(handle.watcher.notify);  
      }  

      return handle;  
    }

通过这种方式添加的通知功能只会在这个组件视图在 refreshView 函数内的首次变更检测时执行一次:

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

    // 安排此视图更新阶段时等待的任何效果。
    if (lView[EFFECTS_TO_SCHEDULE]) {  
        for (const notifyEffect of lView[EFFECTS_TO_SCHEDULE]) {  
            notifyEffect();  
        }  

        // 一旦执行完毕,我们可以丢弃该数组。
        lView[EFFECTS_TO_SCHEDULE] = null;  
    }  
}

调用 notifyEffect 会触发并调用底层观察者 (watcher) 的 consumerMarkDirty 通知回调。这将安排用户的回调函数在变更检测后的某个时间点,通过现有调度器运行。

这就完事儿了 :)

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消