Всем привет! Меня зовут Кулаев Сергей, я — Angular-разработчик в ПСБ. В этой статье я поделюсь с вами внутренним устройством примитива signal (сигнал). В Angular уже достаточно давно появилась возможность обрабатывать изменения данных через этот примитив, и большинству людей он уже знаком, но мало кто понимает, как он устроен под капотом. В ходе статьи мы разберём, что из себя представляет сигнал, в каких библиотеках он встречается, а также напишем свою собственную наивную реализацию сигнала и на её основе детально разберём принцип его работы. Статья будет полезна тем, кто при изучении технологий любит построить свой «велосипед», чтобы разобраться, как это работает на пальцах.

В начале была абстракция

В Angular сначала появилась просто абстракция Signal. Signal по сути своей — обёртка вокруг значения, которая может уведомлять потребителей об изменении этого значения. Потребителем, к примеру, может быть шаблон компонента. Затем на основе этой абстракции стали появляться разные производные:

  1. ComputedSignal — сигнал, значение которого формируется на основе других сигналов;

  2. SignalInput — сигнал, который может изменяться только посредством изменения значений биндинга в шаблоне;

  3. SignalResource — обёртка, которая содержит сигналы с результатом и статусом асинхронных операций.

Сигналы (signals) как примитив управления состоянием стали популярны во фронтенд-фреймворках благодаря простоте и эффективности. Сама концепция сигналов не нова и до появления в Angular уже активно использовалась в других frontend-фреймворках, например, в Knockout (Knockout Observables — аналог Signals). В целом можно сказать, что использование сигналов из разных библиотек очень схоже:

1. SolidJS

   Сигналы —  основной примитив для реактивности.  

     const [count, setCount] = createSignal(0);

     console.log(count()); // чтение

     setCount(5); // обновление

     ```

    

2. Preact (через расширение @preact/signals)

Отдельный пакет для сигналов, интегрируемый в Preact. 

     import { signal } from "@preact/signals";

     const count = signal(0);

     console.log(count.value); // чтение

     count.value = 5; // обновление

     ```

 

3. Qwik

   Использует сигналы для оптимизации реактивности и ленивой загрузки.  

     import { useSignal } from "@builder.io/qwik";

     const count = useSignal(0);

     ```

Signal в Angular

Возможность использовать сигналы в Angular появилась с версии 16. Основная фишка сигнала в том, что изменение сигнала, используемого в шаблоне, вызовет автоматическое обновление шаблона даже при стратегии OnPush. Рассмотрим основные примитивы сигналов в Angular.

Самым первым и простым является WritibleSignal, создаваемый с помощью функции signal().

@Component({
 template: `Значение {{count()}}`,
})
export class SomeComponent  {
 public count = signal(0);
}

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

@Component({
 selector: 'app-root'
 template: `
   {{counter()}}
   {{computedSignal()}}
 `,
})
export class SomeComponent  implements OnInit{
 public counter = signal(0);
 computedSignal = computed(() => this.counter() * 2);
 ngOnInit() {
   setInterval(() => {
     this.counter.set(this.counter() + 1);
   }, 1000)
 }
}

В примере значение сигнала computedSignal будет меняться всякий раз, когда обновится сигнал counter вызовом у него функции set с новым значением, что происходит каждую секунду.

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

@Component({
 selector: 'app-root'
 template: `
   {{counter()}}
 `,
})
export class SomeComponent  implements OnInit{
 public counter = signal(0);
 private loggingEffect = effect(() => {
   console.log(`The count is: ${this.counter()}`);
 });
 ngOnInit() {
   setInterval(() => {
     this.counter.set(this.counter() + 1);
   }, 1000)
 }
}

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

Итак, в целом понятно, что это за зверь такой — этот сигнал. Отличный инструмент для реактивных обновлений, и нет никаких отписок-подписок, как в rxJs. Выглядит круто, но как это реализовано? Давайте проведем эксперимент, воспринимая реализацию сигнала как черный ящик. Мы попробуем создать свою интерпретацию этих реактивных примитивов и посмотрим, что получится.

Создаем свой сигнал

Итак, как нам создать свою реализацию сигнала? Что он из себя представляет? Посмотрим ещё раз на его создание.

const counter = signal(0);

Судя по коду, это функция. Отлично, давайте создадим функцию.

export const customSignal = <T>() {
/// магия
}

Так, функцию мы создали, а что по магии внутри этой функции? Как работает эта абстракция?

Для отображения значения сигнала в шаблоне используется просто вызов counter(). Значит, то, что возвращается из функции конструктора, должно быть функцией. Но тут может возникнуть вопрос: если сигнал — это функция, то как мы можем вызывать у неё функцию set для обновления её значения? Тут достаточно вспомнить основы функций и объектов в js, а именно — что функции по сути своей являются объектами, и следовательно, мы можем у объекта функции задавать и методы. На основе этого несложно предположить, что наш сигнал можно сделать следующим образом:

export const customSignal = <T>(initValue: T) => {
 let value = initValue;
 const innerSignal = function () {
   return value;
 };
 innerSignal.set = function (newValue: T) {
   value = newValue;
 };
 return innerSignal;
};

В нашей реализации функции конструктора сигнала мы создали переменную value – она будет хранить значение сигнала, функцию innerSignal, которая и является самим сигналом.  Вызов этой функции выдаст текущее значение сигнала.  А затем мы добавили функции innerSignal метод set для обновления значения сигнала, и в итоге вернули из конструктора сигнал innerSignal.

Самая интересная часть — это как мы можем сделать свою реализацию вычисляемого сигнала на основе этого сигнала. Итак, что у нас принимает computed? Снова посмотрим на пример использования computed.

const counter = signal();
const computed = computed(() => counter() + 1)

Он принимает функцию, в которой использует какой-то сигнал. Так и хочется написать что-то вроде:

export function customComputed<T>(computeFn: () => T) {
 return customSignal(computeFn());
}

Но тогда вычисляемый сигнал обновится только один раз, и его можно будет перезаписывать — что тоже нам не очень нужно. Значит, надо сделать так, чтобы мы узнавали об изменениях сигналов. Как этого достичь? Логично было бы сделать какое-то место, в котором мы бы узнавали об изменении наших сигналов. Самый простой вариант — сделать некую интерпретацию EventEmmiter.

const signalContext = {
 subscribers: new Set<() => void>(),
 addListener: function (cb: () => void) {
   this.subscribers.add(cb);
 },
 removeListener: function (cb: () => void) {
   this.subscribers.delete(cb);
 },
 emit: function () {
   for (let notifier of this.subscribers) {
     notifier();
   }
 },
};

Созданный нами объект signalContext позволяет нам хранить подписчиков, которые заинтересованы в отслеживании изменений сигналов, в свойстве subscribers. Каждый подписчик по своей сути — функция, которая должна быть выполнена при изменении сигнала. Добавляются подписчики с помощью addListener, а удаляются посредством removeListener. И самое главное — signalContext позволяет уведомлять собранных подписчиков о том, что один из сигналов изменился с помощью метода emit. В emit будет вызвана функция подписчика notifier(), в которой каждый подписчик может по-своему отреагировать на изменение сигнала.

Теперь добавим в наш сигнал оповещение об изменении значения сигнала с помощью вызова signalContext.emit().

export const customSignal = <T>(
   initValue: T
 ) {
   ...
   innerSignal.set = function (newValue: T) {
     value = newValue;
     signalContext.emit(); //*
   };
   ...
 }


Получается, для реализации computed-сигнала нам остается только начать «слушать» эти события. Также важно обеспечить, чтобы computed-сигнал не был записываемым — то есть у него должен отсутствовать метод set.

export function customComputed<T>(computeFn: () => T) {
 let value = computeFn();
 const innerSignal = function () {
   return value;
 };
 const set = function (newValue: T) {
   value = newValue;
 };
 const cbFn = () => {
   set(computeFn());
 };
 signalContext.addListener(cbFn);
 innerSignal.set = function (newValue: T) {
   throw new Error('Not Writable Signal');
 };
 return innerSignal;
}

В теле конструктора вычисляемого сигнала мы также создаем переменную value для хранения текущего значения вычисляемого сигнала. 

И сразу инициализируем её результатом вызова переданной функции инициализации значения сигнала. 

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

После мы создаем приватный колбэк set для обновления значения вычисляемого сигнала. 

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

Затем результат функции записываем в value с помощью внутренней функции set. 

Далее мы регистрируем функцию подписчика cbFn, которая будет вызываться при emit-событиях в signalContext. Именно она поможет нам “слушать” нужные события. 

И в итоге возвращаем сигнал innerSignal, у которого блокируем возможность использования функции set.

Теперь создадим свой эффект. Он будет только лишь подписываться на изменения сигналов, чтобы выполнить переданную ему функцию (и будет намного проще).

export function customEffect(fn: () => void) {
 signalContext.addListener(function () {
   fn();
 });
}

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

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

Один хитрый трюк

В конструкторе Signal мы создадим хранилище Map, в котором каждый сигнал будет хранить только своих подписчиков. А для того, чтобы добавлять подписчиков в хранилище, нам необходимо завести какую-то общую для всех сигналов глобальную переменную, например, ‘watcher’. В эту переменную каждый эффект или вычисляемый сигнал будет класть свою собственную функцию обработки изменения сигналов. По умолчанию эта функция будет равна null. По сути, переменная watcher будет содержать текущий контекст выполнения сигнала, который он обязан уведомить при своем изменении.

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

let watcher = null;

export const customSignal = <T>(
 initValue: T
): [() => T, (value: T) => void] => {
 let value = initValue;
 const watchers = new Map();
 function get(): T {
   if (watcher) {
     const key = Math.random().toString(16);
     watchers.set(key, watcher);
     watcher.deps.push([key, watchers]);
   }
   return value;
 }
 function set(newValue: T) {
   if (value !== newValue) {
     value = newValue;
     for (let watcherItem of watchers.values()) {
       try {
         watcherItem();
       } catch (e) {
         console.log(e);
       }
     }
   }
 }
 return [get, set];
};

Теперь customSignal хранит своих подписчиков в переменной watchers, которых добавляет всякий раз при чтении значения из сигнала в случае, если общая переменная watcher не пуста. Помимо того, что мы сохраняем подписчиков сигнала, каждому подписчику мы передаем уникальный ключ и коллекцию всех подписчиков, чтобы в дальнейшем этот подписчик мог отписаться от текущего сигнала.

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

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

Для этого создадим функцию WritableSignal, в которой превратим кортеж из customSignal в сигнал и в дальнейшем будем создавать изменяемые сигналы именно с помощью customWritableSignal().

export function customWritableSignal<T>(initValue: T) {
  const [get, set] = customSignal<T>(initValue);
  get.set = set;
  return get;
}

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

export function customEffect(fn: () => void): () => void {
 const w = () => fn();
 w.deps = [];
 watcher = w;
 fn();
 watcher = null;
 function stop(): void {
   for (let [key, watchMao] of w.deps) {
     watchMao.delete(key);
   }
   w.deps = [];
 }
 return () => stop();
}

Разберем пошагово, что изменилось. 

Для начала мы создаем функцию w, в которой просто вызываем функцию, переданную в конструктор эффекта. По сути своей функция w — контекст эффекта.

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

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

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

В итоге из эффекта мы возвращаем функцию stop для отписки от эффекта. В функции stop эффект проходится по сигналам, от которых он зависит, и удаляет у них себя из словаря зависимых контекстов.

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

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

export function customComputed(fn) {
 const computedValue = fn();
 const [get, set] = customSignal(computedValue);
 const e = customEffect(() => {
   set(fn());
 });
 return get;
}

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

И вот мы создали свои наивные реализации примитивов сигналов. Ну а теперь самое интересное: что будет, если мы попробуем использовать эти сигналы в Angular-приложении?

Использование собственных сигналов в Angular

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

Для начала узнаем, смогут ли наши сигналы, подобно нативным, обновлять значения в шаблонах. Как это проверить? Достаточно запустить наши сигналы вне зоны Angular с помощью ngZone.runTaskOutsideAngular, куда мы передадим колбэк, где с помощью таймера мы будем изменять сигнал. Таким образом, зона не будет регистрировать выполнение таймера и пересчитывать из-за него шаблон, и если шаблон всё-таки обновится, то это будет заслуга сигнала.

@Component({
   template: `signal value: {{ d() }}`
})
export class AppComponent {
  public d = writableSignal(1);
  ngOnInit() {
   this.zone.runOutsideAngular(() => {
     setInterval(() => {
       (this.d as any).set(this.d() + 1);
       console.log(this.d());
     }, 3000);
   });
 }
}

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

Что надо сделать, чтобы это исправить? По-хорошему, шаблон компонента должен понимать, что у нас изменился сигнал, и запускать обновление шаблона и нижестоящего дерева. Значит, надо запускать cdr.detectChanges каждый раз, как сработал сигнал. Для этого можно использовать эффект. Но что делать, если сигналов у нас несколько? Создавать в каждом компоненте эффект с вызовом cdr.detectChanges и прописывать в них все сигналы из компонента не хочется.

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

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

let globalWatcher = null;
export function setGlobalWatcher(fn) {
 globalWatcher = fn;
}

Теперь нам нужно задать контекст компонента. И сделаем мы это с помощью провайдера, задав токен SIGNAL_COMPONENT_TRIGGER. Так мы сможем, во-первых, получить changeDetector текущего компонента, а во-вторых, задать контекст компонента активным до того, как сигналы будут прочитаны в шаблоне, используя useFactory.  

@Component({
 selector: 'app-root',
 templateUrl: './app.component.html',
 styleUrls: ['./app.component.css'],
 providers: [
   {
     provide: SIGNAL_COMPONENT_TRIGGER,
     useFactory: (cdr: ChangeDetectorRef) => {
       setGlobalWatcher(() => {
         cdr.detectChanges();
       });
     },
     deps: [ChangeDetectorRef],
   },
 ],
})

Как видим функция контекста компонента представляет простой вызов. cdr.detectChanges и контекст этот будет подхвачен сигналами, используемыми в компоненте.
!ВАЖНО: при таком подходе надо будет заинжектить себе этот токен, иначе его экземпляр не создастся.

Также добавим в функцию создания сигнала сохранение колбэка с обновлением шаблона.

export const customSignal = <T>(initValue: T): [() => T, (value: T) => void] => {
…
 if (globalWatcher) {
   watchers.set('global', globalWatcher);
 }
…
   }

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

Недостатки реализации

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

Второй недостаток: переменная globalWatcher никогда не обнуляется, что может вызвать ошибочное поведение, если мы попробуем создать сигнал в сервисе.

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

Время рефакторинга

Итак, попробуем отрефакторить наши сигналы и исправить эти недостатки. Прежде всего исправим дублирование контекстов в коллекции watchers у сигнала. Для этого стоит задать какой-то идентификатор каждому контексту, чтобы можно было понять присутствует ли конкретный контекст в коллекции зависимостей читаемого сигнала. То есть снова добавляется новое свойство в контекст, и с ними уже становится неудобно работать. В целом, из написанного кода мы уже лучше понимаем, что из себя представляет контекст, и для упрощения работы с контекстами создадим класс, что будет представлять контекст.

type DepsMap = Map<Symbol, Watcher>;
export class Watcher {
 constructor(public updateFn: () => void) {}
 id: Symbol = Symbol();
 deps: DepsMap[] = [];
 stop(): void {
   for (let watchMap of this.deps) {
     watchMap.delete(this.id);
   }
   this.deps = [];
 }
}

В этом классе мы инициализируем id контекста и массив со словарями зависимостей сигналов, в которых текущий контекст является потребителем для последующей отписки. В конструктор класса передается функция updateFn, которую необходимо выполнить в рамках контекста при изменении прочитанных в рамках контекста сигналов. Также в этот класс перенесена функция отписки контекста от сигналов на которые он подписан. Мы можем видеть что функция отписки тоже немного изменилась. Так в свойстве deps контекста мы теперь храним массив словарей  с контекстами и без дополнительного ключа. А чтобы достать текущий контекст и удалить его, ключ берется из свойства id класса.

Далее мы изменим тип глобальной переменной watcher на созданный нами класс. Ещё мы дополнительно модифицируем функцию setGlobalWatcher, использовав в ней новый тип и изменив переменную для хранения контекста на просто watсher вместо globalWatcher. А globalWatcher просто удалим. Так как обе эти переменные выполняют одну и ту же роль, нам достаточно  только одной из них. Таким образом, в любой момент времени у нас будет только одна переменная с активным контекстом для всех читаемых сигналов.

let watcher: Watcher | null = null;
export function setGlobalWatcher(w: Watcher | null) {
 watcher = w;
}

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

export function customEffect(fn): () => void {
 const w = new Watcher(() => fn());
 setGlobalWatcher(w);
 fn();
 setGlobalWatcher(null);
 return () => w.stop();
}

Как видно, функция эффекта теперь передаётся в конструктор контекста. Далее контекст эффекта задаётся активным через функцию setGlobalWatcher, а после выполнения функции эффекта глобальный контекст очищается передачей в setGlobalWatcher значения null.

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

export const customSignal = <T>(initValue: T): [() => T, (value: T) => void] => {
 …
  const watchers: DepsMap = new Map();
 …
 function get(): T {
   if (watcher) {
     if (!watchers.get(watcher.id)) {
       watchers.set(watcher.id, watcher);
       watcher.deps.push(watchers);
     }
   }
   return value;
 }
...
};

Теперь переработаем логику работы сигналов с шаблоном. 

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

export function createComponentWatcher(cdr: ChangeDetectorRef) {
 const w = new Watcher(() => cdr.detectChanges());
 return w;
}

Как видно, создание контекста компонента — это просто передача в конструктор Watcher функции с вызовом обновления шаблона компонента. 

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

Новый механизм должен быть похож на работу эффекта: контекст задаётся до рендеринга и обнуляется после него. То есть, нам нужно задать контекст перед вычислением шаблона и обнулить после него. Для этих операций используем хуки ngDoCheck и ngAfterViewChecked, где ngDoCheck вызывается перед вычислением шаблона, а ngAfterViewChecked — после него. Но сначала нам нужно создать контекст компонента. Для этого мы задаём приватную переменную watcher в компоненте, наподобие того, как подобную переменную хранит у себя эффект.

export class AppComponent {
…
 constructor(
   private readonly zone: NgZone,
   private readonly cdr: ChangeDetectorRef
 ) {}
 private watcher = createComponentWatcher(this.cdr);
…
}

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

 ngDoCheck() {
   setGlobalWatcher(this.watcher);
 }

После того, как шаблон будет вычислен, нужно сбросить глобальный контекст:

 ngAfterViewChecked() {
   setGlobalWatcher(null);
 }

Заодно не забудем отписать контекст от сигналов при уничтожении компонента через хук ngOnDestroy.

 ngOnDestroy() {
   this.watcher.stop();
 }

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

Готовое приложение можно посмотреть тут.

Вместо заключения

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

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

Помимо этого, работа с контекстом шаблона компонентов также отличается. В Angular нет необходимости использовать хуки под капотом для задания активного контекста.  Он делает это прямо перед выполнением рендер функцией компонента.  Для хранения активного контекста у View-компонента есть свойство REACTIVE_TEMPLATE_CONSUMER, которое и является тем эффектом-подписчиком, который мы определяли вручную в компоненте.

/**
* The Consumer for this LView's template so that signal reads can be tracked.   
*
* This is initially null and gets assigned a consumer after template execution
   * if any signals were read.
*/
[REACTIVE_TEMPLATE_CONSUMER]: ReactiveLViewConsumer | null; 

Подробнее о том, как работает распространение значений в нодах Angular, можно узнать из этой статьи.

Материалы