Сигналы в Angular: глубокое погружение для занятых разработчиков

Создание сложных пользовательских интерфейсов — непростая задача. В современных веб-приложениях состояние UI редко состоит из простых самостоятельных значений. Скорее это сложное вычисляемое состояние, зависящее от сложной иерархии других значений или вычисляемых состояний. Управление этим состоянием требует немалой работы: разработчики должны хранить, вычислять, инвалидировать и синхронизировать эти значения.

За годы в веб-разработке появилось множество фреймворков и примитивов, упрощающих эту задачу. Общая тема для большинства из них — реактивное программирование, которое предоставляет инфраструктуру для управления состоянием приложения, позволяя разработчикам сосредоточиться на бизнес-логике, а не на однообразных задачах управления состоянием.

Самое недавнее пополнение — сигналы, «реактивный» примитив, который представляет динамически меняющееся значение и может уведомлять заинтересованных потребителей об изменении этого значения. Те, в свою очередь, могут запускать перевычисления или различные побочные эффекты, например создание/удаление компонентов, сетевые запросы, обновление DOM и так далее.

Разные реализации сигналов можно найти в разных фреймворках. Сейчас есть даже попытка стандартизировать сигналы:

… эта работа направлена на согласование экосистемы JavaScript. Несколько авторов фреймворков сотрудничают здесь над общей моделью, которая могла бы лежать в основе их реактивного ядра. Текущий черновик основан на проектных предложениях авторов/мейнтейнеров Angular, Bubble, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue, Wiz и других…

Реализация сигналов в Angular очень похожа на реализацию, предложенную в рамках этого proposal, поэтому в статье я могу проводить параллели между ними.

Сигналы как примитивы

Сигнал представляет ячейку данных, которая может меняться со временем. Сигналы бывают либо «состоянием» (просто значение, которое устанавливается вручную), либо «вычисляемыми» (представьте формулу, основанную на других сигналах).

Вычисляемые сигналы работают за счёт автоматического отслеживания того, какие другие сигналы читаются во время их вычисления. Когда вычисляемый сигнал читают, он проверяет, изменилась ли какая-нибудь из ранее записанных зависимостей, и если да — перевычисляет себя.

Например, здесь у нас есть сигнал состояния 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 ни обновился новым значением, производный сигнал тоже автоматически обновляется.

И сигналы состояния, и вычисляемые сигналы считаются производителями (producers) значений. Производители — это сигналы, которые производят значения и могут доставлять уведомления об изменении.

Сигнал состояния меняет (производит) своё значение, когда значение обновляют через вызов API, а вычисляемый сигнал генерирует новое значение автоматически, когда меняются зависимости, использованные в колбэке.

Вычисляемые сигналы могут также быть потребителями (consumers), потому что они могут зависеть от некоторого числа производителей (потреблять их). В других реактивных реализациях, например в Rx, потребителей также называют «стоками» (sinks).

Когда значение сигнала-производителя меняется, значения зависимых потребителей, например вычисляемых сигналов, не обновляются немедленно. Когда вычисляемый сигнал читают, он проверяет, изменилась ли какая-нибудь из ранее записанных зависимостей, и при необходимости перевычисляет себя.

Это делает вычисляемые сигналы ленивыми, или pull-based: они вычисляются только при обращении к ним, даже если лежащее в основе состояние изменилось раньше. В нашем примере выше значение вычисляемого сигнала вычисляется только тогда, когда мы вызываем isEven(), хотя обновление лежащей в основе зависимости counter произошло раньше, когда мы выполнили counter.set().

Помимо обычных записываемых и вычисляемых сигналов есть также концепция наблюдателей (watchers, эффектов). В противоположность pull-based вычислению у computed-сигналов, изменение сигнала-производителя немедленно уведомляет наблюдателя, синхронно вызывая его колбэк уведомления, то есть фактически «выталкивая» (push) уведомление. Фреймворки оборачивают наблюдателей в эффекты, которые и предоставляются пользователям. Эффекты откладывают уведомление пользовательского кода через планирование (scheduling).

В отличие от промисов, в сигналах всё работает синхронно:

  • Установка нового значения сигнала синхронна, и это немедленно отражается при чтении любого вычисляемого сигнала, который от него зависит. Встроенной пакетной обработки (batching) этой мутации нет.
  • Чтение вычисляемых сигналов синхронно — их значение всегда доступно.
  • Наблюдатели уведомляются синхронно, но эффекты, которые их оборачивают, могут группировать и откладывать уведомление через планирование.

Детали реализации

Внутри реализация сигналов определяет ряд концепций, которые я хочу объяснить в этой статье: реактивный контекст, граф зависимостей и эффекты (наблюдатели). Начнём с реактивного контекста.

Чтобы обсуждать реактивный контекст, представьте кадр стека (кадр выполнения), который определяет окружение, внутри которого JavaScript-код вычисляется и выполняется. В частности, он определяет, какие объекты (переменные) доступны функции. Можно сказать, что доступность этих объектов и определяет контекст. Например, функция, которая выполняется в контексте web worker’а, не имеет доступа к глобальному объекту document.

Реактивный контекст определяет активный объект-потребитель, который зависит от производителей и доступен их функции-аксессору всегда, когда читается их значение. Например, здесь у нас есть потребитель isEvent, который зависит от производителя counter (потребляет его значение). Эта зависимость задаётся обращением к значению counter внутри колбэка computed:

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 попадает сюда из producerUpdateValueVersion внутри фабричной функции createComputed:

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 (представления компонентов) и эффекты выполняются в реактивных контекстах.

Реактивный граф

Реактивный граф строится через зависимости между потребителями и производителями. Реализация реактивного контекста через аксессоры значений делает возможным автоматическое и неявное отслеживание зависимостей сигналов. Пользователям не нужно объявлять массивы зависимостей, и набор зависимостей конкретного контекста не обязан оставаться неизменным между выполнениями.

Когда производитель выполняется, он добавляет себя в зависимости текущего активного потребителя (потребителя, задающего текущий реактивный контекст). Это происходит внутри функции 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']>;
}

Потребители всегда отслеживают производителей, от которых зависят. Производители отслеживают зависимости только от тех потребителей, которые считаются «живыми». Потребитель «живой», когда у него свойство consumerIsAlwaysLive установлено в true или когда он сам является производителем, от которого зависит живой потребитель.

В Angular как живые потребители определены два типа узлов:

  • узлы watch (используются в эффектах)
  • реактивные узлы LView (используются в change detection)

Вот их определения:

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 могут становиться «живыми» потребителями — например, когда используются в колбэке 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;

    // нужно дождаться, пока change detection уведомит эффект
    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);
    });
  }
}

даст такой граф:

реактивный граф зависимостей с узлами 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 читает либо dataA, либо dataB в зависимости от значения сигнала useA:

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}`);
  }
}

Как видите, у графа нет одной единственной начальной вершины. Поскольку каждый потребитель держит список производителей-зависимостей, у которых, в свою очередь, тоже могут быть зависимости (например, у вычисляемого сигнала), можно сказать, что каждый потребитель в момент обращения к нему является корневой вершиной графа.

Двухфазные обновления

Более ранние push-based модели реактивности сталкивались с проблемой избыточных вычислений: если обновление сигнала состояния заставляет вычисляемый сигнал сразу же выполниться, в итоге это может вытолкнуть обновление в UI. Но такая запись в UI может оказаться преждевременной, если исходный сигнал состояния должен был измениться ещё раз до следующего кадра.

Например, для такого графа проблема состоит в том, что мы непреднамеренно вычисляем A -> B -> D, затем C, а потом заново вычисляем D, потому что C изменился. Вычислять D дважды неэффективно и может приводить к заметным для пользователя артефактам.

граф ромбовидной проблемы: A -> B -> D и A -> C -> D

Это известно как проблема ромба (diamond problem).

Иногда из-за таких сбоев (glitches) конечным пользователям даже показывались неверные промежуточные значения. Сигналы избегают этой динамики, будучи pull-based (ленивыми), а не push-based: в момент, когда фреймворк планирует отрисовку 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(), реализация сигналов опрашивает зависимости выше d через consumerPollProducersForChange, чтобы определить, нужно ли перевычисление.

Для эффективной обработки все реактивные узлы запоминают версию узла-зависимости. Чтобы определить изменение, достаточно просто сравнить сохранённую версию узла-производителя с актуальной версией на этом узле:

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;
}

Если они различаются, значит производитель изменился, и реализация запустит перевычисление колбэка computed через producerRecomputeValue:

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. Таким образом, проблемы двойного вычисления для 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 watcher = createWatch(
  () => console.log(d()),
  () => setTimeout(watcher.run, 1000),
  false
);

watcher.notify();

Как только мы обновляем значение через a.set(1), мы видим уведомление живых потребителей в действии:

отладчик показывает обход producerNotifyConsumers по узлам живых потребителей

Узлы b и c — живые потребители узла a, поэтому при выполнении обновления для a Angular пройдёт по node.liveConsumerNode и уведомит эти узлы об изменении.

Но, как упоминалось ранее, на самом деле здесь ничего не происходит. Узел просто помечается как «грязный» (dirty) и распространяет уведомление своим живым потребителям через producerNotifyConsumers:

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

Всё это доходит вниз до наблюдателя (эффекта), который зависит от d. В отличие от обычных реактивных узлов, узел 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»: «грязность» жадно выталкивается по графу при изменении исходного сигнала, но перевычисление выполняется лениво, только когда значения вытягивают чтением их сигналов.

Change detection

Чтобы встроить уведомления на основе сигналов в процесс change detection, Angular опирается на механизм живых потребителей. Шаблоны компонентов компилируются в шаблонные выражения (JS-код) и выполняются в реактивном контексте представления этого компонента. В таком контексте выполнение сигнала вернёт значение, но также зарегистрирует этот сигнал как зависимость представления компонента.

Поскольку шаблонные выражения — живые потребители, Angular создаст связь от производителя к узлу шаблонного выражения. Как только значение производителя обновится, этот производитель немедленно и синхронно уведомит узел шаблона. Получив уведомление, Angular помечает компонент и всех его предков на проверку.

Как вы, возможно, уже знаете из моих других статей, шаблон каждого компонента внутри представлен объектом LView. Вот как это выглядит для компонента:

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

после компиляции это выглядит как обычная JS-функция AppComponent_Template, которая выполняется во время change detection для этого компонента:

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

Когда Angular добавил сигналы в свою реализацию change detection, он обернул все представления компонентов (функции шаблонов) в узел 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, который задаёт реактивный контекст для всех сигналов, к которым обращаются внутри функции шаблона.

В нашем случае всякий раз, когда функция шаблона выполняется как часть change detection, она выполнит производителя ctx.value() в контексте узла функции шаблона, который является ActiveConsumer:

функция шаблона выполняет сигнал в реактивном контексте

Это приведёт к тому, что узел шаблонного выражения (потребитель) будет добавлен как живая зависимость производителя value():

связь живого потребителя от производителя к узлу шаблона

Эта зависимость гарантирует, что как только значение производителя counter изменится, он немедленно уведомит узел-потребитель (шаблонное выражение).

Живые потребители реализуют метод 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, которая вам, возможно, уже знакома из моих предыдущих статей.

Эта функция запускает change detection для каждого LView и выполняет обычные операции change detection: выполняет функцию шаблона, выполняет хуки, обновляет 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, к моменту выполнения функций-аксессоров сигналов, использованных в шаблоне компонента, реактивный контекст уже настроен.

Эффекты и наблюдатели

Эффект — это специализированный инструмент, предназначенный для выполнения операций с побочными эффектами на основе состояния приложения. Эффекты — это живые потребители, задаваемые колбэком, который выполняется в реактивном контексте. Зависимости-сигналы этой функции захватываются, и эффект уведомляется всякий раз, когда любая из его зависимостей выдаёт новое значение.

В большинстве прикладного кода эффекты нужны редко, но могут быть полезны в конкретных обстоятельствах. Вот несколько примеров использования, предлагаемых в документации Angular:

  • Логирование данных или поддержание их синхронизации с window.localStorage
  • Добавление собственного поведения DOM, которое нельзя выразить синтаксисом шаблонов, например собственная отрисовка в элемент <canvas>

Angular не использует эффекты в механизме change detection для запуска обновления UI компонента. Как объяснялось в разделе про change detection, для этой функциональности он опирается на механизм живых потребителей.

Хотя алгоритм сигналов стандартизирован, детали того, как должны вести себя эффекты, не определены и будут различаться между фреймворками. Причина — в тонкой природе планирования эффектов, которое часто интегрируется с циклами отрисовки фреймворка и другими высокоуровневыми, специфичными для фреймворка состояниями или стратегиями, недоступными JavaScript.

Тем не менее proposal по сигналам определяет набор примитивов, а именно API watch, которым авторы фреймворков могут пользоваться для создания собственных эффектов. Интерфейс Watcher используется, чтобы наблюдать за реактивной функцией и получать уведомления, когда зависимости этой функции меняются.

В Angular effect — это обёртка над watcher. Сначала разберёмся, как работают наблюдатели, а потом увидим, как на их основе строится примитив effect.

Сначала импортируем watcher из примитивов Angular и используем его для реализации механизма уведомлений:

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, — ZoneAwareEffectScheduler, который выполняет обновления как часть очереди микрозадач после цикла change detection:

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;
        });
      }
    }

Есть одна интересная особенность, которую Angular вынужден реализовать, чтобы «инициализировать» эффект. Как мы видели в реализации с наблюдателем, отслеживание нужно запустить, вручную вызвав watcher.notify() один раз. Angular тоже должен это сделать и делает это в рамках первого прохода change detection.

Вот как это реализовано.

Когда вы выполняете функцию 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;
}

Функции уведомления, добавленные таким образом, будут выполнены один раз во время первого прохода change detection для представления этого компонента внутри функции 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 лежащего в основе наблюдателя, который, в свою очередь, запланирует запуск эффекта (переданного пользователем колбэка) с помощью существующего планировщика (после change detection):

инициализация эффекта через notifyEffect в refreshView

Вот и вся история.