Сигнали в Angular: докладний розбір для зайнятих розробників
У цій статті описано внутрішні елементи Angular, пов’язані з конкретними вихідними комітами нижче, як досліджено в 2024 році. Такі API, як createWatch, масиви вузлів і EffectHandle, є деталями реалізації. Current Angular відрізняє компонентні ефекти від кореневих ефектів з різним плануванням; використовуйте пов’язані вихідні версії, щоб стежити за цими внутрішніми функціями.
Створення складних користувальницьких інтерфейсів є важким завданням. У сучасних веб-додатках стан інтерфейсу користувача рідко складається з простих окремих значень. Це досить складний обчислений стан, який залежить від складної ієрархії інших значень або обчислених станів. Управління цим станом вимагає багато роботи: розробники повинні зберігати, обчислювати, анулювати та синхронізувати ці значення.
Протягом багатьох років у веб-розробку було введено різноманітні фреймворки та примітиви, щоб спростити це завдання. Центральною темою для більшості з них є реактивне програмування, яке пропонує інфраструктуру для керування станом програми, що дозволяє розробникам зосередитися на бізнес-логіці, а не на повторюваних завданнях керування станом.
Найновішим доповненням є signals, «реактивний» примітив, який представляє динамічно мінливе значення та може повідомляти зацікавлених споживачів, коли значення змінюється. Ті, у свою чергу, можуть запускати повторні обчислення або різні побічні ефекти, напр. створення/знищення компонентів, виконання мережевих запитів, оновлення DOM тощо.
Ми можемо знайти різні реалізації сигналів у різних фреймворках. Зараз навіть є спроби стандартизувати сигнали:
… ці зусилля спрямовані на вирівнювання екосистеми JavaScript. Кілька авторів фреймворку співпрацюють тут над спільною моделлю, яка могла б підтримувати їх ядро реактивності. Поточний проект базується на даних авторів/розробників Angular, Bubble, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue, Wiz та багато іншого…
Реалізація сигналів в Angular дуже нагадує реалізацію, надану як частина пропозиції, тому я можу зробити перехресні посилання між ними в цій статті.
Сигнали як примітиви
Сигнал представляє комірку даних, яка може змінюватися з часом. Сигнали можуть бути або “станом” (просто значенням, яке встановлюється вручну), або “обчисленими” (подумайте про формулу на основі інших сигналів).
Обчислені сигнали функціонують, автоматично відстежуючи, які інші сигнали зчитуються під час їх оцінювання. Коли обчислений сигнал зчитується, він перевіряє, чи змінилася будь-яка з його раніше записаних залежностей, і, якщо так, повторно оцінює себе.
Наприклад, тут ми маємо сигнал стану counter і обчислений сигнал isEven. Ми встановлюємо початкове значення counter на 0, а пізніше змінюємо його на 1. Ви бачите, що обчислений сигнал isEven реагує на зміни, створюючи два різних значення до та після оновлення сигналу counter:
import { computed, signal } from '@angular/core';
// state/writable signal
const counter = signal(0);
// computed signal
const isEven = computed(() => (counter() & 1) == 0);
counter() // 0
isEven() // true
counter.set(1)
counter() // 1
isEven() // falseЗауважте також, що у наведеному вище прикладі сигнал isEven явно не підписується на вихідний сигнал counter. Замість цього він просто викликає вихідний сигнал за допомогою counter() у своїй обчисленій функції. Цього достатньо, щоб з’єднати два сигнали. У результаті кожного разу, коли вихідний сигнал counter оновлюється новим значенням, отриманий сигнал повертає оновлене значення під час наступного зчитування.
Як сигнали стану, так і обчислені вважаються виробниками значень. Виробники представляють сигнали, які створюють значення та можуть доставляти повідомлення про зміни.
Сигнал стану змінює (виробляє) своє значення, коли значення оновлюється через виклик API, тоді як обчислений сигнал повторно обчислює своє значення на вимогу після зміни його залежностей.
Обчислені сигнали також можуть бути споживачами, оскільки вони можуть залежати від певної кількості виробників (споживають). В інших реактивних реалізаціях, напр. Rx, споживачі також відомі як раковини.
Коли значення сигналу виробника змінюється, значення залежних споживачів, напр. обчислені сигнали не оновлюються негайно. Коли обчислений сигнал зчитується, він перевіряє, чи змінилася будь-яка з його раніше записаних залежностей, і за потреби виконує повторну оцінку.
Це робить обчислені сигнали ледачими або заснованими на витягуванні, тобто вони оцінюються лише під час доступу, навіть якщо основний стан змінився раніше. У нашому прикладі вище обчислене значення сигналу оцінюється лише тоді, коли ми викликаємо isEven(), хоча оновлення основної залежності counter відбулося раніше, коли ми виконали counter.set().
Крім звичайних записуваних і обчислених сигналів, існує також концепція спостерігачів (ефектів). На відміну від оцінки обчислених сигналів на основі залучення, зміна сигналу виробника негайно сповіщатиме спостерігача, синхронно викликаючи зворотний виклик сповіщень спостерігача, фактично «виштовхуючи» сповіщення. Фреймворки об’єднують спостерігачів у ефекти, доступні для користувачів. Ефекти відкладають повідомлення про код користувача через планування.
Основні сигнальні операції виконуються синхронно; ефекти можуть запланувати роботу на потім:
- Встановлення нового значення сигналу є синхронним, і це негайно відображається під час читання будь-якого обчисленого сигналу, який залежить від нього згодом. Немає вбудованої пакетної обробки цієї мутації.
- Зчитування обчислених сигналів відбувається синхронно - їх значення завжди доступне.
- Спостерігачі отримують сповіщення синхронно, але ефекти, які охоплюють цих спостерігачів, можуть вибрати пакетне та відкладене сповіщення за допомогою планування.
Деталі впровадження
Внутрішня реалізація сигналів визначає ряд концепцій, які я хочу пояснити в цій статті: реактивний контекст, графік залежностей і ефекти (спостерігачі). Почнемо з реактивного контексту.
Щоб обговорити реактивний контекст, подумайте про фрейм стека (фрейм виконання), який визначає середовище, у якому код JavaScript оцінюється та виконується. Зокрема, він визначає, які об’єкти (змінні) доступні для функції. Можна сказати, що доступність цих об’єктів визначає контекст. Наприклад, функція, яка виконується в контексті веб-працівника, не має доступу до глобального об’єкта document.
Реактивний контекст визначає активний об’єкт споживача, який залежить від виробників і доступний їхнім функціям доступу щоразу, коли зчитується їх значення. Наприклад, у нас є споживач isEven, який залежить від виробника counter (споживає його значення). Ця залежність визначається шляхом доступу до значення counter у зворотному виклику computed:
isEven = computed(() => (counter() & 1) === 0)Коли буде запущено обчислений зворотний виклик, він автоматично виконає функцію доступу до сигналу counter, щоб отримати його значення. Ми можемо сказати, що в цьому випадку сигнал counter виконується в реактивному контексті споживача isEven. Отже, виробник виконується в реактивному контексті, якщо є активний споживач, який залежить від значення цього виробника.
Щоб реалізувати цей механізм реактивного контексту, кожного разу, коли здійснюється доступ до значення споживача, але до його повторного обчислення (до виконання зворотного виклику computed), ми можемо встановити цього споживача як активного споживача. Це можна зробити, просто призначивши цей об’єкт споживача глобальній змінній і зберігаючи його там, поки виконується зворотний виклик. Ця глобальна змінна буде доступна для всіх виробників, запитуваних під час виконання зворотного виклику computed, і вона визначатиме реактивний контекст для всіх виробників, від яких залежить цей споживач.
Саме це робить Angular. Коли обчислений зворотний виклик виконується, він спочатку встановить поточний вузол як активного споживача в producerRecomputeValue:
function producerRecomputeValue(node: ComputedNode<unknown>): void {
...
const prevConsumer = consumerBeforeComputation(node);
let newValue: unknown;
try {
newValue = node.computation();
} catch (err) {...} finally {...}
function consumerBeforeComputation(node: ReactiveNode | null) {
node && (node.nextProducerIndex = 0);
return setActiveConsumer(node);
}Angular потрапляє туди з producerUpdateValueVersion у фабричній функції createComputed:
function createComputed<T>(computation: () => T): ComputedGetter<T> {
...
const computed = () => {
producerUpdateValueVersion(node);
...
};
}
function producerUpdateValueVersion(node: ReactiveNode): void {
...
node.producerRecomputeValue(node);
...
}Цей стек викликів також чітко демонструє цю реалізацію:

Через це, поки виконується зворотний виклик computed, кожен виробник, до якого надсилається запит під час активності цього споживача, знатиме, що вони виконуються в реактивному контексті. Усі виробники, що виконуються в реактивному контексті певного споживача, додаються як залежності споживача. Це створює реактивний граф.
Більшість попередньо існуючих функцій в Angular виконується в нереактивному контексті. Ви можете помітити це, просто знайшовши використання setActiveConsumer зі значенням null:

Наприклад, перед запуском хуків життєвого циклу Angular очищає реактивний контекст:
/**
* Executes a single lifecycle hook, making sure that:
* - it is called in the non-reactive context;
* - profiling data are registered.
*/
function callHookInternal(directive: any, hook: () => void) {
profiler(ProfilerEvent.LifecycleHookStart, directive, hook);
const prevConsumer = setActiveConsumer(null);
try {
hook.call(directive);
} finally {
setActiveConsumer(prevConsumer);
profiler(ProfilerEvent.LifecycleHookEnd, directive, hook);
}
}Функції шаблону Angular (перегляди компонентів) і ефекти запускаються в реактивних контекстах.
Реактивний графік
Реактивний графік будується через залежності між споживачами та виробниками. Реактивна реалізація контексту через засоби доступу до значень дає змогу автоматично та неявно відстежувати залежності сигналу. Користувачам не потрібно оголошувати масиви залежностей, а також не потрібно, щоб набір залежностей певного контексту залишався статичним під час виконання.
Коли виробник виконується, він додає себе до залежностей поточного активного споживача (споживача, який визначає поточний реактивний контекст). Це відбувається всередині функції producerAccessed:
export function producerAccessed(node: ReactiveNode): void {
...
// This producer is the `idx`th dependency of `activeConsumer`.
const idx = activeConsumer.nextProducerIndex++;
if (activeConsumer.producerNode[idx] !== node) {
// We're a new dependency of the consumer (at `idx`).
activeConsumer.producerNode[idx] = node;
// If the active consumer is live, then add it as a live consumer. If not, then use 0 as a
// placeholder value.
activeConsumer.producerIndexOfThis[idx] = consumerIsLive(activeConsumer)
? producerAddLiveConsumer(node, activeConsumer, idx)
: 0;
}У реактивному графі беруть участь як виробники, так і споживачі. Цей графік залежностей є двонаправленим, але є відмінності в тому, які залежності відстежуються в кожному напрямку.
Виробники відстежуються як залежності споживача через властивість producerNode, створюючи межі від споживачів до виробників:
interface ConsumerNode extends ReactiveNode {
producerNode: NonNullable<ReactiveNode['producerNode']>;
producerIndexOfThis: NonNullable<ReactiveNode['producerIndexOfThis']>;
producerLastReadVersion: NonNullable<ReactiveNode['producerLastReadVersion']>;Деякі споживачі також відстежуються як «живі» споживачі та створюють переваги в іншому напрямку, від виробника до споживача. Ці межі використовуються для розповсюдження сповіщень про зміни, коли оновлюється значення виробника:
interface ProducerNode extends ReactiveNode {
liveConsumerNode: NonNullable<ReactiveNode['liveConsumerNode']>;
liveConsumerIndexOfThis: NonNullable<ReactiveNode['liveConsumerIndexOfThis']>;
}Споживачі завжди стежать за виробниками, від яких залежать. Виробники відстежують лише залежності від споживачів, які вважаються «живими». Споживач є «живим», якщо його властивість consumerIsAlwaysLive встановлена на true, або є виробником, від якого залежить живий споживач.
В Angular два типи вузлів визначаються як живі споживачі:
- Вузли watch (використовуються в ефектах)
- реактивні вузли LView (використовуються для виявлення змін)
Ось їхні визначення:
const WATCH_NODE: Partial<WatchNode> = /* @__PURE__ */ (() => {
return {
...REACTIVE_NODE,
consumerIsAlwaysLive: true,
consumerAllowSignalWrites: false,
consumerMarkedDirty: (node: WatchNode) => {
if (node.schedule !== null) {
node.schedule(node.ref);
}
},
hasRun: false,
cleanupFn: NOOP_CLEANUP_FN,
};
})();
const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = {
...REACTIVE_NODE,
consumerIsAlwaysLive: true,
consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
markAncestorsForTraversal(node.lView!);
},
consumerOnSignalRead(this: ReactiveLViewConsumer): void {
this.lView![REACTIVE_TEMPLATE_CONSUMER] = this;
},
};У деяких контекстах сигнали computed можуть стати «живими» споживачами, наприклад, коли використовуються у зворотному виклику effect.
Наступне налаштування коду:
import { ChangeDetectorRef, Component, computed, effect, signal } from '@angular/core';
import { SIGNAL } from '@angular/core/primitives/signals';
@Component({
standalone: true,
selector: 'app-root',
template: 'Angular Love',
styles: []
})
export class AppComponent {
constructor(private cdRef: ChangeDetectorRef) {
const a = signal(0);
const b = computed(() => a() + 'b');
const c = computed(() => a() + 'c');
const d = computed(() => b() + c() + 'd');
const nodes = [a[SIGNAL], b[SIGNAL], c[SIGNAL], d[SIGNAL]] as any[];
d();
const A = 0, B = 1, C = 2, D = 3;
const depBToA = nodes[B].producerNode[0] === nodes[A];
const depCToA = nodes[C].producerNode[0] === nodes[A];
const depDToB = nodes[D].producerNode[0] === nodes[B];
const depDToC = nodes[D].producerNode[1] === nodes[C];
console.log(depBToA, depCToA, depDToB, depDToC);
const e = effect(() => b()) as any;
// need to wait for change detection to notify the effect
setTimeout(() => {
// effect depends on B
const depEToB = e.watcher[SIGNAL].producerNode[0] === nodes[B];
// live consumers link from producer A to B,
// and from B to E, because E (effect) is a live consumer
const depLiveAToB = nodes[A].liveConsumerNode[0] === nodes[B];
const depLiveBToE = nodes[B].liveConsumerNode[0] === e.watcher[SIGNAL];
console.log(depLiveAToB, depLiveBToE, depEToB);
});
}
}створить наступний графік:

Реалізація реактивного контексту через активного споживача забезпечує динамічне відстеження залежностей. Коли певний споживач встановлено як активний, виробники, які оцінюються, визначаються динамічно за допомогою послідовності викликів цих виробників. Список залежностей може бути переорганізований для ActiveConsumer щоразу, коли до виробника звертаються у реактивному контексті цього споживача.
Щоб реалізувати це, залежності споживача відстежуються в масиві producerNode:
interface ConsumerNode extends ReactiveNode {
producerNode: NonNullable<ReactiveNode['producerNode']>;
producerIndexOfThis: NonNullable<ReactiveNode['producerIndexOfThis']>;
producerLastReadVersion: NonNullable<ReactiveNode['producerLastReadVersion']>;Коли обчислення для певного споживача повторюється, вказівник (індекс) nextProducerIndex у цьому масиві ініціалізується індексом 0, і кожна прочитана залежність порівнюється із залежністю від попереднього запуску в поточному розташуванні вказівника. Якщо є невідповідність, це означає, що залежності змінилися з моменту останнього запуску, і стару залежність можна видалити та замінити новою. Наприкінці циклу будь-які невідповідні залежності, що залишилися, можна видалити.
Це означає, що якщо у вас є залежність, необхідна лише для однієї гілки, а попередній обчислення взяв іншу гілку, то зміна цього тимчасово невикористаного значення не призведе до переобчислення обчисленого сигналу, навіть якщо його вилучено. Це призводить до можливості доступу до різних наборів сигналів від одного виконання до наступного.
Наприклад, цей обчислений сигнал dynamic читає dataA або dataB залежно від значення сигналу useA:
const dynamic = computed(() => useA() ? dataA() : dataB());У будь-який заданий момент він матиме набір залежностей або [useA, dataA], або [useA, dataB], і він ніколи не може залежати від dataA і dataB одночасно.
Цей код, схожий на цей тест у полізаповненні пропозиції сигналів, чітко демонструє, що:
import { computed, signal } from '@angular/core';
import { SIGNAL} from '@angular/core/primitives/signals';
const states = Array.from('abcdefgh').map((s) => signal(s));
const sources = signal(states);
const vComputed = computed(() => {
let str = '';
for (const state of sources()) str += state();
return str;
});
const n = vComputed[SIGNAL] as any;
expectEqual(vComputed(), 'abcdefgh');
expectEqualArrayElements(n.producerNode.slice(1), states.map(s => s[SIGNAL]));
sources.set(states.slice(0, 5));
expectEqual(vComputed(), 'abcde');
expectEqualArrayElements(n.producerNode.slice(1), states.slice(0, 5).map(s => s[SIGNAL]));
sources.set(states.slice(3));
expectEqual(vComputed(), 'defgh');
expectEqualArrayElements(n.producerNode.slice(1), states.slice(3).map(s => s[SIGNAL]));
function expectEqual(v1, v2): any {
if (v1 !== v2) throw new Error(`Expected ${v1} to equal ${v2}`);
}
function expectEqualArrayElements(v1, v2): any {
if (v1.length !== v2.length) throw new Error(`Expected ${v1} to equal ${v2}`);
for (let i = 0; i < v1.length; i++) {
if (v1[i] !== v2[i]) throw new Error(`Expected ${v1} to equal ${v2}`);
}
}Як бачите, для графа немає жодної початкової вершини. Оскільки кожен споживач зберігає список виробників залежностей, які, у свою чергу, можуть мати залежності, напр. обчислений сигнал, тож можна сказати, що кожен споживач у момент звернення до нього є кореневою вершиною графа.
Оновлення в два етапи
Попередні моделі реактивності, засновані на натисканні, зіткнулися з проблемою надлишкових обчислень: якщо оновлення сигналу стану спричиняє активне виконання обчисленого сигналу, зрештою це може призвести до оновлення інтерфейсу користувача. Але цей запис в користувальницький інтерфейс може бути передчасним, якщо мала відбутися ще одна зміна вихідного сигналу стану перед наступним кадром.
Наприклад, для подібного графіка ця проблема передбачає ненавмисне оцінювання A -> B -> D і C, а потім повторне оцінювання D, оскільки C змінився. Переоцінка D двічі є неефективною та може призвести до помітних збоїв для користувача.

Це відоме як алмазна проблема.
Іноді через такі глюки кінцевим користувачам навіть показувалися неточні проміжні значення. Сигнали уникають цієї динаміки, будучи заснованими на витягуванні (ліниві), а не на основі натискання: у той час, коли фреймворк планує відтворення інтерфейсу користувача, він витягне відповідні оновлення, уникаючи марної роботи як на обчислення, так і на запис у DOM.
Розглянемо цей приклад:
const a = signal(0);
const b = computed(() => a() + 'b');
const c = computed(() => a() + 'c');
const d = computed(() => b() + c() + 'd');
// run the computed callback to set up dependencies
d();
// update the signal at the top of the graph
setTimeout(() => a.set(1), 2000);У цьому прикладі немає живих споживачів, тому оновлення a не поширює сповіщення. У наступному фрагменті показано значення вузла та оновлення версії:
function signalSetFn(node, newValue) {
...
if (!node.equal(node.value, newValue)) {
node.value = newValue;
signalValueChanged(node);
}
}
function signalValueChanged(node) {
node.version++;
...
}Коли пізніше ми отримуємо доступ до значення d(), реалізація сигналів опитує залежності від d до consumerPollProducersForChange, щоб визначити, чи потрібне повторне обчислення.
Для ефективної обробки всі реактивні вузли записують версію вузла залежності. Щоб визначити зміну, достатньо просто порівняти збережену версію вузла-виробника з фактичною версією на вузлі:
interface ConsumerNode extends ReactiveNode {
...
producerLastReadVersion: NonNullable<ReactiveNode['producerLastReadVersion']>;
}
function consumerPollProducersForChange(node) {
...
// Poll producers for change.
for (let i = 0; i < node.producerNode.length; i++) {
const producer = node.producerNode[i];
const seenVersion = node.producerLastReadVersion[i];
// First check the versions. A mismatch means that the producer's value is known to have
// changed since the last time we read it.
if (seenVersion !== producer.version) {
return true;
}Якщо вони відрізняються, у виробника відбулися зміни, і реалізація запустить повторне обчислення обчисленого зворотного виклику через producerRecomputeValue:
export function producerUpdateValueVersion(node: ReactiveNode): void {
...
if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) {
// None of our producers report a change since the last time they were read, so no
// recomputation of our value is necessary, and we can consider ourselves clean.
node.dirty = false;
node.lastCleanEpoch = epoch;
return;
}
node.producerRecomputeValue(node);
// After recomputing the value, we're no longer dirty.
node.dirty = false;
node.lastCleanEpoch = epoch;
}Що повторить процес для залежностей B. Таким чином він досягне вузла A, який на цьому етапі призведе до оцінки гілки D->B->A. Але оскільки D також залежить від виробника C, він переоцінить його перед обчисленням D. Таким чином, немає проблеми подвійного обчислення для D.
Однак іноді вам може знадобитися нетерпеливо повідомити певних споживачів. Як ви вже здогадалися, вони відомі як «живі» споживачі. У цьому випадку сповіщення про зміну поширюється через графік, щойно оновлюється значення виробника, повідомляючи живих споживачів, які залежать від виробника.
Деякі з цих споживачів можуть бути похідними значеннями і, таким чином, також виробниками, які роблять недійсними свої кешовані значення, а потім продовжують розповсюдження сповіщення про зміну своїм власним живим споживачам тощо. Зрештою це сповіщення досягає ефектів, які самостійно планують повторне виконання.
Важливо те, що під час цієї фази не запускаються побічні ефекти та не виконується повторне обчислення проміжних або похідних значень, лише анулювання кешованих значень. Це дозволяє сповіщенню про зміну охоплювати всі зачеплені вузли на графіку без можливості спостерігати за проміжними чи глюковими станами.
За потреби, після завершення цього поширення змін (синхронно), цей етап може супроводжуватися ледачою оцінкою, яку ми розглянули вище.
Щоб побачити цей етап сповіщення в дії, давайте додамо живого споживача, напр. спостерігач, до нашої установки. Коли a оновлюється, оновлення поширюється на залежних живих споживачів:
import { computed, signal } from '@angular/core';
import { createWatch } from '@angular/core/primitives/signals';
const a = signal(0);
const b = computed(() => a() + 'b');
const c = computed(() => a() + 'c');
const d = computed(() => b() + c() + 'd');
setTimeout(() => a.set(1), 3000);
// watcher will setup a dependency on `d`
const watcher = createWatch(
() => console.log(d()),
() => setTimeout(watcher.run, 1000),
false
);
watcher.notify();Щойно ми оновимо значення для a.set(1), ми побачимо сповіщення про живих споживачів у дії:

Вузли b і c є живими споживачами вузла a, тому під час запуску оновлення для a Angular перейде на node.liveConsumerNode і повідомить ці вузли про зміну.
Але, як згадувалося раніше, насправді тут нічого не відбувається. Вузол просто позначається як брудний і поширює сповіщення своїм живим споживачам через producerNotifyConsumers:
function consumerMarkDirty(node) {
node.dirty = true;
producerNotifyConsumers(node);
node.consumerMarkedDirty?.(node);
}Усе це зводиться до спостерігача (ефекту), який залежить від d. На відміну від звичайних реактивних вузлів, вузол спостереження реалізує планування у своєму методі consumerMarkedDirty:
const WATCH_NODE: Partial<WatchNode> = (() => {
return {
...REACTIVE_NODE,
consumerIsAlwaysLive: true,
consumerAllowSignalWrites: false,
consumerMarkedDirty: (node: WatchNode) => {
if (node.schedule !== null) {
node.schedule(node.ref);
}
},
hasRun: false,
cleanupFn: NOOP_CLEANUP_FN,
};
})();І тут фаза повідомлення та обхід графа припиняється.
Цей двоступеневий процес іноді називають алгоритмом «виштовхувати/витягувати»: «бруд» охоче проштовхується через графік, коли вихідний сигнал змінюється, але перерахунок виконується ліниво, лише коли значення витягуються шляхом зчитування їхніх сигналів.
Виявлення змін
Щоб інтегрувати сповіщення на основі сигналів у процес виявлення змін, Angular покладається на механізм живих споживачів. Шаблони компонентів компілюються у вирази шаблонів (код JS) і виконуються в реактивному контексті подання цього компонента. У таких контекстах виконання сигналу поверне значення, але також зареєструє сигнал як залежність перегляду компонента.
Реактивний споживач представлення компонентів активний. Коли сигнал, зчитаний його шаблоном, змінюється, Angular позначає цього споживача брудним і позначає перегляди предків для обходу. Обхід предка для досягнення брудного нащадка не обов’язково вимагає оновлення власного шаблону цього предка.
Як ви, можливо, вже знаєте з інших моїх статей, шаблон кожного компонента всередині представлено як об’єкт LView. Ось як це виглядає для компонента:
@Component({...})
export class AppComponent {
value = signal(0);
}після компіляції це виглядає як звичайна функція JS AppComponent_Template, яка виконується під час виявлення змін для цього компонента:
this.ɵcmp = defineComponent({
type: AppComponent,
...
template: function AppComponent_Template(rf, ctx) {
if (rf & 1) {
ɵɵtext(0);
}
if (rf & 2) {
ɵɵtextInterpolate1("", ctx.value(), "\n");
}
},
});Коли Angular додав сигнали до своєї реалізації виявлення змін, він загорнув усі представлення компонентів (функція шаблону) у вузол ReactiveLViewConsumer:
export interface ReactiveLViewConsumer extends ReactiveNode {
lView: LView | null;
}Інтерфейс реалізований вузлом REACTIVE_LVIEW_CONSUMER_NODE:
const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = {
...REACTIVE_NODE,
consumerIsAlwaysLive: true,
consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
markAncestorsForTraversal(node.lView!);
},
consumerOnSignalRead(this: ReactiveLViewConsumer): void {
this.lView![REACTIVE_TEMPLATE_CONSUMER] = this;
},
};Ви можете подумати про цей процес як про те, що кожне представлення отримує власний ReactiveLViewConsumer вузол споживач, який визначає реактивний контекст для всіх сигналів, доступ до яких здійснюється всередині функції шаблону.
У нашому випадку, щоразу, коли функція шаблону виконується як частина виявлення змін, вона виконає виробника ctx.value() у контексті вузла функції шаблону, який є ActiveConsumer:

Це призведе до того, що вузол виразу шаблону (споживач) буде додано як живу залежність до виробника value():

Ця залежність гарантує, що як тільки значення виробника value зміниться, воно негайно повідомить вузол споживача (вираз шаблону).
Живі споживачі забезпечують зворотний виклик consumerMarkedDirty, який синхронно викликається виробником, коли його значення змінюється:
/**
* Propagate a dirty notification to live consumers of this producer.
*/
function producerNotifyConsumers(node: ReactiveNode): void {
...
try {
for (const consumer of node.liveConsumerNode) {
if (!consumer.dirty) {
consumerMarkDirty(consumer);
}
}
} finally {
inNotificationPhase = prev;
}
}
function consumerMarkDirty(node: ReactiveNode): void {
node.dirty = true;
producerNotifyConsumers(node);
node.consumerMarkedDirty?.(node);
}Всередині consumerMarkedDirty markAncestorsForTraversal встановлює HasChildViewsToRefresh на предків. Це запитує перехід до ураженого представлення та відрізняється від позначення кожного шаблону предка для оновлення:
const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = {
...
consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
markAncestorsForTraversal(node.lView!);
},
};
function markAncestorsForTraversal(lView: LView) {
let parent = getLViewParent(lView);
while (parent !== null) {
...
parent[FLAGS] |= LViewFlags.HasChildViewsToRefresh;
parent = getLViewParent(parent);
}
}Останнє запитання: коли Angular встановлює поточний вузол споживача LView як ActiveConsumer? Усе це відбувається всередині функції refreshView, яку ви, можливо, вже знаєте з моїх попередніх статей.
Ця функція запускає виявлення змін для кожного LView і запускає загальні операції виявлення змін: виконання функції шаблону, виконання перехоплювачів, оновлення запитів і встановлення зв’язків з хостом. По суті, до того, як Angular запустить усі ці операції, додано цілий фрагмент коду для обробки реактивності.
Ось як це виглядає:
function refreshView<T>(tView, lView, templateFn, context) {
...
// Start component reactive context
enterView(lView);
let returnConsumerToPool = true;
let prevConsumer: ReactiveNode | null = null;
let currentConsumer: ReactiveLViewConsumer | null = null;
if (!isInCheckNoChangesPass) {
if (viewShouldHaveReactiveConsumer(tView)) {
currentConsumer = getOrBorrowReactiveLViewConsumer(lView);
prevConsumer = consumerBeforeComputation(currentConsumer);
} else {... }
...
try {
...
if (templateFn !== null) {
executeTemplate(tView, lView, templateFn, RenderFlags.Update, context);
}
}Оскільки цей код виконується до того, як Angular запускає функцію шаблону компонента в коді executeTemplate, коли виконуються функції доступу до сигналів, які використовуються в шаблоні компонента, уже існує реактивне налаштування контексту.
Ефекти та спостерігачі
Ефект — це спеціалізований інструмент, який призначений для виконання побічних операцій на основі стану програми. Ефекти — це живі споживачі, визначені за допомогою зворотного виклику, який виконується в реактивному контексті. Сигнальні залежності цієї функції фіксуються, і ефект повідомляється щоразу, коли будь-яка з її залежностей дає нове значення.
Ефекти рідко потрібні в коді більшості програм, але можуть бути корисними в певних обставинах. Ось кілька прикладів використання, запропонованих у документах Angular:
- Реєстрація даних або їх синхронізація з
window.localStorage - Додавання спеціальної поведінки DOM, яка не може бути виражена за допомогою синтаксису шаблону, напр. виконання спеціального рендерингу для елемента
<canvas>
Angular не використовує ефекти в механізмі виявлення змін для запуску оновлення інтерфейсу користувача компонента. Як пояснюється в розділі про виявлення змін, для цієї функції він покладається на механізм живих споживачів.
Пропозиція сигналів спрямована на стандартизацію основних реактивних примітивів; це не прийнятий стандарт ECMAScript. Він залишає планування ефектів для фреймворків, де його можна інтегрувати з рендерингом та іншою роботою програми.
Пропозиція включає Signal.subtle.Watcher для інтеграції фреймворку. Angular має власний API низького рівня createWatch. Вони виконують споріднені ролі, але є різними інтерфейсами; у наведених нижче прикладах використовується API Angular.
В Angular effect є оболонкою над watcher. Спочатку давайте дослідимо, як працюють спостерігачі, і ми побачимо, як вони використовуються для створення примітиву effect.
Спочатку ми імпортуємо watcher з примітивів Angular і використаємо його для реалізації механізму сповіщень:
import { signal } from '@angular/core';
import { createWatch } from '@angular/core/primitives/signals';
const counter = signal(0);
const watcher = createWatch(
// run the user provided callback and set up tracking
// this will be executed 2 times
// 1st after `watcher.notify()` and 2nd time after `counter.set(1)`
() => counter(),
// this is called by the `notify` method
// or by the consumer itself through through consumerMarkDirty method,
// schedules the user provided callback to run in 1000ms
() => setTimeout(watcher.run, 1000),
false
);
// mark the watcher as dirty (stale) to force the user provided callback
// to run and set up tracking for the `counter` signal
// `notify` method will call `consumerMarkDirty` under the hood
watcher.notify();
// when the value changes, consumerMarkDirty is executed
// which schedules the user provided callback to run
setTimeout(() => this.counter.set(1), 3000);Коли ми запускаємо watcher.notify(), Angular синхронно викликає метод consumerMarkDirty на вузлі спостереження. Однак зворотний виклик сповіщення, визначений користувачем, не виконується одразу після сповіщення. Замість цього планується, що він пройде через watcher.run деякий час у майбутньому. watch просто викличе цю операцію планування, коли отримає сповіщення «markDirty».
Тут ви можете побачити в дії:

Коли ми запускаємо counter.set(1), той самий ланцюжок викликів призводить до планування зворотного виклику, наданого користувачем.
Щоб створити функцію effect(), Angular загортає спостерігач у клас EffectHandle:
export function effect(effectFn,options): EffectRef {
const handle = new EffectHandle();
...
return handle;
}
class EffectHandle implements EffectRef, SchedulableEffect {
unregisterOnDestroy: (() => void) | undefined;
readonly watcher: Watch;
constructor(...) {
this.watcher = createWatch(
(onCleanup) => this.runEffect(onCleanup),
() => this.schedule(),
allowSignalWrites,
);
this.unregisterOnDestroy = destroyRef?.onDestroy(() => this.destroy());
}Ви бачите, що спостерігач налаштовано в класі EffectHandle. У нашому прикладі вище, де ми раніше використовували спостерігачі, використання функції effect значно спростить налаштування:
import { Component, effect, signal } from '@angular/core';
@Component({...})
export class AppComponent {
counter = signal(0);
constructor() {
this.counter = signal(0);
// this will be executed 2 times
effect(() => this.counter());
setTimeout(() => this.counter.set(1), 3000);
}
}Коли ми використовуємо функцію effect безпосередньо, ми передаємо лише один зворотний виклик. Це визначений користувачем зворотний виклик, який встановлює залежності та запланований для запуску Angular, коли залежності оновлюються.
Версія джерела, розглянута тут, використовує ZoneAwareEffectScheduler, який ставить мікрозавдання в чергу для очищення запланованих ефектів. Наведені нижче фрагменти описують цю реалізацію, а не поточний контракт планування для всіх ефектів Angular:
export class ZoneAwareEffectScheduler implements EffectScheduler {
private queuedEffectCount = 0;
private queues = new Map<Zone | null, Set<SchedulableEffect>>();
private readonly pendingTasks = inject(PendingTasks);
private taskId: number | null = null;
scheduleEffect(handle: SchedulableEffect): void {
this.enqueue(handle);
if (this.taskId === null) {
const taskId = (this.taskId = this.pendingTasks.add());
queueMicrotask(() => {
this.flush();
this.pendingTasks.remove(taskId);
this.taskId = null;
});
}
}Є одна цікава примха, яку Angular повинен реалізувати, щоб «ініціалізувати» ефект. Як ми побачили у реалізації з watcher, нам потрібно розпочати відстеження, вручну викликавши watcher.notify() один раз. Angular також має зробити це, і робить це як частину першого запуску виявлення змін.
Ось як це реалізовано.
Коли ви виконуєте функцію effect в контексті ін’єкції компонента, Angular додасть зворотний виклик сповіщень до об’єкта представлення LView[EFFECTS_TO_SCHEDULE] компонента:
export function effect(
effectFn: (onCleanup: EffectCleanupRegisterFn) => void,
options?: CreateEffectOptions,
): EffectRef {
...
const handle = new EffectHandle();
// Effects need to be marked dirty manually to trigger their initial run. The timing of this
// marking matters, because the effects may read signals that track component inputs, which are
// only available after those components have had their first update pass.
// ...
const cdr = injector.get(ChangeDetectorRef, null, {optional: true}) as ViewRef<unknown> | null;
if (!cdr || !(cdr._lView[FLAGS] & LViewFlags.FirstLViewPass)) {
// This effect is either not running in a view injector, or the view has already
// undergone its first change detection pass, which is necessary for any required inputs to be
// set.
handle.watcher.notify();
} else {
// Delay the initialization of the effect until the view is fully initialized.
(cdr._lView[EFFECTS_TO_SCHEDULE] ??= []).push(handle.watcher.notify);
}
return handle;
}Додані таким чином функції сповіщення буде виконано один раз під час першого запуску виявлення змін для перегляду цього компонента всередині функції refreshView:
export function refreshView<T>(tView,lView,templateFn,context) {
...
// Schedule any effects that are waiting on the update pass of this view.
if (lView[EFFECTS_TO_SCHEDULE]) {
for (const notifyEffect of lView[EFFECTS_TO_SCHEDULE]) {
notifyEffect();
}
// Once they've been run, we can drop the array.
lView[EFFECTS_TO_SCHEDULE] = null;
}
}Виклик notifyEffect ініціює зворотний виклик сповіщень consumerMarkDirty основного спостерігача, який, у свою чергу, запланує виконання ефекту (наданого користувачем зворотного виклику) за допомогою існуючого планувальника (після виявлення змін):

І це вся історія.