Часть 1: Мотивация изменений
1.1. Контекст: знакомые проблемы и новые ожидания
Каждый Angular-разработчик, создававший сколько-нибудь сложное приложение, наверняка сталкивался с этим. Легкое, но заметное подрагивание интерфейса при быстром вводе в форму. Необъяснимая задержка перед отрисовкой модального окна на странице, где живут сотни компонентов. Все это - симптомы системной проблемы, с которой мы мирились годами.
В своей прошлой статье о Change Detection я уже касался корня этой проблемы. Текущая модель обнаружения изменений, основанная на Zone.js и глобальной проверке дерева компонентов сверху вниз, по своей природе неизбирательна. Zone.js не знает, что именно изменилось, поэтому при любом асинхронном событии он просто сообщает фреймворку: Пора на проверку. В ответ Angular вынужден запускать полный обход, затрагивая большинство компонентов, чтобы найти те единичные изменения, которые действительно произошли. Это очень затратный подход, который в крупных проектах неизбежно приводит к лишним проверкам и потере производительности.
Однако, как справедливо отмечалось в RFC от команды Angular, проблема лежит глубже. В существующей системе мир реактивности (где мы используем RxJS) и мир обновления представления (Change Detection) были разделены. Они работали параллельно, но не были тесно связаны в единое целое.
На этом фоне запросы от сообщества звучали все настойчивее. Разработчики хотели получить встроенный, декларативный способ создания производных состояний (derived state). Мы нуждались в более гранулярной синхронизации с UI, чтобы обновлялись только те части DOM, которые действительно изменились. Мы хотели избавиться от печально известной ошибки ExpressionChangedAfterItHasBeenChecked и, конечно же, стремились к будущему без Zone.js.
Команда Angular услышала эти запросы. Стало очевидно, что фреймворку нужна единая и ясная модель потока данных, где реактивность является не внешним слоем, а частью самого ядра.
1.2. Теория в основе: Граф, Продюсеры и Консьюмеры
Чтобы по-настоящему понять, что такое Сигналы, нужно на время забыть об Angular и посмотреть на общую теорию реактивных систем. В основе любой такой системы лежит простая, но мощная структура данных - направленный ациклический граф.
Не пугайтесь термина. Представьте это как сеть взаимосвязанных узлов, где информация течет строго в одном направлении. В этой сети есть всего два типа действующих лиц, или акторов, и все они представлены в коде единым интерфейсом - ReactiveNode.
export interface ReactiveNode {
// ...
/**
* Продюсеры, от которых зависит этот Консьюмер
*/
producers: ReactiveLink | undefined;
/**
* Список Консьюмеров этого Продюсера
*/
consumers: ReactiveLink | undefined;
// ...
}
Каждый узел в графе - это ReactiveNode. У него есть список тех, кого он слушает (producers), и список тех, кто слушает его (consumers).
Продюсер (Producer): Это узел, который содержит значение. Он - источник истины.
Консьюмер (Consumer): Это узел, который реагирует на изменение значения у Продюсера.
Некоторые узлы, как мы увидим позже, могут быть и Продюсерами, и Консьюмерами одновременно. Они потребляют значения от одних узлов (producers), чтобы произвести новое значение для других (consumers).
Как строятся зависимости: active listeners (активные слушатели)
Самое изящное в этой модели - то, как связи в графе строятся автоматически. Процесс выглядит так:
Когда Косьюмер (например, функция computed) собирается выполниться, он объявляет всей системе: Сейчас я активен и слушаю!. В коде это реализуется через установку глобальной переменной activeConsumer.
let activeConsumer: ReactiveNode | null = null;
export function setActiveConsumer(consumer: ReactiveNode | null): ReactiveNode | null {
const prev = activeConsumer;
activeConsumer = consumer;
return prev;
}
Во время своего выполнения Консьюмер обращается к одному или нескольким Продюсерами, чтобы прочитать их значения. Этот доступ вызывает функцию producerAccessed.
producerAccessed видит, что activeConsumer не пустой, и создает связь (ReactiveLink) между текущим Продюсером и активным слушателем - Консьюмером.
export function producerAccessed(node: ReactiveNode): void {
if (activeConsumer === null) {
return;
}
// ...
// Создаем новую связь между Продюсером (node) и Консьюмером (activeConsumer)
const newLink = {
producer: node,
consumer: activeConsumer,
// ...
};
// Добавляем эту связь в список producers для нашего Консьюмера
if (prevProducerLink !== undefined) {
prevProducerLink.nextProducer = newLink;
} else {
activeConsumer.producers = newLink;
}
// ...
}
В результате этой простой последовательности между узлами образуется невидимая связь. Система теперь точно знает, что этот Консьюмер зависит от этого Продюсера.
Как распространяются изменения: версии и оповещения
Когда значение Продюсера меняется, происходит обратный процесс - распространение уведомлений:
У каждого Продюсера есть внутренняя версия. Когда его значение обновляется, он увеличивает номер этой версии, а также глобальный счетчик (epoch), чтобы вся система знала, что что-то изменилось.
type Version = number & {__brand: 'Version'};
let epoch: Version = 1 as Version; // Глобальный счетчик эпох
export interface ReactiveNode {
version: Version;
// ...
}
Сразу после этого Продюсер проходит по своему списку consumers и оповещает каждого из них, вызывая consumerMarkDirty.
export function producerNotifyConsumers(node: ReactiveNode): void {
// ...
for (
let link: ReactiveLink | undefined = node.consumers;
link !== undefined;
link = link.nextConsumer
) {
const consumer = link.consumer;
if (!consumer.dirty) {
consumerMarkDirty(consumer); // Помечаем консьюмера как "грязного"
}
}
// ...
}
export function consumerMarkDirty(node: ReactiveNode): void {
node.dirty = true;
// ... и уведомляем его консьюмеров, запуская цепную реакцию
producerNotifyConsumers(node);
}
Когда системе нужно получить значение от грязного Консьюмера, он сперва проверяет версии всех своих зависимостей. Если он видит, что версия изменилась, он понимает, что его собственное значение устарело, и запускает пересчет.
Эта модель позволяет системе работать хирургически точно. Обновляются только те части графа, которые напрямую или косвенно зависят от измененного значения. Больше никаких глобальных проверок.
Именно на этих трех китах - продюсерах, консьюмерах и автоматически построенном реактивном графе - и строятся те самые API, которые мы рассмотрим дальше в статье.
1.3. Основные акторы на сцене реактивности
Теперь, когда мы понимаем механику реактивного графа, давайте познакомимся с его основными акторами. Эти три примитива - signal, computed и effect - являются фундаментальными строительными блоками новой реактивной модели Angular. Каждый из них играет свою уникальную роль, соответствующую концепциям Продюсера и Консьюмера, которые мы только что обсудили.
-
signal() - Источник истины (Чистый Продюсер)
Роль: Это отправная точка нашего графа. Сигнал - это специальный объект-обертка над значением, который умеет отслеживать, кто его читает, и уведомлять всех, когда его значение изменяется. В терминах графа - это узел Продюсер, который не имеет собственных зависимостей.
-
computed() - Производное состояние (Продюсер + Консьюемер)
Роль: Это самый интересный актор. computed-сигнал слушает другие сигналы (Консьюмер) и вычисляет новое значение на их основе, которое, в свою очередь, предоставляет другим частям системы (Продюсер). Ключевые его свойства - lazy (он не пересчитывается, пока его не попросят) и мемоизация (он кэширует результат и не будет делать лишних вычислений, если зависимости не изменились).
-
effect() - Побочный эффект (Чистый Консьюмер)
Роль: Это конечная точка реактивной цепочки. Эффект - это операция, которая слушает один или несколько сигналов (Консьюмер) и запускается каждый раз, когда их значения меняются. Его задача - не производить новое значение, а вызывать побочные эффекты: обновить DOM, отправить данные на сервер, записать что-то в console.log.
Помимо этой троицы, существует важная утилита, которая позволяет нам управлять построением графа:
-
untracked() - Вне графа
Роль: Это функция-модификатор, которая позволяет нам прочитать значение сигнала внутри реактивного контекста (например, внутри computed или effect), не создавая при этом зависимость. Это мощный инструмент для оптимизации и предотвращения нежелательных реактивных циклов.
Теперь, когда мы познакомились с главными действующими лицами, давайте рассмотрим каждого из них подробно, с примерами кода и практическими сценариями использования.
Часть 2: Фундаментальные Инструменты - Новая Троица
2.1. signal() - Источник истины
Сигнал - это основа всей реактивной системы. Проще говоря, это обертка над значением, которая позволяет Angular отслеживать, где это значение используется, и автоматически уведомлять всех консьюмеров, когда оно меняется. Сигнал - это наш основной Продюсер, отправная точка для любого потока данных.
Практическое использование
Работать с сигналами интуитивно просто. У нас есть три основные операции:
Создание: с помощью функции signal() с начальным значением.
Чтение: вызвав сигнал как функцию.
Изменение: с помощью методов .set() и .update().
Давайте посмотрим на простой пример:
// 1. Создаем сигнал с начальным значением 'Алексей'
const userName = signal('Алексей');
// 2. Чи��аем значение, вызывая сигнал как функцию
console.log(`Добро пожаловать, ${userName()}!`); // -> Добро пожаловать, Алексей!
// 3. Устанавливаем новое значение напрямую с помощью .set()
userName.set('Вася');
console.log(`Добро пожаловать, ${userName()}!`); // -> Добро пожаловать, Вася!
// 4. Обновляем значение на основе предыдущего с помощью .update()
// Это более безопасный способ для сложных преобразований
userName.update(currentName => currentName + ' Васильев');
console.log(userName()); // -> Алексей Васильев
Под капотом: как это на самом деле работает
Каждый раз, когда мы вызываем signal(), мы на самом деле создаем экземпляр SignalNode - реализации ReactiveNode, которую мы обсуждали ранее. Давайте разберем, что происходит во время чтения и записи, глядя на исходный код.
1. Чтение значения: userName()
Когда мы вызываем userName(), внутри срабатывает функция signalGetFn. Ее реализация обманчиво проста, но в ней и заключается вся магия построения зависимостей.
export function signalGetFn<T>(node: SignalNode<T>): T {
// 1. Сообщаем графу, что текущий "активный консьюмер"
// (например, effect или computed) сейчас читает этот сигнал.
producerAccessed(node);
// 2. Просто возвращаем текущее значение.
return node.value;
}
Ключевой момент здесь - вызов producerAccessed(node). Именно в этот момент, если существует activeConsumer, между ним и нашим сигналом (userName) создается невидимая связь в графе зависимостей. Система теперь знает: Этот консьюмер зависит от сигнала userName.
2. Запись значения: userName.set('Вася')
Вызов .set() запускает функцию signalSetFn. Ее задача - не просто поменять значение, а запустить цепную реакцию уведомлений.
export function signalSetFn<T>(node: SignalNode<T>, newValue: T) {
// ...
// 1. Сравниваем новое значение со старым.
// Если они одинаковы, ничего не делаем. Это важная оптимизация.
if (!node.equal(node.value, newValue)) {
// 2. Обновляем внутреннее значение.
node.value = newValue;
// 3. Запускаем процесс уведомления!
signalValueChanged(node);
}
}
Самое интересное происходит в signalValueChanged:
function signalValueChanged<T>(node: SignalNode<T>): void {
// 1. Увеличиваем версию самого сигнала.
node.version++;
// 2. Увеличиваем глобальную "эпоху", чтобы все знали, что в системе
// произошли изменения.
producerIncrementEpoch();
// 3. Проходим по списку всех консьюмеров (consumers), которые
// подписались на этот сигнал, и помечаем их как "грязные" (dirty).
producerNotifyConsumers(node);
// ...
}
Именно вызов producerNotifyConsumers(node) и является тем самым механизмом, который пробуждает все зависимые computed-сигналы и effect-ы, сообщая им, что их зависимости изменились и им, возможно, потребуется пересчитать свое значение или выполниться заново.
Таким образом, signal() - это не просто переменная, а умный узел в реактивном графе, который точно знает, кто от него зависит, и эффективно оповещает их об изменениях.
2.2. computed() - Производное состояние
Если signal - это источник, то computed - это река, которая из него вытекает. computed-сигнал позволяет нам объединить один или несколько других сигналов в новое, производное значение, которое обновляется автоматически.
Ключевые особенности computed:
Декларативность: Вы описываете, как рассчитать значение, а не когда.
Ленивость (Lazy): Значение вычисляется только в тот момент, когда его кто-то запрашивает в первый раз.
Мемоизация (Memoization): Результат вычисления кэшируется. Если вы запросите значение повторно, а его зависимости не изменились, computed вернет кэшированный результат без повторного вызова функции.
В терминах графа, computed - это гибрид. Он является Консьюмером по отношению к сигналам, которые он читает и Продюсером для тех, кто читает его самого.
Практическое использование
Классический пример - объединение имени и фамилии.
const firstName = signal('Алексей');
const lastName = signal('Васильев');
// 1. Создаем computed, который зависит от firstName и lastName.
// Эта функция не выполнится немедленно.
const fullName = computed(() => {
console.log('Вычисляю полное имя...');
return `${firstName()} ${lastName()}`;
});
// 2. Первое чтение. Сейчас функция выполнится.
console.log(fullName());
// -> Вычисляю полное имя...
// -> Алексей Васильев
// 3. Второе чтение. Зависимости не менялись.
// Функция не будет вызвана, вернется кэшированное значение.
console.log(fullName());
// -> Алексей Васильев (без лога в консоли)
// 4. Меняем одну из зависимостей.
lastName.set('Колбаскин');
// 5. Читаем снова. computed "грязный" и пересчитает значение.
console.log(fullName());
// -> Вычисляю полное имя...
// -> Алексей Колбаскин
Под капотом: умные вычисления
Когда мы вызываем computed(() => ...), мы создаем ComputedNode. Давайте посмотрим, как она работает, на основе предоставленного кода.
1. Создание: createComputed()
Функция createComputed создает узел ComputedNode, сохраняет в него нашу функцию-вычисление (computation), но не запускает ее. Изначально, значение узла установлено в специальный символ UNSET.
export const UNSET: any = /* @__PURE__ */ Symbol('UNSET');
// ...
const COMPUTED_NODE = /* @__PURE__ */ (() => {
return {
...REACTIVE_NODE,
value: UNSET, // <-- Начальное значение не определено
dirty: true, // <-- Считается "грязным" с самого начала
// ...
};
})();
2. Чтение: fullName()
Когда мы впервые вызываем fullName(), запускается цепочка событий:
const computed = () => {
// 1. Проверить, не устарело ли значение, и пересчитать, если нужно.
producerUpdateValueVersion(node);
// 2. Сообщить графу, что текущий activeConsumer читает этот computed.
producerAccessed(node);
// ...
return node.value;
};
Ключевая логика скрыта в producerUpdateValueVersion. Эта функция видит, что наш computed-сигнал "грязный" (dirty: true), и вызывает его внутренний метод producerRecomputeValue, который и выполняет нашу функцию.
3. Пересчет значения: producerRecomputeValue()
Это сердце computed. Вот что происходит внутри этого метода:
producerRecomputeValue(node: ComputedNode<unknown>): void {
// ...
// 1. Устанавливаем ТЕКУЩИЙ computed-узел как activeConsumer.
// Это критически важный шаг!
const prevConsumer = consumerBeforeComputation(node);
let newValue: unknown;
try {
// 2. ЗАПУСКАЕМ НАШУ ФУНКЦИЮ: computation()
// В нашем примере: () => `${firstName()} ${lastName()}`
newValue = node.computation();
}
// ...
finally {
// 3. Восстанавливаем предыдущего консьюмера.
consumerAfterComputation(node, prevConsumer);
}
// 4. Сравниваем старое и новое значения.
if (/* wasEqual */) {
// Если значение не изменилось, ничего не делаем.
// Версия (version) не увеличивается.
node.value = oldValue;
return;
}
// 5. Если значение новое, сохраняем его и УВЕЛИЧИВАЕМ ВЕРСИЮ.
node.value = newValue;
node.version++;
}
Самое главное происходит на шагах 1 и 2. Когда computed-сигнал устанавливает себя как activeConsumer и запускает нашу функцию, все сигналы, которые читаются внутри (firstName() и lastName()), регистрируют fullName как своего консьюмера. Так автоматически строятся зависимости!
А на шаге 4 происходит мемоизация: если наша функция вернет то же самое значение, что и в прошлый раз, версия computed-сигнала не изменится. Это значит, что любые консьюмеры, зависящие от fullName, не будут уведомлены об изменениях, что предотвращает лишние пересчеты дальше по цепочке.
2.3. effect() - Реакция на изменения (Побочные эффекты)
Если signal - это источник, а computed - это преобразование, то effect - это пункт назначения. Это конечная точка любой реактивной цепочки, ее главная задача - произвести побочный эффект, то есть синхронизировать состояние наших сигналов с чем-то во внешнем мире.
В отличие от computed, effect никогда не производит значения. Он просто выполняется. Его используют для:
Манипуляций с DOM, которые не вписываются в шаблоны Angular.
Логирования изменений.
Сохранения данных в localStorage или отправки на сервер.
Интеграции со сторонними, не-сигнальными библиотеками.
В терминах графа, effect - это чистый Консьюмер. Он слушает Продюсеров, но сам ничего не производит.
Практическое использование
effect запускается как минимум один раз при создании, а затем перезапускается каждый раз, когда меняется любой из сигналов, прочитанных внутри него.
const counter = signal(0);
// 1. Создаем эффект, который зависит от counter.
// Он немедленно выполнится.
effect(() => {
console.log(`Счетчик теперь равен: ${counter()}`);
});
// -> Счетчик теперь равен: 0
// 2. Изменяем сигнал. Эффект автоматически перезапустится.
counter.set(1);
// -> Счетчик теперь равен: 1
Управление жизненным циклом с onCleanup
Часто побочные эффекты требуют очистки (например, отписка от событий, отмена таймера). effect предоставляет для этого специальную функцию onCleanup.
const isActive = signal(true);
effect((onCleanup) => {
if (isActive()) {
console.log('Эффект активен, запускаем таймер...');
const timerId = setInterval(() => console.log('Тик-так'), 1000);
// 3. Регистрируем функцию очистки.
// Она будет вызвана ПЕРЕД следующим запуском эффекта
// или когда эффект будет уничтожен.
onCleanup(() => {
console.log('Очистка: останавливаем таймер.');
clearInterval(timerId);
});
} else {
console.log('Эффект неактивен.');
}
});
// ...
isActive.set(false); // Это вызовет перезапуск эффекта.
// -> Очистка: останавливаем таймер.
// -> Эффект неактивен.
Под капотом: вечно живой консьюмер
При создании effect создается BaseEffectNode. Его ключевое отличие от computed кроется в конфигурации.
export const BASE_EFFECT_NODE: Omit<BaseEffectNode, ...> = {
...REACTIVE_NODE,
consumerIsAlwaysLive: true, // <-- Ключевое отличие!
consumerAllowSignalWrites: true,
dirty: true,
kind: 'effect',
};
Свойство consumerIsAlwaysLive: true означает, что effect всегда остается подключенным к графу и получает push-уведомления от своих зависимостей. В отличие от computed, который может "уснуть", если его никто не читает, effect всегда начеку.
Процесс выполнения: runEffect()
Когда effect нужно выполнить (впервые или при изменении зависимости), вызывается функция runEffect.
export function runEffect(node: BaseEffectNode) {
// 1. Помечаем себя как "чистого", чтобы избежать повторного запуска
// до следующего изменения.
node.dirty = false;
// ...
// 2. Устанавливаем себя как activeConsumer.
// Механизм построения зависимостей тот же, что и у computed.
const prevNode = consumerBeforeComputation(node);
try {
// 3. СНАЧАЛА выполняем функцию очистки от предыдущего запуска.
node.cleanup();
// 4. ПОТОМ выполняем основную функцию эффекта.
node.fn();
} finally {
// 5. Убираем себя из activeConsumer.
consumerAfterComputation(node, prevNode);
}
}
Процесс практически идентичен computed, но с двумя важными отличиями:
Нет возвращаемого значения: effect ничего не производит и не кэширует.
Приоритет очистки: Перед каждым новым запуском всегда выполняется функция cleanup от предыдущего запуска. Это гарантирует, что у нас никогда не будет "висящих" подписок или таймеров от старых итераций эффекта.
Таким образом, effect - это надежный и предсказуемый способ соединить реактивный мир сигналов с императивным миром побочных эффектов.
2.4. resource() - На Горизонте (Экспериментальный API)
Работа с асинхронными данными - одна из самых частых задач в веб-разработке. Нам постоянно нужно что-то загружать с сервера, обрабатывать состояния загрузки, успеха, ошибки и обновлять данные при изменении параметров запроса. resource() - это экспериментальная попытка Angular предоставить встроенный, основанный на сигналах инструмент для элегантного решения этой задачи.
Практическое использование resource():
const userId = signal(1);
const userResource = resource({
// params - это сигнал или функция, возвращающая параметры для загрузчика.
// resource будет перезапускаться при изменении этих параметров.
params: () => userId(),
// loader - асинхронная функция, которая выполняет запрос.
// Она получает параметры и AbortSignal для отмены.
loader: async ({ params: id, abortSignal }) => {
const res = await fetch(`/api/users/${id}`, { signal: abortSignal });
return res.json();
},
});
// resource возвращает объект с набором сигналов:
// userResource.status() // 'loading', 'resolved', 'error', 'reloading'
// userResource.value() // T | undefined
// userResource.error() // Error | undefined
// userResource.isLoading() // boolean
Вся логика управления состояниями, отмены предыдущих запросов и обработки ошибок скрыта внутри. Мы просто описываем, от чего зависит наш запрос (params) и как его выполнить (loader).
Под капотом: effect, linkedSignal и машина состояний
Изучив исходный код, мы можем увидеть, что resource - это сложная композиция уже знакомых нам примитивов:
Внешний effect: В основе ResourceImpl лежит effect, который подписывается на изменение параметров (this.extRequest). Именно этот эффект отвечает за запуск и отмену асинхронных операций.
this.effectRef = effect(this.loadEffect.bind(this), { /* ... */ });
AbortController для отмены: При каждом новом запуске effect создает AbortController и передает его AbortSignal в нашу loader-функцию. Если параметры меняются до завершения предыдущего запроса, effect вызывает this.abortInProgressLoad(), который отменяет старый fetch.
Машина состояний на сигналах: Самое сердце resource - это сигнал state, который управляет всей машиной состояний (idle, loading, resolved и т.д.). Этот сигнал часто реализован через linkedSignal (внутренний примитив), чтобы мгновенно реагировать на изменение параметров.
this.state = linkedSignal<WrappedRequest, ResourceState<T>>({
source: this.extRequest, // Источник - наши параметры
computation: (extRequest, previous) => {
// Вычисляем новое состояние ('loading', 'idle', и т.д.)
// на основе изменения параметров.
},
});
Проекция состояний в публичные сигналы: Публичные сигналы status, value и error являются computed-сигналами, которые просто читают внутренний state и возвращают нужную его часть.
override readonly status = computed(() => projectStatusOfState(this.state()));
override readonly error = computed(() => {
// ...
return stream && !isResolved(stream) ? stream.error : undefined;
});
Таким образом, resource - это не новый фундаментальный примитив, а высокоуровневая абстракция, построенная поверх signal, computed и effect, чтобы решить одну из самых распространенных и сложных задач в веб-приложениях. Это прекрасный пример того, как базовые строительные блоки могут быть скомпонованы в мощные, декларативные инструменты.
2.5. untracked() - Как избежать зависимостей
До сих пор мы говорили о том, как computed и effect автоматически обнаруживают и подписываются на сигналы, которые они читают. Это основа их магии. Но иногда такое поведение нежелательно. Бывают ситуации, когда нам нужно прочитать значение сигнала, не создавая при этом реактивную зависимость.
Именно для этого и существует утилита untracked(). Она позволяет выполнить функцию в не-реактивном контексте, временно "отключив" механизм отслеживания.
Практическое использование
Самый частый сценарий - логирование или отладка внутри effect. Представим, что у нас есть эффект, который должен реагировать только на изменение имени пользователя, но при этом логировать и его имя, и текущий счетчик.
Неправильный подход:
const userName = signal('Алексей');
const counter = signal(0);
// Этот эффект будет срабатывать при изменении И userName, И counter
effect(() => {
console.log(`Пользователь ${userName()} нажал на счетчик ${counter()} раз.`);
});
// Это изменение вызовет эффект. Все правильно.
userName.set('Картошка');
// -> Пользователь Картошка нажал на счетчик 0 раз.
// И это изменение ТОЖЕ вызовет эффект. А мы этого не хотели.
counter.set(1);
// -> Пользователь Картошка нажал на счетчик 1 раз.
Правильный подход с untracked():
Мы хотим, чтобы эффект срабатывал только при изменении userName, а значение counter просто считывалось "попутно".
const userName = signal('Алексей');
const counter = signal(0);
effect(() => {
// Мы создаем зависимость только от userName.
const currentUser = userName();
// А значение counter читаем внутри untracked,
// чтобы избежать создания зависимости от него.
const currentCount = untracked(() => counter());
console.log(`Пользователь ${currentUser} нажал на счетчик ${currentCount} раз.`);
});
userName.set('Ванпанчмэн');
// -> Пользователь Ванпанчмэн нажал на счетчик 0 раз.
// Теперь это изменение НЕ вызовет эффект.
counter.set(1);
// (в консоли ничего не появится)
untracked - это мощный инструмент для тонкой настройки реактивного поведения и оптимизации, позволяющий избежать лишних запусков effect и пересчетов computed.
Под капотом: элегантная простота
Реализация untracked на удивление проста, но очень показательна. Она напрямую манипулирует сердцем реактивного графа - глобальной переменной activeConsumer.
export function untracked<T>(nonReactiveReadsFn: () => T): T {
// 1. Сохраняем текущего "слушателя" и временно устанавливаем его в null.
const prevConsumer = setActiveConsumer(null);
try {
// 2. Выполняем переданную функцию.
// Поскольку activeConsumer теперь null, любые вызовы signal()
// внутри этой функции не смогут создать зависимость.
return nonReactiveReadsFn();
} finally {
// 3. В блоке finally (чтобы гарантировать выполнение даже при ошибке)
// восстанавливаем предыдущего "слушателя".
setActiveConsumer(prevConsumer);
}
}
Весь механизм сводится к трем шагам:
Надеть наушники: setActiveConsumer(null) временно делает систему "глухой" к любым запросам на отслеживание зависимостей.
Выполнить работу: Функция пользователя выполняется в этом "тихом" режиме.
Снять наушники: setActiveConsumer(prevConsumer) возвращает все как было, восстанав��ивая нормальное реактивное поведение для остального кода.
Эта простая, реализация показывает, насколько гибка и управляема вся система, построенная на концепции activeConsumer.
2.6. linkedSignal - Глубокое погружение (Внутренний API)
На первый взгляд, linkedSignal очень похож на computed. Он тоже берет один или несколько сигналов и вычисляет на их основе новое значение. Так в чем же разница?
В чем computed бессилен: Память о прошлом
computed - это чистая, stateless (без состояния) функция. Она получает на вход текущие значения зависимостей и возвращает результат. Она ничего не знает о том, каким было ее предыдущее значение или какими были значения зависимостей в прошлый раз.
Но что, если нам нужно построить state machine (конечный автомат)? Что, если новое состояние зависит не только от текущего значения, но и от предыдущего состояния?
Представьте сложный пагинатор: при переключении страницы (source изменился) мы хотим не просто сбросить данные, а показать старые данные с флагом isStale, пока грузятся новые. Для этого computed не подходит - ему неоткуда взять «старые данные». Ему потребуется сложный effect с несколькими вспомогательными сигналами.
linkedSignal решает именно эту проблему. Это stateful (с состоянием) версия computed.
Практическое отличие: сигнатура функции
-
computed( () => T )
Принимает функцию без аргументов. Она просто читает нужные сигналы из внешней области видимости.
-
linkedSignal({ source: () => S, computation: (source: S, previous?: { source: S, value: D }) => D })
Принимает source - функцию.
Принимает computation - функцию, которая получает на вход новое значение source и объект previous, содержащий предыдущее значение source и предыдущее вычисленное значение value.
Это и есть ключевое отличие: linkedSignal дает нам память о прошлом.
Под капотом: как хранится «память»
Давайте посмотрим на сердце linkedSignal - его функцию producerRecomputeValue. Она почти идентична той, что у computed, но с одним важным дополнением.
producerRecomputeValue(node: LinkedSignalNode<unknown, unknown>): void {
// ...
const oldValue = node.value;
node.value = COMPUTING;
const prevConsumer = consumerBeforeComputation(node);
let newValue: unknown;
try {
// 1. Получаем новое значение из источника.
const newSourceValue = node.source();
// 2. СОЗДАЕМ ОБЪЕКТ previous ИЗ СТАРЫХ ДАННЫХ!
// node.sourceValue - это значение source из прошлого запуска.
// oldValue - это вычисленное значение из прошлого запуска.
const prev =
oldValue === UNSET || oldValue === ERRORED
? undefined
: {
source: node.sourceValue,
value: oldValue,
};
// 3. Вызываем нашу computation с новыми и старыми данными.
newValue = node.computation(newSourceValue, prev);
// 4. ЗАПОМИНАЕМ новое значение source для следующего запуска.
node.sourceValue = newSourceValue;
} catch (err) {
// ...
} finally {
consumerAfterComputation(node, prevConsumer);
}
// ...
node.value = newValue;
node.version++;
}
Как видите, LinkedSignalNode хранит не только свое текущее значение (value), но и значение своего источника из предыдущего запуска (sourceValue). Это и позволяет ему передавать в функцию вычисления объект previous, открывая дорогу для реализации сложных конечных автоматов.
Если computed - это map для сигналов (преобразование одного значения в другое), то linkedSignal - это reduce (вычисление нового значения на основе предыдущего аккумулятора и нового элемента).
Часть 3: Сигналы в Компонентах - API нового поколения
Итак, мы разобрались с двигателем. Мы изучили signal, computed и effect - три шестеренки (за вычетом низко уровневого linkedSignal и эксперементального resource), которые приводят в движение всю реактивную систему. Но как эта мощь передается на колеса? Как она меняет то, как мы пишем наши компоненты каждый день?
Старый мир декораторов (@Input(), @Output(), @ViewChild()) служил нам верой и правдой долгие годы. Но он был спроектирован до того, как сигналы стали сердцем фреймворка. Он полон неявной магии и требует использования хуков жизненного цикла, таких как ngOnChanges и AfterViewInit, чтобы реагировать на изменения.
С появлением сигналов мы получили возможность построить новый, более совершенный мост между логикой компонента и его окружением. Мы уходим от неявной магии декораторов, которые что-то делают с полями класса за кулисами, к явной композиции функций, которые возвращают нам управляемые реактивные значения.
Этот новый подход дает нам три неоспоримых преимущества:
Прямая реактивность: Входные параметры (input) и запросы к DOM (viewChild) теперь сами являются сигналами. Мы можем использовать их напрямую в computed и effect, полностью избавляясь от громоздкого ngOnChanges. Поток данных становится прозрачным и предсказуемым.
Улучшенная конфигурация и типобезопасность: Новые функции принимают объекты конфигурации, что позволяет нам делать инпуты обязательными (required: true) или применять к ним функции-трансформеры (transform) прямо в месте объявления. Это делает код более надежным и декларативным.
Упрощение жизненного цикла: Поскольку viewChild теперь возвращает сигнал, нам больше не нужно ждать хука AfterViewInit, чтобы безопасно получить доступ к элементу. Мы можем просто создать effect, который сработает, как только сигнал получит свое значение.
В этой главе мы разберем, как новые сигнальные API заменяют и улучшают своих предшественников из мира декораторов. Мы разделим их на две группы:
API для Компонента (input, model, output), определяющие его публичный контракт.
API для Запросов к DOM (viewChild, contentChild и их множественные версии), отвечающие за взаимодействие с шаблоном.
Начнем с самого фундаментального - с того, как данные попадают в наш компонент.
Часть 3.1. input() - Новый стандарт для входных данных
Функция input() - это краеугольный камень нового API компонентов. Она полностью заменяет декоратор @Input() и делает процесс получения данных компонентом более явным, гибким и, что самое главное, реактивным.
Каждый вызов input() создает сигнал, который Angular обновляет автоматически, когда меняется соответствующее свойство, переданное от родительского компонента. Это означает, что мы можем напрямую использовать входные данные в computed и effect, забыв про ngOnChanges навсегда.
Практическое использование: от простого к сложному
input() предлагает гибкий API для разных сценариев.
1. Опциональный инпут
Если инпут не обязателен, мы можем объявить его без значения или с начальным значением.
@Component({...})
export class AvatarComponent {
// Тип будет Signal<string | undefined>
// Если родитель ничего не передаст, значением будет undefined.
src = input<string>();
// Тип будет Signal<number>
// Если родитель ничего не передаст, значением будет 100.
size = input(100);
}
2. Обязательный инпут: input.required()
Одна из главных болей старого @Input() - отсутствие простого способа сделать его обязательным. Теперь это решается вызовом input.required().
@Component({...})
export class UserProfileComponent {
// Тип будет Signal<string>.
// Если родитель не передаст userId, Angular выдаст ошибку
userId = input.required<string>();
}
3. Трансформация данных: опция transform
Еще одна мощная возможность - трансформация входных данных прямо в момент их получения. Классический пример - преобразование строкового атрибута в boolean.
@Component({...})
export class ButtonComponent {
// booleanAttribute - это встроенная функция-трансформер.
// Теперь мы можем написать <app-button disabled></app-button>,
// и disabled() вернет true, а не пустую строку.
disabled = input(false, { transform: booleanAttribute });
}
4. Псевдонимы: опция alias
Если публичное имя инпута должно отличаться от имени свойства в классе, используется alias.
@Component({...})
export class ProductComponent {
// В шаблоне родителя: <app-product [productId]="'abc'"></app-product>
// Внутри компонента: this.id()
id = input.required<string>({ alias: 'productId' });
}
Под капотом: как input() становится сигналом
Проанализировав исходный код, можно увидеть архитектуру, стоящую за этой простой функцией.
Точка входа: Публичная функция input() - это фасад. В зависимости от того, вызываете вы input() или input.required(), она вызывает внутреннюю фабричную функцию createInputSignal. Для required-инпутов она передает специальный маркер REQUIRED_UNSET_VALUE.
Создание узла: Фабрика createInputSignal создает InputSignalNode - это специализированная версия SignalNode, о которой мы говорили ранее. Она хранит значение, а также опциональную функцию-трансформер (transformFn).
export function createInputSignal<T, TransformT>(
initialValue: T,
options?: InputOptions<T, TransformT>,
): InputSignalWithTransform<T, TransformT> {
const node: InputSignalNode<T, TransformT> = Object.create(INPUT_SIGNAL_NODE);
node.value = initialValue;
node.transformFn = options?.transform;
// ...
}
Чтение значения: Когда мы вызываем myInput(), срабатывает функция inputValueFn. Она, как и обычный сигнал, вызывает producerAccessed(node), чтобы зарегистрировать зависимость. Но перед этим она проверяет, не является ли значение REQUIRED_UNSET_VALUE. Если да, и мы пытаемся прочитать его до того, как Angular его установил, мы получаем ошибку - так работает механизм required.
function inputValueFn() {
producerAccessed(node);
if (node.value === REQUIRED_UNSET_VALUE) {
// ... бросить ошибку ...
}
return node.value;
}
Запись значения: Мы не можем менять input() напрямую (он не writable). За нас это делает сам Angular. Когда фреймворк обнаруживает изменение в привязке, он вызывает внутренний метод applyValueToInputSignal. Важно, что перед этим он уже применил функцию-трансформер (если она есть). Затем этот метод просто вызывает signalSetFn - ту же самую функцию, что и у обычного signal, чтобы обновить значение и уведомить всех потребителей.
applyValueToInputSignal<T, TransformT>(node: InputSignalNode<T, TransformT>,value: T) {
signalSetFn(node, value);
}
Таким образом, input() - это не магия. Это умная обертка, которая создает специализированный, доступный только для чтения сигнал (InputSignalNode), предоставляет Angular "черный ход" для его обновления (applyValueToInputSignal) и добавляет полезные возможности вроде required и transform на этапе конфигурации.
Часть 3.2: output() - Модернизированный EventEmitter
Функция output() - это современная замена для декоратора @Output() и класса EventEmitter. Она предоставляет простой и типобезопасный способ для компонента отправлять события родительским компонентам.
Важное отличие от input(): output() не возвращает сигнал. Вместо этого он возвращает объект типа OutputEmitterRef<T>, у которого есть два ключевых метода: .emit() для отправки данных и .subscribe() для программного прослушивания.
Практическое использование
Использование output() интуитивно понятно и очень похоже на старый EventEmitter.
1. Объявление и отправка события
@Component({
selector: 'app-user-actions',
// ...
})
export class UserActionsComponent {
// Объявляем output. Тип - OutputEmitterRef<string>.
userDeleted = output<string>();
// Объявляем output без данных. Тип - OutputEmitterRef<void>.
closed = output();
onDeleteClick() {
const userId = '123';
// Отправляем событие с данными.
this.userDeleted.emit(userId);
}
onCloseClick() {
// Отправляем событие без данных.
this.closed.emit();
}
}
В родительском компоненте мы можем слушать эти события так же, как и раньше:
<app-user-actions
(userDeleted)="onUserDeleted($event)"
(closed)="onPanelClosed()">
</app-user-actions>
2. Программная подписка
Новый OutputEmitterRef также позволяет подписываться на события программно, что может быть полезно для более сложных сценариев взаимодействия между директивами.
// В какой-то директиве или компоненте
const actions = viewChild.required(UserActionsComponent);
constructor() {
effect(() => {
// Подписываемся на событие программно
const sub = actions().userDeleted.subscribe(userId => {
console.log(`Пользователь ${userId} был удален.`);
});
// Подписка будет автоматически отменена при уничтожении компонента
// благодаря DestroyRef.
});
}
Под капотом: простой, но эффективный механизм
Реализация output() гораздо проще, чем у input(), поскольку ей не нужно быть частью реактивного графа. Это, по сути, легковесный, современный EventEmitter.
Точка входа: Функция output() - это простая фабрика, которая создает и возвращает новый экземпляр класса OutputEmitterRef<T>. Больше ничего. Вся логика инкапсулирована в этом классе.
export function output<T = void>(opts?: OutputOptions): OutputEmitterRef<T> {
ngDevMode && assertIninjectionContext(output);
return new OutputEmitterRef<T>();
}
Ядро: Класс OutputEmitterRef - это и есть вся "магия".
Хранение подписчиков: У него есть внутреннее свойство listeners - это просто массив функций-колбэков, которые были зарегистрированы через .subscribe().
Автоматическая отписка: В конструкторе он получает DestroyRef текущего компонента. Когда компонент уничтожается, onDestroy колбэк очищает массив listeners, предотвращая утечки памяти. Это большое улучшение по сравнению с ручным takeUntil(this.destroy$) в мире RxJS.
constructor() {
this.destroyRef.onDestroy(() => {
this.destroyed = true;
this.listeners = null;
});
}
Отправка события: Метод .emit() - это простой цикл, который проходит по массиву listeners и вызывает каждую функцию-колбэк с переданным значением.
emit(value: T): void {
// ...
for (const listenerFn of this.listeners) {
listenerFn(value);
}
}
untracked для безопасности: Вы могли заметить, что вызов подписчиков обернут в setActiveConsumer(null). Это важная мера предосторожности. Она гарантирует, что если внутри колбэка подписчика (listenerFn) будет читаться какой-то сигнал, это случайно не создаст реактивную зависимость для effect-а, который, возможно, инициировал вызов .emit(). Это предотвращает трудноотлавливаемые побочные эффекты.
В итоге, output() - это не революция, а эволюция. Он берет знакомую концепцию EventEmitter, делает ее проще (не нужно создавать new EventEmitter()), безопаснее (автоматическая отписка) и немного "умнее" (защита от случайных реактивных зависимостей), идеально вписывая ее в современный API Angular.
Часть 3.3: model() - Революция двусторонней привязки
Двусторонняя привязка всегда была одной из ключевых фич Angular. Синтаксис [(ngModel)] или [(value)] позволял легко синхронизировать состояние между родительским и дочерним компонентами. Но под капотом это всегда была лишь синтаксическая обертка над парой из @Input() и @Output().
model() берет эту концепцию и превращает ее в первоклассный, единый примитив. model() - это writable signal (записываемый сигнал), который одновременно является и входом (input), и выходом (output).
Практическое использование
Объявление model() похоже на input(), но возвращает ModelSignal<T>, который можно не только читать, но и изменять.
@Component({
selector: 'app-custom-input',
// ...
})
export class CustomInputComponent {
// Объявляем model. Тип - ModelSignal<string>.
// По умолчанию это создаст input value и output valueChange.
value = model('');
}
Внутри CustomInputComponent мы можем как читать, так и писать в этот сигнал:
// Читаем значение
const currentValue = this.value();
// Обновляем значение. Это АВТОМАТИЧЕСКИ вызовет output!
this.value.set('новое значение');
Родительский компонент может использовать знакомый синтаксис двусторонней привязки:
<app-custom-input [(value)]="parentProperty"></app-custom-input>
Как и input, model также имеет .required версию: model.required<T>().
Под капотом: умная комбинация input, output и signal
model() - это не новый фундаментальный примитив, а гениальная композиция тех частей, которые мы уже рассмотрели. Исходный код createModelSignal раскрывает эту комбинацию.
-
Создание составных частей: Внутри createModelSignal создаются два ключевых элемента:
InputSignalNode: Та же структура, что и у input(), для хранения значения и получения обновлений от родителя.
OutputEmitterRef: Та же структура, что и у output(), для отправки изменений родителю.
export function createModelSignal<T>(initialValue:T,opts?:ModelOptions):ModelSignal<T>{
const node: InputSignalNode<T, T> = Object.create(INPUT_SIGNAL_NODE);
const emitterRef = new OutputEmitterRef<T>();
// ...
}
-
Возвращаемый объект - гибрид: Функция createModelSignal возвращает одну функцию (getter), но затем "навешивает" на нее методы от всех трех миров:
От мира сигналов: Она ведет себя как сигнал, ее можно вызвать для чтения (getter()).
От мира input: Она имеет внутреннюю структуру InputSignalNode (getter[SIGNAL] = node), что позволяет Angular-рантайму обновлять ее значение извне.
От мира output: Она получает методы .subscribe() и destroyRef от OutputEmitterRef.
От мира writable signal: Ей напрямую добавляются методы .set() и .update().
Ключевая связка в .set(): Вся магия происходит в реализации метода .set().
getter.set = (newValue: T) => {
// Сначала проверяем, что значение действительно изменилось.
if (!node.equal(node.value, newValue)) {
// 1. Обновляем внутреннее состояние сигнала,
// как это делает обычный signalSetFn.
// Это уведомит всех локальных потребителей (effect, computed).
signalSetFn(node, newValue);
// 2. СРАЗУ ПОСЛЕ ЭТОГО отправляем событие наружу
// через внутренний OutputEmitterRef.
emitterRef.emit(newValue);
}
};
Этот код идеально иллюстрирует суть model(): это input, который при любом внутреннем изменении своего значения автоматически вызывает output.
model() избавляет нас от необходимости вручную создавать пару @Input()/@Output(), писать бойлерплейт для их синхронизации и делает код компонента значительно чище и декларативнее. Это настоящий game-changer для создания интерактивных, форм-подобных компонентов.
Часть 3.4: Запросы к DOM - viewChild и contentChild
В старом API, чтобы получить доступ к элементу шаблона или дочернему компоненту, мы использовали декораторы @ViewChild и @ContentChild. Главной проблемой этого подхода было то, что результат запроса был доступен только внутри хука ngAfterViewInit (или ngAfterContentInit). Попытка обратиться к нему в ngOnInit или конструкторе приводила к ошибке.
Новые функции viewChild и contentChild (а также их множественные версии) решают эту проблему, возвращая сигнал. Этот сигнал изначально пуст (undefined), и Angular автоматически обновляет его значение, как только запрашиваемый элемент появляется в DOM.
Практическое использование
1. viewChild и viewChildren: доступ к шаблону компонента
viewChild используется для получения одного элемента из собственного шаблона компонента. viewChildren - для получения списка элементов.
@Component({
template: `
<input #nameInput placeholder="Имя">
<app-item *ngFor="let i of items" />
`
})
export class MyFormComponent {
// Ищем один элемент по шаблонной переменной.
// Тип - Signal<ElementRef | undefined>
nameInput = viewChild<ElementRef>('nameInput');
// Ищем все дочерние компоненты ItemComponent.
// Тип - Signal<ReadonlyArray<ItemComponent>>
items = viewChildren(ItemComponent);
constructor() {
// Теперь мы можем реагировать на появление элемента с помощью effect!
// Больше не нужен ngAfterViewInit.
effect(() => {
const inputEl = this.nameInput();
if (inputEl) {
// Элемент доступен, можно с ним работать.
inputEl.nativeElement.focus();
}
});
}
}
Как и input, viewChild имеет .required версию, которая выдаст ошибку, если элемент не будет найден.
2. contentChild и contentChildren: доступ к проекциям (ng-content)
Эти функции работают аналогично, но ищут элементы, которые были переданы в компонент снаружи, через <ng-content>.
// В компоненте-обертке
@Component({
selector: 'app-panel',
template: `
<div class="header">
<ng-content select="app-panel-header"></ng-content>
</div>
<div class="body">...</div>
`
})
export class PanelComponent {
// Ищем компонент, который был спроецирован в <ng-content>.
// Тип - Signal<PanelHeaderComponent | undefined>
header = contentChild(PanelHeaderComponent);
}
Под капотом: отложенное разрешение через сигналы
-
Точки входа: Функции viewChild, viewChildren, contentChild и contentChildren - это просто обертки. Они выполняют проверку assertInInjectionContext и затем вызывают одну из трех внутренних фабричных функций
createSingleResultOptionalQuerySignalFn (для viewChild())
createSingleResultRequiredQuerySignalFn (для viewChild.required())
createMultiResultQuerySignalFn (для viewChildren())
Фабрики сигналов: Эти внутренние функции создают специальный тип сигнала. Этот сигнал "знает", какой запрос (query) ему соответствует.
Работа фреймворка: Angular-рантайм после каждого цикла обнаружения изменений выполняет все зарегистрированные запросы. Если результат запроса изменился (например, элемент появился, исчез или добавился новый в viewChildren), фреймворк находит соответствующий сигнал запроса и вызывает его внутренний метод .set(), обновляя его значение.
Этот механизм позволяет нам полностью перейти на реактивный стиль работы с DOM. Вместо императивной проверки "появился ли элемент?" в хуке ngAfterViewInit, мы декларативно описываем effect, который сам сработает в нужный момент. Это делает код чище, надежнее и избавляет от целого класса ошибок, связанных с жизненным циклом компонента.
Часть 4: Продвинутые Техники - За гранью основ
4.1. effect() и DestroyRef: Управление жизненным циклом через DI
До появления сигналов управление "долгоживущими" подписками и другими ресурсами было тесно связано с хуками жизненного цикла, в частности с ngOnDestroy. Это приводило к большому количеству бойлерплейта (Subscription, Subject.takeUntil()).
Сигнальный effect предлагает более элегантный, композируемый подход, используя существующий механизм Dependency Injection (DI) и токен DestroyRef.
Старый подход:
@Component({...})
export class OldComponent implements OnInit, OnDestroy {
private subscription: Subscription;
ngOnInit() {
this.subscription = someObservable$.subscribe(...);
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}
Новый подход с effect:
@Component({...})
export class NewComponent {
constructor() {
effect(() => {
// Этот эффект автоматически подпишется на сигналы внутри
// и будет автоматически уничтожен вместе с компонентом.
console.log(`Значение сигнала: ${mySignal()}`);
});
}
}
Как это работает? effect, вызванный в конструкторе компонента, автоматически "цепляется" к DestroyRef этого компонента. DestroyRef - это DI-токен, который предоставляет хук onDestroy. Когда компонент уничтожается, DestroyRef вызывает все зарегистрированные на нем колбэки, включая функцию очистки, неявно созданную для нашего effect.
Это делает код чище и позволяет легко выносить реактивную логику в отдельные сервисы или композитные функции, не привязываясь к хукам жизненного цикла конкретного компонента.
4.2. Мост в мир RxJS: toSignal() и toObservable()
Ни одно крупное приложение не будет переписано на сигналы мгновенно. Годами мы строили сложную логику на RxJS, и нам нужен надежный мост между двумя мирами. Angular предоставляет две утилиты для этого, находящиеся в @angular/core/rxjs-interop.
toSignal(source$): Превращает Observable в Signal. Это основной инструмент для интеграции существующих сервисов или HttpClient запросов в новый, сигнальный мир.
const myData$ = this.myService.getDataStream();
// Превращаем стрим в сигнал.
// initialValue обязателен, если стрим не синхронный (как большинство).
const myData = toSignal(myData$, { initialValue: [] });
// Теперь myData - это обычный readonly-сигнал.
// computed(() => myData().length);
toObservable(source): Выполняет обратную операцию - превращает Signal в Observable. Это полезно, когда часть вашего при��ожения все еще ожидает Observable (например, старый компонент или библиотека), а вы хотите передать ей значение из сигнала.
const counter = signal(0);
const counter$ = toObservable(counter);
// Теперь можно использовать все операторы RxJS
counter$.pipe(debounceTime(300)).subscribe(val => ...);
Эти две функции обеспечивают плавный и поэтапный переход на сигналы, позволяя двум парадигмам сосуществовать в одном приложении.
4.3. Как сигналы меняют шедулинг: Жизнь без Zone.js
Сигналы - это не просто новый способ управления состоянием; это технология, которая позволяет Angular отказаться от Zone.js. Чтобы понять как, нужно заглянуть в сердце нового механизма рендеринга - в ChangeDetectionScheduler.
В zoneless-приложении (provideZonelessChangeDetection()) именно этот планировщик решает, когда запускать проверку изменений. Весь процесс можно разбить на три четких шага.
Шаг 1: Уведомление (Notification)
Вместо того чтобы Zone.js отлавливал все подряд, теперь Angular получает явные уведомления о том, что что-то могло измениться. Эти причины перечислены в enum NotificationSource. Среди них:
SetInput: Обновился input() компонента.
Listener: Сработал обработчик события в шаблоне.
MarkForCheck: Был вызван cdr.markForCheck().
RootEffect / ViewEffect: effect стал "грязным". Именно так сигналы сообщают системе о своих изменениях.
Когда мы вызываем .set() у сигнала, который используется в шаблоне или в effect, в конечном итоге вызывается метод scheduler.notify(NotificationSource.ViewEffect).
Шаг 2: Маркировка и Планирование (Mark & Schedule)
Метод notify() в ChangeDetectionSchedulerImpl не запускает рендеринг немедленно. Он делает две очень быстрые вещи:
Маркирует работу: Он устанавливает флаг в ApplicationRef, указывая, какой тип работы нужно будет выполнить. Например, для ViewEffect он установит appRef.dirtyFlag |= ApplicationRefDirtyFlags.ViewTreeTraversal. Это просто битовая маска, очень дешевая операция.
Планирует tick (только один раз): Затем он проверяет, не запланирован ли уже tick. Если нет, он использует scheduleCallbackWithRafRace (умная комбинация requestAnimationFrame и setTimeout(0)), чтобы запланировать выполнение ApplicationRef.tick() в ближайшем будущем.
Это ключевой момент. Сколько бы сигналов вы ни изменили в одном обработчике события, все они приведут лишь к установке разных флагов и одному запланированному tick(). Система дедуплицирует работу по рендерингу.
notify(source: NotificationSource): void {
// 1. Устанавливаем флаги "грязности"
this.appRef.dirtyFlags |= ...;
// 2. Проверяем, нужно ли планировать
if (!this.shouldScheduleTick()) {
return;
}
// 3. Планируем tick с помощью requestAnimationFrame (или setTimeout)
this.cancelScheduledCallback = this.ngZone.runOutsideAngular(() =>
scheduleCallback(() => this.tick()),
);
}
Шаг 3: Хирургическое Обновление (Execution)
Когда запланированный tick() наконец выполняется, ApplicationRef больше не проверяет все дерево компонентов. Он смотрит на свои dirtyFlags и на внутренний список "грязных" компонентов, помеченных сигналами, и запускает обнаружение изменений только для них и их дочерних элементов.
Сигналы разрывают прямую связь между любым асинхронным действием и глобальной проверкой. Вместо этого они создают явную, отслеживаемую и эффективную цепочку:
Изменение сигнала -> notify(ViewEffect) -> Установка флага в ApplicationRef -> Однократное планирование tick -> Точечное обновление только "грязных" компонентов.
Это делает рендеринг более предсказуемым, производительным и, наконец, освобождает Angular от необходимости полагаться на магию Zone.js.
Часть 5: Критический Взгляд - Нюансы и подводные камни
5.1. Смена парадигмы: Декларативность против Императивности
Самый большой барьер при переходе на сигналы - не синтаксический, а ментальный. Мы привыкли писать императивный код: «когда происходит событие А, сделай действие Б, а потом обнови свойство В».
Сигналы требуют декларативного мышления: «свойство В всегда является результатом вычисления на основе сигналов А и Б».
Ловушка: Попытка использовать effect как замену императивному коду.
// Антипаттерн: императивное мышление в мире сигналов
const a = signal(1);
const b = signal(2);
const c = signal(0); // Вместо computed, создаем writable signal для результата
effect(() => {
// Вручную "синхронизируем" c, когда a или b меняются
c.set(a() + b());
});
Технически, это работает. Но это полностью убивает идею. Мы теряем ленивость и мемоизацию computed, создаем лишний effect и пишем код, который сложнее читать. Правильный, декларативный подход очевиден:
// Правильный, декларативный подход
const a = signal(1);
const b = signal(2);
const c = computed(() => a() + b());
Переход на сигналы требует сознательного усилия думать в терминах «что есть что», а не «что делать когда».
5.2. Отладка: В поисках источника изменений
В императивном коде легко отследить, что вызвало изменение: вы просто ставите брейкпоинт в месте, где написано this.value = .... В реактивной системе с длинными цепочками computed это становится сложнее.
Ловушка: Непонимание, почему computed пересчитывается.
const a = signal(1);
const b = computed(() => a() * 2);
const c = computed(() => b() + 5);
const d = computed(() => c() > 10);
// ...
a.set(2); // Это изменение вызовет пересчет b, c, и d.
Если у вас есть effect, зависящий от d, и он срабатывает неожиданно, отследить всю цепочку до первопричины (a.set(2)) может быть нетривиально.
Что делать?
Логирование: Старый добрый console.log - ваш лучший друг. Вы можете временно вставить его прямо в функцию computed, чтобы посмотреть, когда и с какими значениями она вызывается.
const c = computed(() => {
const result = b() + 5;
console.log('Пересчет c:', { b: b(), result });
return result;
});
Именование сигналов: Используйте опцию debugName в продвинутых API, чтобы давать сигналам осмысленные имена, которые появятся в DevTools.
Работа с сигналами требует смены не только подхода к написанию кода, но и к его отладке. Но эти трудности с лихвой окупаются предсказуемостью и производительностью, которые они приносят.
Часть 6: Под Капотом - Пульс реактивной системы
Мы уже знаем, что сигналы ст��оят граф зависимостей. Но как система понимает, что именно нужно пересчитать, а что можно оставить в покое? Ответ кроется в системе версий, которую можно представить как "реактивные часы".
6.1. Глобальная Эпоха (epoch) и Локальные Версии
Внутри движка сигналов существуют два типа "часов":
Глобальная эпоха (epoch): Это простой счетчик, который увеличивается на единицу каждый раз, когда меняется любой writable signal в приложении. Можно думать об этом как о глобальном такте. Если epoch не изменился, значит, в системе вообще не было никаких изменений состояния.
Локальная версия (version): Каждый узел в графе (и signal, и computed) имеет свой собственный счетчик версии. Он увеличивается только тогда, когда значение этого конкретного узла действительно меняется.
Каждый узел также помнит, в какой глобальной эпохе его в последний раз проверяли и признали "чистым" (lastCleanEpoch). Эта комбинация из трех чисел - version, epoch, lastCleanEpoch - и позволяет творить магию ленивых вычислений.
6.2. Анатомия одного изменения: от set() до computed()
Давайте проследим полный жизненный цикл одного изменения и посмотрим, как эти "часы" работают.
Сцена: signal.set()
Предположим, мы вызываем mySignal.set(newValue). Происходит три вещи:
Тикают глобальные часы: epoch++. Весь мир переходит в новую эру.
Обновляется локальная версия: mySignal.version++.
Распространяется "грязь": mySignal проходит по списку своих прямых потребителей (consumers) и помечает их как dirty = true. Эта "грязь" может распространиться дальше вверх по графу.
Сцена: Чтение computed()
Теперь где-то в коде (например, в effect шаблона) происходит чтение myComputed(). myComputed может быть "грязным", но он не бросается сразу себя пересчитывать. Вместо этого он запускает процесс, называемый "опрос продюсеров" (consumerPollProducersForChange):
myComputed смотрит на своих родителей (зависимости). Для каждой зависимости он сравнивает версию, которую он запомнил при последнем чтении (link.lastReadVersion), с текущей версией зависимости (producer.version).
Если версии не совпадают - бинго! Зависимость точно изменилась. Опрос можно прекращать. myComputed знает, что ему нужно пересчитать свое значение.
Если версии совпадают, это еще ничего не значит. Возможно, сама зависимость - это другой computed, который еще не знает, что он устарел. Поэтому myComputed рекурсивно просит свою зависимость проверить ее версию (producerUpdateValueVersion). Этот процесс продолжается вниз по графу, пока мы либо не найдем изменение, либо не дойдем до самого низа и не убедимся, что изменений не было.
И только в том случае, если этот опрос подтвердил, что хотя бы одна зависимость реально изменилась, будет запущена дорогостоящая функция-вычисление внутри myComputed.
После пересчета myComputed обновляет свою version, сбрасывает флаг dirty = false и, что самое важное, записывает this.lastCleanEpoch = epoch. Это говорит системе: "В текущей глобальной эпохе я проверен и актуален. Можете меня не беспокоить до следующего epoch".
6.3. Связь с Zoneless: Маркировка, а не исполнение
Как этот механизм приводит нас к zoneless-будущему?
Конечным потребителем в большинстве случаев является внутренний effect, связанный с шаблоном компонента. Когда сигнал, используемый в шаблоне, меняется, этот effect помечается как dirty.
Когда ChangeDetectionScheduler решает запустить tick, он запускает эти "грязные" effect-ы. effect, в свою очередь, запускает описанный выше механизм опроса версий для всех computed, от которых он зависит.
Но сам effect не меняет DOM напрямую. Его единственная задача после всех проверок - это вызвать markForCheck() для своего компонента. Он просто говорит: "Эй, планировщик, я подтверждаю, что моему компоненту нужно обновиться".
Таким образом, система версий позволяет эффективно определить, нужно ли вообще что-то делать, а явная связь effect -> markForCheck -> ChangeDetectionScheduler гарантирует, что это будет сделано централизованно, эффективно и только для тех компонентов, которые действительно изменились.
Часть 7: Что дальше?
Мы начали наше путешествие с простого, но наболевшего вопроса: «Должен быть лучший способ». Мы устали от неизбирательных проверок Zone.js, от бойлерплейта ngOnChanges и takeUntil, от неявной магии, которая работала до тех пор, пока не переставала. Сигналы - это и есть тот самый лучший способ. И теперь, в конце пути, мы можем с уверенностью сказать, почему.
8.1. Новая триада Angular
Сигналы меняют то, как мы пишем, читаем и даже думаем о коде Angular, и сводится это к трем ключевым улучшениям:
Производительность по умолчанию: Гранулярная реактивность - это не просто красивая теория. Это практический механизм, который позволяет Angular точно знать, какую часть DOM нужно обновить. Вместо того чтобы проверять все приложение, фреймворк теперь может вносить хирургические изменения. Это открывает дорогу к будущему без Zone.js и делает наши приложения быстрыми не благодаря оптимизациям, а по самой своей природе.
Простота и элегантность кода: Вспомните примеры "до" и "после", которые мы рассматривали. Громоздкие реализации ngOnChanges заменяются одним computed. Ручное управление подписками в ngOnDestroy исчезает благодаря effect, интегрированному с DestroyRef. Кода становится меньше. Он становится более декларативным, его легче читать и поддерживать.
Предсказуемость потока данных: "Магия" уходит, уступая место явным связям. Реактивный граф - это не просто деталь реализации, это карта потока данных в вашем приложении. Если что-то изменилось, вы всегда можете отследить цепочку зависимостей от effect-а вниз до signal-а, который послужил первопричиной. Это делает отладку и понимание сложной логики на порядок проще.
8.2. Взгляд в будущее
То, что мы видим сегодня - это лишь первый, хоть и гигантский, шаг. Сигналы - это не просто новая фича, это фундамент для следующего поколения Angular-компонентов.
Переход на сигналы - это больше, чем изучение нового синтаксиса. Это приглашение начать "думать реактивно".
Начните с малого: оберните существующий RxJS-поток в toSignal и посмотрите, как легко он интегрируется в computed. Попробуйте отрефакторить один компонент с ngOnChanges на сигнальные инпуты. Почувствуйте, как ваш код становится чище.
Мы стоим на пороге новой эры в истории Angular. Эры предсказуемости, производительности и элегантности. Инструменты у вас в руках.
ganqqwerty
Удивительно, как в реакте ты все время думаешь про то, чтобы ни дай бог не вызвать рендер лишний раз, в то время как в Ангуляре надеешься чтобы хоть как-то заработало и ты при этом не помер от вязанки rx-потоков.