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

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

Как достигается рост производительности?

Благодаря сигналам Angular сможет использовать все преимущества нового реактивного способа для обнаружения изменений. Чтобы лучше понять эффективность Angular Signals в рамках синхронизации DOM и компонентной модели, стоит сравнить текущий механизм обнаружения изменений и новый, на основе сигналов. Для начала введём ряд условных обозначений:

Рассмотрим приложение, состоящее из нескольких компонентов. Некоторые из них имеют модели, которые логически зависят друг от друга, некоторые нет. Все их представления связаны отношениями родитель-потомок в дереве DOM.

Как Angular обнаруживает изменения сейчас

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

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

  1. Angular начинает с вершины дерева и проверяет корневой узел.

  2. Затем он продолжает обход дерева, чтобы определить, какие компоненты необходимо обновить.

  3. Наконец, он достигает компонента, в котором модель изменилась.

  4. Проверка на равенство завершается неудачно, и DOM обновляется.

Эти шаги выполняются всякий раз, когда инициируется обнаружение изменений.

Обнаружение изменений на основе сигналов

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

Так что же происходит, когда модель меняется? Шаблон уведомляется об этом изменении, и DOM обновляется.

И больше ничего не просходит.

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

Так как же работают сигналы? Producers and Consumers

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

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

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

Другими словами, производители производят реактивность, а потребители ее потребляют.

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

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

Вместе они строят граф зависимостей, в котором явно показано, как различные узлы связаны друг с другом.

Как изменения распространяются через граф зависимостей

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

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

    counter.set(1);

Когда effect будет впервые создан, в консоль выведется «0 is even», как и ожидалось. И как counter, так и evenOrOdd будут зарегистрированы как зависимости эффекта.

Когда же в counter будет установлено значение 1, это заставит Angular пересчитать как функцию evenOrOdd, так и effect. В данном случае важно соблюсти порядок выполнения операций и предотвратить выполнение эффекта быстрее, чем функции evenOrOdd, иначе в консоли можно было увидеть лишний вызов «1 is even», что является неверным поведением реактивности сигналов. Однако такого не происходит, и в консоли мы не увидим данный лог, выведется верный результат «1 is odd» без некорретных промежуточных значений.

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

Push/Pull алгоритм

Angular Signals гарантируют согласованное выполнение операций, разделяя обновления графа Producer/Consumer на две фазы.

Первая фаза начинается, когда значение Producer'а изменяется. Уведомление об изменении распространяется по графу, сообщая потребителям, которые зависят от производителя, о потенциальном обновлении.

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

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

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

Динамическое отслеживание зависимостей

Когда выполняется операция реактивного контекста (например, effect), считываемые ей сигналы отслеживаются как зависимости. Однако это может быть не один и тот же набор сигналов от одного исполнения к другому. Например, вычисленный сигнал:

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

считывает либо dataA, либо dataB в зависимости от значения сигнала useA. В любой момент у него будет набор зависимостей [useA, dataA] или [useA, dataB], и он никогда не сможет одновременно зависеть от dataA и dataB.

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

ValueVersioning и TrackingVersioning

Данная часть является самой сложной в понимании работы сигналов, поэтому ниже алгоритм работы будет разобран детальнее.

Производители отслеживают монотонно увеличивающееся valueVersion, представляющее семантическую идентичность их значения. Значение valueVersion увеличивается, когда источник создаёт семантически новое значение. Текущая версия valueVersion сохраняется в структуре зависимостей сущности, когда потребитель читает данные из источника.

Прежде чем потребители запускают свои реактивные операции, они опрашивают свои зависимости и запрашивают обновление valueVersion, если это необходимо. Для computed это вызовет повторное вычисление значения и последующую проверку на равенство, если значение устарело (что делает этот опрос рекурсивным процессом, поскольку computed также является потребителем, который будет опрашивать своих собственных производителей). Если это повторное вычисление даёт семантически измененное значение, valueVersion увеличивается.

Затем потребитель может сравнить valueVersion нового значения с кэшированным, чтобы определить, действительно ли изменилась эта конкретная зависимость. Делая это для всех производителей, потребитель может определить, что если все значения valueVersion совпадают, то фактического изменения какой-либо зависимости не произошло, и он может пропустить реакцию на это изменение (например, пропустить запуск функции effect).

Предположим, имеется следующий код:

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

    counter.set(1);
    counter.set(2);

Вот что происходит при выполнении операции counter.set(2):

  1. Изменение сигнала counter запускает push/pull алгоритм.

  2. Счётчик Producer сбрасывает кэш и уведомляет своего потребителя isEven о том, что его значение устарело.

  3. isEven также является производителем, что означает, что он также уведомляет своих потребителей (в данном случае effect). Push-фаза заканчивается.

  4. effect теперь запрашивает текущее значение isEven.

  5. isEven снова запрашивает последнюю версию counter.

  6. counter обновил своё значение и valueVersion, уведомив isEven о своем новом состоянии.

  7. isEven пересчитывает собственное значение, определяет, что его значение изменилось, и увеличивает версию своего значения.

  8. Наконец, effect распознаёт новое значение версии. Извлекает новое значение, которое изменилось на true, выполняется с новым значением и регистрирует «even!» в консоли.

А теперь посмотрим, что происходит при выполнении операции counter.set(4):

  1. Опять же, изменение counter в 4 запускает push/pull алгоритм.

  2. Счётчик Producer сбрасывает кэш и уведомляет своего потребителя isEven о том, что его значение устарело.

  3. isEven также является производителем, что означает, что он также уведомляет своих потребителей (в данном случае effect). Push-фаза заканчивается.

  4. effect теперь запрашивает текущее значение isEven.

  5. isEven снова запрашивает последнюю версию counter.

  6. counter обновил своё значение до 4, а его valueVersion, равный 3, уведомляет isEven о своем новом состоянии.

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

  8. Наконец, effect распознаёт, что значение valueVersion сигнала isEven не изменилось. И его не нужно выполнять.

Такой подход с отложенным извлечением значений позволяет соблюдать согласованность выполнения программного кода на Angular и сделать код надёжным для написания и использования.

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

Это означает, что разработчику не придётся беспокоиться об отписке от сигнала. Такой способ отслеживания зависимостей позволит навсегда избавиться от утечек памяти в сигналах в любом Angular приложении.

Итог

Рассмотренный подход к реализации Angular Signals очень удобен. Он помогает оптимизировать часть той логики, которая скрывается в нынешнем механизме обнаружения изменений, непосредственно уведомляя о своём изменении узел DOM. Реализация push/pull алгоритма, в свою очередь, даёт программистам больше гибкости и надёжности в использовании сигналов, что однозначно положительно скажется на качестве программного кода!

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


  1. nin-jin
    31.07.2023 23:10

    Спустя 8 лет, разработчики Ангуляра таки дошли до того, что в $mol было изначально, правда не дожали. Тем не менее, мои поздравления.


    1. radtie
      31.07.2023 23:10

      Вообще сигналы восходят к библиотеке, которая вдохновлялась KnockoutJS, т.ч. спустя 13 лет получается.


      1. nin-jin
        31.07.2023 23:10

        Ага, и проблемы с оптимальностью пересчётов на том же уровне 13-летней давности.