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

Для многих моих коллег сигналы стали чем-то мистическим. С одной стороны, ряд задач теперь действительно получается решать намного элегантнее, с другой стороны, возникают вопросы по поводу внутренних механизмов их работы. В частности, загадочно выглядели computed сигналы, в которых в отличие от хуков React не прописываются зависимости напрямую. Также возникали вопросы связанные с детекцией изменений. В отличие от Observable и async pipe с явным вызовом ChangeDetectorRef, сигналы могут сами по себе использоваться в шаблоне, уведомляя Angular об изменениях.

В этой статье мы погрузимся в исходный код сигналов, разберем детально их работу и постараемся дать ответ на следующие вопросы:

  1. Как устроены сигналы.

  2. Механизм работы computed сигналов.

  3. Как связаны сигналы и механизм детекции изменений.

Сигналы под капотом

Исходники можно найти на гитхабе Angular. Там же более подробно можно ознакомиться со всеми принципами работы сигналов. Здесь же мы разберем отдельные аспекты, которые понадобятся для нашей цели – понимания связи сигналов и детекции изменений.

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

Как создается обертка
const signalFn = createSignal(initialValue) as SignalGetter<T> & WritableSignal<T>;
const node = signalFn[SIGNAL];

if (options?.equal) {
  node.equal = options.equal;
}

signalFn.set = (newValue: T) => signalSetFn(node, newValue);
signalFn.update = (updateFn: (value: T) => T) => signalUpdateFn(node, updateFn);
signalFn.asReadonly = signalAsReadonlyFn.bind(signalFn as any) as () => Signal<T>;

Передача данных здесь реализована посредством графа. Сигнал выступает в качестве узла в этом графе, реализуя интерфейс ReactiveNode. Каждый сигнал может быть Producer и/или Consumer (далее – производитель и потребитель). Производитель является источником данных для потребителя. Связь реализована посредством хранения как прямых ссылок на объекты ReactiveNode, так и хранением перекрестных индексов: потребитель хранит индекс, по которому он записан у производителя, так и возможна и обратная ситуация. Почему обратное не всегда верно - рассмотрим дальше.

export interface ReactiveNode {
  version: Version;
  lastCleanEpoch: Version;
  dirty: boolean;

  producerNode: ReactiveNode[] | undefined;
  producerLastReadVersion: Version[] | undefined;
  producerIndexOfThis: number[] | undefined;

  liveConsumerNode: ReactiveNode[] | undefined;


  // Отдельно хочется отметить эти методы, которые используются 
  // в качестве хуков жизненного цикла самих сигналов
  producerMustRecompute(node: unknown): boolean;
  producerRecomputeValue(node: unknown): void;
  consumerMarkedDirty(node: unknown): void;

}

Важным понятием в этой системе является свойство "жизни" сигнала (isLive). Сигнал считается "живым" в двух случаях: если у него есть хоть один потребитель или же у него был выставлен флаг consumerIsAlwaysLive (данный флаг проставляется, например, для сигналов типа watch - effect). Данное свойство гарантирует отсутствие утечек памяти - производитель не хранит у себя ссылки на потребителей, если !isLive(consumer), что позволит GC удалить ненужные объекты из памяти.

Для создания базовых сигналов используется функция createSignal(), которая является аналогом функции signal из @angular/core. Она возвращает функцию, у которой есть поле вида readonly [SIGNAL]: unknown, где SIGNAL = Symbol('SIGNAL'). Это поле используется для хранения узла ReactiveNode. Вызов полученной функции возвращает текущее значение сигнала.

Процесс обновления сигналов

Для того чтобы обновить значение в уже созданном сигнале существуют функции signalSetFn и signalUpdateFn, аналогами которых являются привычные методы сигналов .set() и .update().

Процесс изменения сигнала можно разбить на два этапа:

  1. Уведомление об изменении

  2. Вычисление новых значений

При вызове одной из функций обновления значений, запускается первый этап. Узел устанавливает новое значение, инкрементируется его версия. Именно она применяется потребителями для того, чтобы сократить перерасчет значений, если она при последнем обращении потребителя не изменилась. Затем, узел уведомляет об изменениях каждого своего потребителя, помечая его как dirty = true. После уведомления всех потребителей по цепочке, этот этап завершается.

Эпохи

Помимо обновления версии в самом сигнале, инкрементируется глобальный счетчик эпох (epoch), который позволяет оптимизировать процесс отслеживания изменений – нет необходимости пересчитывать значения, если данный сигнал уже пересчитывал свое значение в текущую эпоху. Это может быть полезно, например, в таком графе зависимостей: S1 -> C1, C1 -> C2, C1 -> C3, {C2, C3} => C4. При получении значения С4, в нем C1 будет рассчитан только 1 для С2, для C3 вернется уже посчитанная версия, поскольку все вычисления происходили в рамках одной эпохи.

Эпоха, в которую был произведен последний пересчет, хранится в поле lastCleanEpoch

Второй этап обновления значений запускается лениво, после того как было запрошено значение одного из узлов-потребителей. Алгоритм расчета разберем на примере работы computed сигналов.

Алгоритм работы computed signals

Давайте рассмотрим небольшой простой пример, на котором будет понятен как алгоритм работы данного типа сигналов, так и в целом механизм передачи изменений по графу.

const numberSignal = createSignal(0);
const double = createComputed(() => numberSignal() * 2);
const doubledValue = double();

Мы вызвали созданный computed сигнал. После чего идет проверка условий, при которых не требуется пересчитывать текущий узел (например, когда узел !isDirty и lastCleanEpoch === epoch). Если же эти условия не были выполнены, то начинается пересчет значений для всех производителей. Выглядит он так:

  • Сигнал double записывается в глобальную для всех ReactiveNode переменную activeNode, объявленную на уровне модуля graph.ts. Для этого используется публичная функция consumerBeforeComputation. Это является начальной точкой построения графа.

  • После чего вызывается callback, указанный при создании computed сигнала. В момент исполнения сигнала в этом callback (в примере - вызов numberSignal()) – в массив activeNode.producerNode добавляется этот сигнал. Если isLive(activeNode), то в него добавляется activeNode как потребитель.

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

После того как все значения были получены, значение activeNode задается null, сигнал double помечается «чистым».

Код вычисления значения computed сигнала
    producerRecomputeValue(node: ComputedNode<unknown>): void {

      if (node.value === COMPUTING) {
        throw new Error('Detected cycle in computations.');
      }

      const oldValue = node.value;
      node.value = COMPUTING;

	  // Установили текущий узел как activeNode
      const prevConsumer = consumerBeforeComputation(node);
      let newValue: unknown;
      try {
        newValue = node.computation(); // Произвели вычисления всего графа.
      } catch (err) {
        newValue = ERRORED;
        node.error = err;
      } finally {
        consumerAfterComputation(node, prevConsumer);
      }

      if (
        oldValue !== UNSET &&
        oldValue !== ERRORED &&
        newValue !== ERRORED &&
        node.equal(oldValue, newValue)
      ) {

        // Изменений не произошло 
        node.value = oldValue;
        return;
      }

      node.value = newValue;
      node.version++;

    },

Упрощенно и схематично порядок исполнения можно представить так:

  1. Вызвали расчет значения computed сигнала double()

  2. double.isDirty = true

  3. activeNode = double

  4. Вызов callback для double

  5. numberSignal()

  6. activeNode.producers.push(numberSignal)

  7. Получено последнее значение numberSignal

  8. Завершение подсчета, поскольку у numberSignal нет производителей

  9. activeNode = null

  10. double.isDirty = false

При первом вызове computed сигнала происходит построение графа зависимостей. Узлы сохраняют ссылки друг на друга, перекрестные индексы, а также версии последних считанных значений. Граф строится через специальную глобальную переменную activeNode, в которой сохраняется контекст работы последнего активного узла, что позволяет выстраивать граф от нижних (дочерних) узлов к родительским.

Сигналы в системе ChangeDetection в Angular

Когда команда Angular презентовала сигналы, одной из приятных особенностей сигналов выделялась возможность их использования без async pipe, который внутри себя напрямую обращался в ChangeDetector при каждом изменении значения. Как уже было показано, сам примитив существует как отдельная библиотека без привязки к самому фреймворку. Поэтому давайте разберемся, как все же сигналы встроены в систему детекции изменений.

Прежде всего, необходимо упомянуть интерфейc LView. Ознакомиться более подробно с ним можно в исходниках. Нам важно знать о нем следующее – объекты, реализующие этот интерфейс, содержат информацию о шаблоне компонента, например, информацию о запросах к шаблону (ViewChild, ContentChild и прочее), а также флаги о необходимости обновлении шаблона. Они же используются при определении необходимости перерисовки компонента.

const enum LViewFlags {
  ...
  Dirty = 1 << 6,
  Attached = 1 << 7,
  Destroyed = 1 << 8,
  IsRoot = 1 << 9,
  ...
}

Помимо всего прочего, в этом интерфейсе есть поле REACTIVE_TEMPLATE_CONSUMER, в которое записывается специальный сигнал (ReactiveLViewConsumer), где устанавливается activeNode перед выполнением темплейта. Приведу небольшой отрывок из функции refreshView

function refreshView() {
... 
  if (viewShouldHaveReactiveConsumer(tView)) {
      currentConsumer = getOrBorrowReactiveLViewConsumer(lView);
      // Устанавливаем activeNode здесь
      prevConsumer = consumerBeforeComputation(currentConsumer);
  } else if (getActiveConsumer() === null) {
      returnConsumerToPool = false;
      currentConsumer = getOrCreateTemporaryConsumer(lView);
      // И здесь
      prevConsumer = consumerBeforeComputation(currentConsumer);
  }
...
  executeTemplate(tView, lView, templateFn, RenderFlags.Update, context);

}

Поскольку сигнал устанавливается перед исполнением шаблона (до вызова функции executeTemplate), то каждый сигнал из шаблона будет выполняться в его контексте, то есть запишет его как своего потребителя (поскольку он создается consumerIsAlwaysLive). Это значит, что при изменении любого сигнала из шаблона (если изменилось его значение или значение какого-либо из его производителей),REACTIVE_TEMPLATE_CONSUMER будет тоже помечен как isDirty, что, в свою очередь, заставит сработать специальный хук consumerMarkedDirty. В этом хуке компонент помечается специальным флагом LViewFlags.RefreshView, который говорит о необходимости обновить шаблон этого компонента.

const TEMPORARY_CONSUMER_NODE = {
  ...REACTIVE_NODE,

  consumerIsAlwaysLive: true,

  consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
    let parent = getLViewParent(node.lView!);
    while (parent && !viewShouldHaveReactiveConsumer(parent[TVIEW])) {
      parent = getLViewParent(parent);
    }
    if (!parent) {
      return;
    }
    markViewForRefresh(parent);
  },
};

Именно таким образом при изменении любого сигнала в шаблоне компонент при следующем цикле детекции изменений будет отрисован заново.

Заключение

Сигналы уже довольно глубоко внедрены в исходники самого фреймворка. Мы видим, что алгоритмы детекции изменений уже напрямую завязаны на механизмы обновления самих сигналов. Дальнейшее развитие этой концепции в конечном итоге приведет нас к тому, что для отображения изменений не потребуется проходить по всему дереву компонентов, а также к полному отказу от zone.js в этом процессе. Ждем еще большую интеграцию сигналов в сам Angular.

Надеюсь, после прочтения данной статьи вы смогли более подробно разобраться в работе computed сигналов, а также в связанных с ними алгоритмах детекции изменений!

Комментарии (1)


  1. Zdomb
    01.08.2024 20:32

    "Ничего не понятно, но очень интересно"