Angular Signals Implementation

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

В данной статье мы будем опираться непосредственно на кодовую базу Angular — https://github.com/angular/angular/tree/main.

Вкратце о сигналах

Сигналы в Angular — это функции без аргументов (() => T). При выполнении они возвращают текущее значение сигнала.

    /**
     * Symbol used to tell `Signal`s apart from other functions.
     *
     * This can be used to auto-unwrap signals in various cases, or to auto-wrap non-signal values.
     */
    export const SIGNAL = /* @__PURE__ */ Symbol('SIGNAL');

    /**
     * A reactive value which notifies consumers of any changes.
     *
     * Signals are functions which returns their current value. To access the current value of a signal,
     * call it.
     *
     * Ordinary values can be turned into `Signal`s with the `signal` function.
     */
    export type Signal = (() => T) & {
        [SIGNAL]: unknown;
    };

Выполнение сигналов происходит без сайд эффектов, хотя может лениво пересчитывать промежуточные значения (ленивая мемоизация). Определенные контексты (например, выражения шаблонов) могут быть реактивными. В таких контекстах выполнение сигнала вернет значение, а также зарегистрирует сигнал как зависимость рассматриваемого контекста. Владелец контекста будет уведомлен, если какая-либо из его зависимостей сигнала создаст новое значение. Этот механизм контекста и функции получения позволяет автоматически и неявно отслеживать зависимости сигнала контекста. Пользователям не нужно объявлять массивы зависимостей, и набор зависимостей конкретного контекста не должен оставаться статичным между выполнениями.

С места в карьер, к коду!

Перейдём непосредственно к реализации сигналов. Чтобы углубиться и вникнуть в суть, будем рассматривать алгоритм сигналов шаг за шагом.

    /**
     * Create a `Signal` that can be set or updated directly.
     */
    export function signal(initialValue: T, options?: CreateSignalOptions): WritableSignal {
        performanceMarkFeature('NgSignals');
        const signalFn = createSignal(initialValue) as SignalGetter & WritableSignal;
        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;
        if (ngDevMode) {
            signalFn.toString = () => `[Signal: ${signalFn()}]`;
        }
        return signalFn as WritableSignal;
    }

Определяя сигнал, конечно, есть возможность указать тип хранимого значения. Также есть возможность указания начального значения initialValue и дополнительных необязательных параметров options, содержащих (в данный момент) пока единственный параметр equal. equal используется для определения того, является ли новое предоставленное значение таким же по сравнению со значением текущего сигнала.
Если функция равенства определяет, что 2 значения равны, она:

  1. блокирует обновление значения сигнала;

  2. пропускает распространение изменений.

    /**
     * Options passed to the `signal` creation function.
     */
    export interface CreateSignalOptions {
        /**
         * A comparison function which defines equality for signal values.
         */
        equal?: ValueEqualityFn;
    }

    /**
     * A comparison function which can determine if two values are equal.
     */
    export type ValueEqualityFn = (a: T, b: T) => boolean;

Возвращаемое значение — WritableSignal — наследует тип Signal, возвращая значение сигнала при вызове, а также добавляет ряд новых методов — set, update, asReadonly:

    /** Symbol used distinguish `WritableSignal` from other non-writable signals and functions. */
    export const ɵWRITABLE_SIGNAL = /* @__PURE__ */ Symbol('WRITABLE_SIGNAL');

    /**
     * A `Signal` with a value that can be mutated via a setter interface.
     */
    export interface WritableSignal extends Signal {
        [ɵWRITABLE_SIGNAL]: T;

        /**
         * Directly set the signal to a new value, and notify any dependents.
         */
        set(value: T): void;

        /**
         * Update the value of the signal based on its current value, and
         * notify any dependents.
         */
        update(updateFn: (value: T) => T): void;

        /**
         * Returns a readonly version of this signal. Readonly signals can be accessed to read their value
         * but can't be changed using set or update methods. The readonly signals do _not_ have
         * any built-in mechanism that would prevent deep-mutation of their value.
         */
        asReadonly(): Signal;
    }

Первым (вспомогательным) шагом в реализации сигналов является функция performanceMarkFeature, принимающая на вход строку. Данная функция — обёртка над методом mark из Performance Web API — позволяет создавать временную метку для отслеживания производительности функции создания сигналов.

    const markedFeatures = new Set();

    // tslint:disable:ban
    /**
     * A guarded `performance.mark` for feature marking.
     *
     * This method exists because while all supported browser and node.js version supported by Angular
     * support performance.mark API. This is not the case for other environments such as JSDOM and
     * Cloudflare workers.
     */
    export function performanceMarkFeature(feature: string): void {
        if (markedFeatures.has(feature)) {
            return;
        }
        markedFeatures.add(feature);
        performance?.mark?.('mark_feature_usage', {detail: {feature}});
    }

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

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

   export interface SignalNode extends ReactiveNode {
        value: T;
        equal: ValueEqualityFn;
    }

    export type SignalBaseGetter = (() => T) & {readonly [SIGNAL]: unknown};

    // Note: Closure *requires* this to be an `interface` and not a type, which is why the
    // `SignalBaseGetter` type exists to provide the correct shape.
    export interface SignalGetter extends SignalBaseGetter {
        readonly [SIGNAL]: SignalNode;
    }

    /**
     * The default equality function used for `signal` and `computed`, which uses referential equality.
     */
    export function defaultEquals(a: T, b: T) {
        return Object.is(a, b);
    }

    // Note: Using an IIFE here to ensure that the spread assignment is not considered
    // a side-effect, ending up preserving `COMPUTED_NODE` and `REACTIVE_NODE`.
    // TODO: remove when https://github.com/evanw/esbuild/issues/3392 is resolved.
    export const SIGNAL_NODE: SignalNode = /* @__PURE__ */ (() => {
        return {
            ...REACTIVE_NODE,
            equal: defaultEquals,
            value: undefined,
        };
    })();

    /**
     * Create a `Signal` that can be set or updated directly.
     */
    export function createSignal(initialValue: T): SignalGetter {
        const node: SignalNode = Object.create(SIGNAL_NODE);
        node.value = initialValue;
        const getter = (() => {
            producerAccessed(node);
            return node.value;
        }) as SignalGetter;
        (getter as any)[SIGNAL] = node;
        return getter;
    }

Что здесь происходит?

Во-первых, определяются сразу несколько интерфейсов:

  1. SignalNode наследуется от ReactiveNode (рассмотрим ниже) и имеет 2 обязательных поля — value и equal, мы рассмотрели их выше;

  2. SignalGetter наследуется от SignalBaseGetter, который является аналогом типа Signal за исключением того, что является readonly, а также имеет readonly поле [SIGNAL] типа SignalNode.

Во-вторых, определяется объект SIGNAL_NODE типа SignalNode, в котором устанавливаются дефолтные значения value в undefined и equal в функцию defaultEquals, которая сравнивает значения сигналов через Object.is, а также содержит значения по умолчанию объекта REACTIVE_NODE (также рассмотрим ниже).

В-третьих, на базе уже созданного SIGNAL_NODE через Object.create создаётся новый объект node, который по сути и будет являться самим сигналом (или его инстансом). Мы присваиваем ему наше начальное значение сигнала initialValue и создаём функцию getter, в которой происходит вызов функции producerAccessed(node) и возвращается значение сигнала. Далее в поле [SIGNAL] функции getter'a присваиваем сам инстанс сигнала node (да-да, JS и так умеет, если кто-то сомневался) и возвращаем getter.

Алгоритм работы сигналов (Producer and Consumer)

Producer’ы и Consumer’ы

Внутренне реализация сигналов определяется в терминах двух абстракций, producer’ов и consumer’ов. Producer’ы представляют значения, которые могут доставлять уведомления об изменениях, такие как различные разновидности сигналов. Consumer’ы представляют реактивный контекст, который может зависеть от некоторого количества producer’ов. И producer’ы, и consumer’ы определяют объект node, который реализует интерфейс ReactiveNode и моделирует участие в реактивном графе. Любой ReactiveNode может выступать в роли producer’а, consumer’а или в роли их обоих одновременно, взаимодействуя с соответствующим подмножеством API. Например, WritableSignals реализуют ReactiveNode, но работают только с API producer’а, поскольку WritableSignals не потребляют другие значения сигналов, а computed, в свою очередь, потребляют другие сигналы для создания новых реактивных значений.

Граф зависимостей

ReactiveNode'ы связаны между собой графом зависимостей. Этот граф зависимостей является двунаправленным, но существуют различия в том, как зависимости отслеживаются в каждом направлении.

Consumer’ы всегда отслеживают producer’ов, от которых они зависят. Producer’ы отслеживают только зависимости от consumer’ов, которые считаются «живыми». Consumer является «живым», когда:

  1. Он устанавливает свойство consumerIsAlwaysLive своего ReactiveNode в значение true;

  2. Он также является producer’ом, от которого зависит «живой» consumer.

Взаимосвязь между Producer'ом и Consumer'ом

Взаимосвязь между Producer’ом и Consumer’ом

В этом смысле «живость» является транзитивным свойством consumer’ов. Все effect’ы являются «живыми» consumer’ами по умолчанию, а computed нет. Однако если любое значение computed будет располагаться внутри effect’а, computed будет рассматриваться как «живой» consumer.

Жизнеспособность и управление памятью

Концепция жизнеспособности позволяет отслеживать зависимости от producer’а к consumer’у без риска утечек памяти, например:

    const counter = signal(1);
    let double = computed(() => counter() * 2);
    console.log(double()); // 2
    double = null;

Если бы граф зависимостей имел жёсткую ссылку от counter к double, то double сохранялся бы даже если бы пользователь удалил ссылку на исходный сигнал counter. Но поскольку double не является «живым» consumer’ом, граф не содержит ссылку от counter к double, и double может быть освобожден сборщиком мусора, когда пользователь удаляет его.

Взаимосвязь между counter и double

Взаимосвязь между counter и double

«Неживые» consumer’ы и polling

Следствием отсутствия отслеживания от counter к double является то, что при изменении счетчика:

    counter.set(2);

Никакое уведомление не может распространяться в графе от counter к double, чтобы сообщить computed, что ему нужно отбросить свое запомненное значение (2) и пересчитать (4). Вместо этого, когда double () считывается, он опрашивает своих producer’ов (которые отслеживаются в графе) и проверяет, сообщили ли какие-либо из них об изменении с момента последнего вычисления double. Если нет, double может безопасно использовать свое запомненное значение.

С «живым» consumer’ом всё иначе. Если effect создан:

    effect(() => console.log(double()));

Тогда double становится «живым» consumer’ом, так как это зависимость «живого» producer’а (effect’а), и граф будет иметь жёсткие ссылки counter→double→effect. Однако нет риска утечек памяти, так как effect в любом случае напрямую ссылается на double, и эффект не может быть просто удален и должен быть вручную уничтожен (что приведет к тому, что double больше не будет «живым» consumer’ом). То есть, нет способа для ссылки от producer’а на «живого» consumer’а существовать в графе без того, чтобы consumer также ссылался на producer’а за пределами графа.

Алгоритм в действии

Вернёмся к коду определения сигнала и проследим путь создания сигнала до конца. Из метода createSignal мы получили функцию, возвращающую значение сигнала и содержащее в себе его инстанс.

Далее мы получаем сам инстанс из функции, и при наличии функции сравнения options?.equal, заменяем её внутри самого сигнала, переопределяя defaultEquals.

    ...
    const node = signalFn[SIGNAL];
    if (options?.equal) {
        node.equal = options.equal;
    }
    ...

Затем задаём необходимые в WritableSignal методы set, update и asReadonly, и в dev-режиме переопределим метод toString, который будет возвращать строку со значением сигнала. На выходе получается тот самый сигнал, а точнее функция signalFn со всеми необходимыми методами, содержащая в себе метод сравнения значений сигнала и при вызове возвращающая само значение.

    ...
    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;
    if (ngDevMode) {
        signalFn.toString = () => `[Signal: ${signalFn()}]`;
    }
    return signalFn as WritableSignal;

Теперь, чтобы понять, как работает алгоритм изнутри, нужно рассмотреть реализацию графа:

    type Version = number & {__brand: 'Version'};

    export interface ReactiveNode {
        version: Version;
        lastCleanEpoch: Version;
        dirty: boolean;
        producerNode: ReactiveNode[] | undefined;
        producerLastReadVersion: Version[] | undefined;
        producerIndexOfThis: number[] | undefined;
        nextProducerIndex: number;
        liveConsumerNode: ReactiveNode[] | undefined;
        liveConsumerIndexOfThis: number[] | undefined;
        consumerAllowSignalWrites: boolean;
        readonly consumerIsAlwaysLive: boolean;
        producerMustRecompute(node: unknown): boolean;
        producerRecomputeValue(node: unknown): void;
        consumerMarkedDirty(node: unknown): void;
        consumerOnSignalRead(node: unknown): void;
    }

    export const REACTIVE_NODE: ReactiveNode = {
        version: 0 as Version,
        lastCleanEpoch: 0 as Version,
        dirty: false,
        producerNode: undefined,
        producerLastReadVersion: undefined,
        producerIndexOfThis: undefined,
        nextProducerIndex: 0,
        liveConsumerNode: undefined,
        liveConsumerIndexOfThis: undefined,
        consumerAllowSignalWrites: false,
        consumerIsAlwaysLive: false,
        producerMustRecompute: () => false,
        producerRecomputeValue: () => {},
        consumerMarkedDirty: () => {},
        consumerOnSignalRead: () => {},
    };

Да-да, это именно то, что в добавок к публичным методам (set, update, asReadonly) содержит инстанс сигнала node.
Теперь попробуем посмотреть, что происходит, когда мы делаем установку значения сигнала через set, а именно что происходит внутри signalSetFn:

    export function signalSetFn(node: SignalNode, newValue: T) {
        if (!producerUpdatesAllowed()) {
            throwInvalidWriteToSignalError();
        }

        if (!node.equal(node.value, newValue)) {
            node.value = newValue;
            signalValueChanged(node);
        }
    }

    /**
     * Whether this `ReactiveNode` in its producer capacity is currently allowed to initiate updates,
     * based on the current consumer context.
     */
    export function producerUpdatesAllowed(): boolean {
        return activeConsumer?.consumerAllowSignalWrites !== false;
    }

Прежде чем функция signalSetFn установит новое значение сигнала, если оно отличается от текущего, и запустит процесс распространения изменений, функция producerUpdatesAllowed проверяет, разрешено ли в настоящее время этому ReactiveNode (текущему consumer’у) в качестве producer’а инициировать обновления на основе текущего контекста consumer’а. Если нет — выбросит соответствующее исключение.

    function signalValueChanged(node: SignalNode): void {
        node.version++;
        producerIncrementEpoch();
        producerNotifyConsumers(node);
        postSignalSetFn?.();
    }

    /**
     * Increment the global epoch counter.
     *
     * Called by source producers (that is, not computeds) whenever their values change.
     */
    export function producerIncrementEpoch(): void {
        epoch++;
    }

    /**
    * Propagate a dirty notification to live consumers of this producer.
    */
    export function producerNotifyConsumers(node: ReactiveNode): void {
        if (node.liveConsumerNode === undefined) {
            return;
        }

        // Prevent signal reads when we're updating the graph
        const prev = inNotificationPhase;
        inNotificationPhase = true;
        try {
            for (const consumer of node.liveConsumerNode) {
                if (!consumer.dirty) {
                    consumerMarkDirty(consumer);
                }
            }
        } finally {
            inNotificationPhase = prev;
        }
    }

    export function consumerMarkDirty(node: ReactiveNode): void {
        node.dirty = true;
        producerNotifyConsumers(node);
        node.consumerMarkedDirty?.(node);
    }

В противном же случае запустится механизм распространения изменений значения сигнала:

  1. producerIncrementEpoch увеличит глобальный счётчик epoch — каждый раз, когда источник данных будет изменяться, счётчик обновится;

  2. producerNotifyConsumers уведомит всех «живых» consumer’ов о том, что их источник данных изменился. Если у сигнала нет «живых» consumer’ов (liveConsumerNode), функция завершает выполнение. Иначе для каждого consumer’а проверяется, находится ли он в «грязном» состоянии (dirty);

  3. И если нет, вызывается функция consumerMarkDirty. Эта функция помечает consumer’а как «грязного», что означает, что его данные устарели и требуется обновление. После этого снова вызывается producerNotifyConsumers для дальнейшего уведомления consumer’ов.

    Флаг inNotificationPhase позволяет предотвратить повторное чтение сигнала в момент уведомления consumer’ов, после чего восстанавливает предыдущее состояние (prev).

Подведём итоги или Push/Pull Алгоритм

Angular Signals гарантирует выполнение без сбоев, разделяя обновления графа ReactiveNode на две фазы.

Первая фаза выполняется немедленно, когда значение producer’а изменяется. Это уведомление об изменении передаётся по графу, уведомляя «живых» consumer’ов, которые зависят от этого producer’а, о потенциальном обновлении. Некоторые из этих consumer’ов могут быть производными значениями и, таким образом, также являться producer’ами. Они инвалидируют свои кешированные значения и продолжают распространение уведомления об изменении к своим собственным «живым» consumer’ам, и так далее. В конечном итоге уведомление доходит до эффектов, которые планируют своё повторное выполнение.

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

После того как это распространение изменений завершено (синхронно), может начаться вторая фаза. Тут значения сигналов могут быть прочитаны приложением или фреймворком, что запускает пересчёт всех необходимых производных значений, которые были ранее инвалидированы. Этот процесс называется алгоритмом «push/pull»: «грязность» (некорректность данных) активно передаётся по графу, когда изменяется исходный сигнал, но пересчёт выполняется лениво — только тогда, когда значения запрашиваются путём чтения их сигналов. Подробнее о работе алгоритма можно прочитать тут.

Рассмотренная реализация сигналов в Angular показала, что концепция реактивных значений, хотя и сложна на первый взгляд, вполне доступна для понимания. Мы детально разобрали ключевые функции и механизмы, которые управляют изменением состояния сигналов, отслеживанием зависимостей и уведомлением потребителей. Одним из преимуществ такой реализации является автоматическое управление реактивностью без явного использования подписок и сторонних библиотек.

© Habrahabr.ru