Чтобы эффективно использовать сигналы Angular Signals, вам нужно понимать концепцию «реактивного контекста» и то, как работает отслеживание зависимостей. В этой статье я объясню обе эти вещи и покажу, как избежать некоторых связанных с ними ошибок.
Отслеживание зависимостей
Когда вы используете сигналы, вам не нужно беспокоиться о подписке и отписке. Чтобы понять, как это работает, нам понадобятся несколько терминов:
Граф зависимостей (Dependency Graph): граф, где каждый узел реализует интерфейс ReactiveNode;
Производители (Producers): узлы, содержащие значения и уведомляющие о новых значениях (они «производят реактивность»);
Потребители (Consumers): узлы, которые считывают произведенные значения (они «потребляют реактивность»);
Сигналы являются производителями, функция computed()
— производитель и потребитель одновременно, функция effect()
— потребитель, шаблоны — потребители.
Рекомендую вам прочитать более подробную статью о графике зависимостей в Angular Signals (с анимированным примером графика).
Как работает автоматическое отслеживание зависимостей: есть глобальная для всех реактивных узлов переменная activeConsumer
, и каждый раз, когда computed()
запускает свою функцию реактивных вычислений, каждый раз, когда effect()
запускает свою функцию выполнения сайд‑эффектов, или когда шаблон проверяется на изменения, они:
Считывают значение переменной
activeConsumer
(чтобы запомнить предыдущего потребителя);Регистрируют себя в качестве
activeConsumer
;Запускают функцию или выполняют шаблон (на этом этапе могут быть считаны некоторые сигналы);
Регистрируют предыдущего потребителя (из шага 1) как
activeConsumer
.
Когда любой производитель считывается, он извлекает значение activeConsumer
и включает этого активного потребителя в список потребителей, зависящих от сигнала. Когда сигнал обновляется, он впоследствии отправляет уведомление каждому потребителю из своего списка.
Давайте пошагово рассмотрим, что происходит в этом примере:
@Component({
template: `
Items count: {{ $items().length }}
Active items count: {{ $activeItemsCount() }}
`
})
class ExampleComponent {
protected readonly $items = signal([{id: 1, $isActive: signal(true) }]);
protected readonly $activeItemsCount = computed(() => {
return this.getActiveItems().length;
});
private getActiveItems() {
return this.$items().filter(i => i.$isActive());
}
}
Шаблон считывает значение
activeConsumer
и сохраняет его в переменнойprevConsumer
(эта переменная является локальной для шаблона);Шаблон устанавливает себя в качестве
activeConsumer
;Он вызывает сигнал
$items()
, чтобы получить значение;Сигнал
$items
извлекает значениеactiveConsumer
;Полученное значение не пустое (в нем содержится ссылка на шаблон), поэтому сигнал
$items
помещает это значение (ссылку на наш шаблон) в список потребителей. После этого каждый раз, когда$items
будет обновляться, шаблон будет получать уведомление — в графе зависимостей появилась новая ссылка;$items
возвращает значение в шаблон;Шаблон считывает значение сигнала
$activeItemsCount
. Чтобы вернуть значение,$activeItemsCount
должен запустить свою вычислительную функцию (функцию, которую мы передаем в нашем коде вcomputed()
);Перед запуском вычислительной функции
$activeItemsCount
считывает значениеactiveConsumer
и сохраняет его в своей локальной переменнойprevConsumer
. Поскольку$activeItemsCount
также является потребителем, он помещает ссылку на себя в переменнуюactiveConsumer
;Вычислительная функция вызывает функцию
getActiveItems()
;Внутри этой функции мы считываем значение
$items
— шаги с 3 по 6 повторяются, но поскольку наш шаблон уже зависит от$items
, шаг 5 не будет добавлять нового потребителя в список;Когда возвращается значение (массив элементов),
getActiveItems()
считывает каждый элемент этого массива и считывает значение$isActive()
;$isActive
— это сигнал. Поэтому, прежде чем вернуть значение, он повторяет шаги 3–6. На шаге 4$isActive
извлекает значениеactiveConsumer
. В данный моментactiveConsumer
содержит ссылку на$activeItemsCount
, поэтому на шаге 5$isActive
(каждый из массива) добавит$activeItemsCount
в список зависимых потребителей. Каждый раз, когда$isActive
обновляется,$activeItemsCount
получает уведомление,$activeItemsCount
уведомляет наш шаблон о том, что его значение устарело и должно быть пересчитано. После этого наш шаблон в конечном итоге (не сразу после уведомления) спросит$activeItemsCount
, каково новое значение, и шаги с 7 по 14 будут повторены;getActiveItems()
возвращает значение.$activeItemsCount
использует это значение для вычислений и перед тем, как вернуть его, помещает значение своей локальной переменнойprevConsumer
в переменнуюactiveConsumer
;$activeItemsCount
возвращает значение;Шаблон помещает ранее сохраненное значение
prevConsumer
вactiveConsumer
.
Это не маленький список, но, пожалуйста, прочитайте его внимательно.
Самое важное здесь то, что потребителям (computed()
, effect()
, шаблонам) не нужно беспокоиться о добавлении сигналов, которые они считывают, в список зависимостей. Сигналы будут делать это сами, используя переменную activeConsumer
. Эта переменная доступна любому реактивному узлу, поэтому неважно, как глубоко в цепочке функций будет прочитан сигнал — любой сигнал получит значение activeConsumer
и добавит его в список потребителей.
Помните: если вы вызываете в шаблоне функцию, computed()
или effect()
(любого потребителя), а эта функция читает другую функцию, а та читает другую функцию... и, наконец, на каком‑то уровне функция читает сигнал, то этот сигнал добавляет потребителя в свой список и уведомляет его об обновлениях.
Все это чтение (почти как отладка), может быть утомительным, поэтому позвольте мне развлечь вас этим небольшим приложением:
Пожалуйста, выполните следующие действия в этом приложении:
Нажмите кнопку «2», чтобы сделать ее активной, а затем нажмите ее еще раз. Обратите внимание, что текст «Active items» над кнопками отражает это изменение;
Нажмите кнопку «Add Item»;
Нажмите кнопку «4». Обратите внимание, что надпись «Active items» не отражает изменения;
Нажмите кнопку «2»;
Теперь несколько раз нажмите кнопку «4» и обратите внимание, что текст «Active items» отражает состояние кнопки, как и ожидалось.
Но почему это происходит? Давайте посмотрим на код:
export type Item = {
id: number;
$isActive: WritableSignal<boolean>;
};
@Component({
selector: 'my-app',
template: `
<div>Active items: {{ $activeItems() }}</div>
<div>
<span>Click to to toggle:</span>
@for(item of items; track item.id) {
<button (click)="item.$isActive.set(!item.$isActive())"
[class.active]="item.$isActive()">
{{ item.id }}
</button>
}
</div>
<div>
<button (click)="addItem()">Add Item</button>
</div>
`,
})
export class App {
protected readonly items: Item[] = [
{ id: 1, $isActive: signal(true) },
{ id: 2, $isActive: signal(false) },
{ id: 3, $isActive: signal(true) },
];
protected readonly $activeItems = computed(() => {
const ids = [];
for (const item of this.items) {
if (item.$isActive()) {
ids.push(item.id);
}
}
return ids.join(', ');
});
protected addItem() {
this.items.push({
id: this.items.length + 1,
$isActive: signal(false),
});
}
}
Теперь давайте разберемся, почему строка «Active items» обновляется некорректно.
Привязка в нашем шаблоне:
<div>Active items: {{ $activeItems() }}</div>
$activeItems
это сигнал, предоставленный функцией computed()
:
protected readonly $activeItems = computed(() => {
const ids = [];
for (const item of this.items) {
if (item.$isActive()) {
ids.push(item.id);
}
}
return ids.join(', ');
});
Функция, которую мы передаем в computed()
, будет выполняться заново каждый раз, когда любой из сигналов, которые она считывает, будет обновлен. Какие сигналы мы считываем?
Это сигнал $isActive
для каждого элемента в массиве this.items
.
Обратите внимание, как знак $
в имени переменной помогает быстро найти источники реактивности. Этот принцип аналогично применим к функции effect()
и шаблонам компонентов. Именно поэтому я использую его, но на деле это просто вопрос личных предпочтений.
Почему же $activeItems
не обновляется после шагов 2 и 3?
Вычислительная функция будет повторно выполнена только тогда, когда обновится один из сигналов, от которых она зависит.
Когда мы нажимаем кнопку «Add Item», мы изменяем this.items
и создаем новый сигнал внутри нового элемента. Но до этого момента наша функция computed()
никогда не читала этот сигнал, поэтому у нее нет его в списке зависимостей.
До и после нажатия кнопки «Add Item» список сигналов, от которых зависит $activeItems
, остается неизменным: три сигнала $isActive
от трех элементов в this.items
.
Поскольку ни один из этих сигналов не изменяется, когда мы нажимаем на кнопку «Add Item», computed()
не будет уведомлен, и вычислительная функция не будет выполнена повторно.
Мы можем переключать наш новый элемент в списке кнопок сколько угодно раз, но только сигналы трех первых элементов будут оповещать $activeItems
, и он снова выполнит отправленную нами функцию.
Но если мы повторно выполним нашу вычислительную функцию, она снова прочитает все элементы из this.items
и, наконец, прочитает новый сигнал. Новый сигнал станет новой зависимостью узла $activeItems
, и он будет получать уведомления при каждом изменении одного из этих сигналов.
Для этого нам нужно изменить одну из существующих зависимостей: именно для этого мы нажимаем кнопку «2» в шаге 4.
Этот пример создан для того, чтобы напомнить вам, что функции, которые мы передаем в computed()
и effect()
, будут повторно выполняться только тогда, когда один из производителей, которых они считали, будет обновлен.
Поэтому всегда полезно перепроверять, какие зависимости есть у вашей computed()
и какие из них должны вызвать повторное вычисление. Если некоторые из них не должны — используйте untracked()
.
Некоторые функции, которые мы передаем в computed()
или effect()
, могут считывать сигналы (или функции, которые они вызывают, могут считывать сигналы).
this.$petWalkingIsAllowed = computed(() => {
return this.$isFreeTime() && this.isItGoodWeatherOutside();
});
isItGoodWeatherOutside() {
return $isSunny() && $isWarm() && !$isStormy();
}
Чтобы понять, стоит ли оборачивать такие вызовы функцией untracked()
, чтобы избежать нежелательных повторных вычислений, мы можем воспользоваться следующей логикой:
Если мы не хотим, чтобы наша
computed()
вычисляла новый результат, когда эта функция (isItGoodWeatherOutside()
) возвращает новое значение, оберните ее вuntracked()
:
this.$petWalkingIsAllowed = computed(() => {
return this.$isFreeTime() && untracked(() => this.isItGoodWeatherOutside());
});
isItGoodWeatherOutside() {
return $isSunny() && $isWarm() && !$isStormy();
}
Если при каждом новом значении из этой функции мы хотим повторно выполнить вычисления, не оборачивайте ее в функцию
untracked()
.
Как видите, untracked()
помогает нам контролировать, какие зависимости мы хотим отслеживать. Она также помогает управлять еще одним важным аспектом:
Реактивный контекст
Выше, в разделе про автоматическое отслеживание зависимостей, я упоминал переменную activeConsumer
.
Если activeConsumer
не равен null
, прочитанные сигналы добавят этот activeConsumer
в список потребителей, чтобы впоследствии уведомлять членов этого списка об изменении сигнала. Если реактивный узел будет прочитан, когда activeConsumer
пуст, он не создаст новой связи в графе зависимостей реактивных узлов.
Другими словами, пока activeConsumer
установлен, мы считываем сигналы внутри реактивного контекста.
В большинстве случаев реактивный контекст будет обрабатываться автоматически, и будут создаваться и удаляться только нужные связи и зависимости.
Но иногда мы непреднамеренно создаем утечку реактивного контекста.
Давайте рассмотрим это приложение:
https://stackblitz‑starters‑hgrfbo.stackblitz.io
Если вы попробуете повзаимодействовать с ним, то заметите что:
Нажатие на кнопку «Add Item» приводит к полному сбросу всех статусов;
Нажатие на кнопку переключения статуса меняет его случайным образом, затрагивая не только одну кнопку.
Сможете ли вы быстро обнаружить ошибку?
@Component({
template: `
<div>Active items: {{ $activeItems() }}</div>
<div class="flex-row">
<span>Click to to toggle:</span>
@for(item of $items(); track item.id) {
<button (click)="item.$isActive.set(!item.$isActive())" [class.active]="item.$isActive()" [style.transform]="'scale('+item.$scale()+')'">
{{ item.id }}
</button>
}
</div>
<div>
<button (click)="addItem()">Add Item</button>
</div>
`,
})
export class App {
private readonly $itemsCount = signal(3);
protected readonly $items: Signal<Item[]> = computed(() => {
console.warn('Generating items!');
const items: Item[] = [];
for (let id = 0; id < this.$itemsCount(); id++) {
const $isActive = signal(Math.random() > 0.5);
const $scale = signal($isActive() ? 1.2 : 1);
items.push({ id, $isActive, $scale });
}
return items;
});
protected readonly $activeItems = computed(() => {
const ids = [];
for (const item of this.$items()) {
if (item.$isActive()) {
ids.push(item.id);
}
}
return ids.join(', ');
});
protected addItem() {
this.$itemsCount.update(c => c + 1);
}
}
Что мы можем здесь увидеть:
Мы выводим список элементов из
$items
, который был вычислен вcomputed()
;$items генерирует новый массив элементов, а их количество контролируется сигналом
$itemsCount
. Каждый раз, когда мы изменяем$itemsCount
, элементы генерируются заново;addItem()
просто увеличивает$itemsCount
, вызывая повторное вычисление $items.
Теперь мы видим, почему «Add Item» работает именно так. Давайте попробуем выяснить, почему себя странно ведет переключение статуса.
Если мы откроем консоль, то заметим, что каждый раз, когда мы нажимаем на кнопку, в логах появляется предупреждение «Generating items!». Но почему? Мы не изменяем $itemsCount, так почему же $items
вычисляется заново?
Возможно, вы уже заметили, что функция вычисления $items считывает еще один источник реактивности: сигнал $isActive
:
const $scale = signal($isActive() ? 1.2 : 1);
Этот сигнал ($isActive
) считывается в реактивном контексте: activeConsumer
содержит $items
, поэтому $isActive
будет уведомлять $items
о каждом изменении. Поэтому, когда мы изменяем $isActive
в попытке переключить этот статус, мы запускаем повторное вычисление $items
.
Существует множество способов исправить эту ошибку, но данный подход предотвращает утечку реактивного контекста:
const $scale = signal(untracked($isActive) ? 1.2 : 1);
Что делает функция untracked()
?
/**
* https://github.com/angular/angular/blob/75a186e321cb417685b2f13e9961906fc0aed36c/packages/core/src/render3/reactivity/untracked.ts#L15
*
* packages/core/src/render3/reactivity/untracked.ts
*
**/
export function untracked<T>(nonReactiveReadsFn: () => T): T {
const prevConsumer = setActiveConsumer(null);
try {
return nonReactiveReadsFn();
} finally {
setActiveConsumer(prevConsumer);
}
}
устанавливает
activeConsumer
вnull
и сохраняет возвращаемое значение в локальной переменнойprevConsumer
;запускает заданную функцию;
восстанавливает
activeConsumer
изprevConsumer
.
Она временно отключает реактивный контекст, выполняет нашу функцию, а затем восстанавливает его.
Поэтому во время выполнения нашей функции, если какие‑либо сигналы считываются, они будут считывать null
из activeConsumer
и не будут добавлять его в свои списки потребителей. Другими словами, никаких новых зависимостей создано не будет.
В этом примере у нас есть несколько «подсказок» в консоли, а наш код очень простой и лаконичный. В реальных приложениях считывание сигналов может быть зарыто глубоко в цепочке вызовов функций, и код может быть гораздо сложнее и объемнее. Подобные ошибки могут быть сложными для отладки в реальных приложениях, поэтому я рекомендую предотвращать их, используя untracked()
всякий раз, когда вы не хотите создавать утечку реактивного контекста.
Существуют довольно интересные и неожиданные действия, приводящие к утечке реактивного контекста:
Создание экземпляра класса, который считывает некоторые сигналы;
Вызов функции, которая вызывает другую функцию, считывающую сигнал;
Создание компонента внутри
effect()
;Передача нового значения
observable
.
Когда вы используете computed()
и effect()
,
Читайте другие сигналы с осторожностью — при каждом изменении, вызванном любой другой функцией, они будут заново выполнять наши
computed()
иeffect()
.Делайте эти функции легко читаемыми и понятными;
Дважды проверяйте каждый источник реактивности, который потребляет ваша функция.
Как это часто бывает, неявное отслеживание зависимостей имеет не одни только преимущества, но и заставляет идти на компромиссы. Но при умелом и осторожном использовании с помощью сигналов Angular можно создавать замечательные приложения.
Больше про языки программирования эксперты OTUS рассказывают в рамках практических онлайн-курсов. С полным каталогом курсов можно ознакомиться по ссылке.