Як працюють Angular Signals під капотом
Привіт! Мене звати Євген Русаков, я займаюся розробкою Front-end рішень у компанії Сільпо. Наразі наша команда поступово переходить на використання сигналів, і я вирішив глибше розібратися, як вони працюють «під капотом». Окей, погнали!
Концепція
Спочатку команда Angular вирішила додати реактивність на рівні примітивів. Як я вже розповідав раніше, сигнали можуть залежати один від одного: коли змінюється значення, потрібно автоматично сповіщати всі елементи, що залежать від нього. Для цього потрібна система, яка автоматично створює необхідні залежності і видаляє зайві.
Абстракція
Якщо заглянути у вихідний код сигналів, які були на початку, можна побачити, що основними абстракціями були класи ReactiveNode та ReactiveEdge.
ReactiveNode використовувався для реалізації сутностей ComputedImpl, WritableSignalImpl і ще деяких. Він також містив логіку для створення та видалення зв’язків між нодами, що дає змогу підтримувати актуальність графа.
abstract class ReactiveNode {
protected consumers: Map<string, ReactiveEdge>;
protected producers: Map<string, ReactiveEdge>;
protected trackingVersion: number;
protected valueVersion: number;
}
Як можна здогадатися з назв WritableSignalImpl та ComputedImpl, перший створюється при виклику функції signal(), а другий — під час створення обчислюваного сигналу за допомогою computed.
class WritableSignalImpl<T> extends ReactiveNode {
private value: T;
private equal: IValueEqualityFn<T>;
}
class ComputedImpl<T> extends ReactiveNode {
private value: T;
private stale: boolean;
private equal: IValueEqualityFn<T>;
}
Що стосується ReactiveEdge, то ця абстракція містить слабкі посилання (WeakRef) на ноди, які він поєднує.
interface ReactiveEdge {
readonly producerNode: WeakRef<IReactiveNode>;
readonly consumerNode: WeakRef<IReactiveNode>;
atTrackingVersion: number;
seenValueVersion: number;
}
Об’єкт WeakRef дозволяє створити посилання на інший об’єкт, не перешкоджаючи його видаленню збирачем сміття. Це означає, що якщо на об’єкт більше немає посилань, він може бути знищений, навіть якщо є посилання через WeakRef.
Звучить чудово, але є нюанс. Команда Angular переписала більшу частину коду signal, і тепер вони працюють інакше.
Як працюють сигнали зараз
Тепер існують живі та неживі сигнали. У цій статті я зосереджуся лише на неживих.
Раніше це працювало так: коли сигнал змінювався, він повідомляв своїх спостерігачів і позначав їх як застарілі. Потім, коли хтось звертався до computed значення, і якщо воно було застарілим, його перераховували.
Тепер коли сигнал змінюється, він просто оновлює свою версію та значення. А коли хтось читає computed значення, перевіряється, чи були зміни, і тільки за потреби значення обчислюється наново.
Окей, погнали розбиратися...
Спершу створимо сигнал із початковим значенням:
const counter = signal(1);
Після цього «під капотом» формується SIGNAL_NODE, і граф виглядає ось так:
SIGNAL_NODE counter value: 1, version: 0
Коли ми викликаємо метод signal, він повертає WritableSignal із набором методів для керування станом.
export function signal<T>(initialValue: T, options?: CreateSignalOptions<T>): WritableSignal<T> {
const node: SignalNode<T> = Object.create(SIGNAL_NODE);
node.value = initialValue;
options?.equal && (node.equal = options.equal);
function signalFn() {
producerAccessed(node);
return node.value;
}
signalFn.set = signalSetFn;
signalFn.update = signalUpdateFn;
signalFn.mutate = signalMutateFn;
signalFn.asReadonly = signalAsReadonlyFn;
(signalFn as any)[SIGNAL] = node;
return signalFn as WritableSignal<T>;
}
Якщо подивитись на файл signal.ts, він містить різні методи, серед яких є signalSetFn. Розглянемо, що саме робить цей метод:
function signalSetFn<T>(this: SignalFn<T>, newValue: T) {
const node = this[SIGNAL];
if (!producerUpdatesAllowed()) {
throwInvalidWriteToSignalError();
}
if (!node.equal(node.value, newValue)) {
node.value = newValue;
signalValueChanged(node);
}
}
Коли значення сигналу змінюється, і нове не збігається з поточним, оновлюються значення та версія сигналу через signalValueChanged (нижче ми подивимось на неї детальніше). Якщо є активні споживачі, вони отримують сповіщення про зміни.
Повернемося до нашого прикладу. Виконаємо ще кілька викликів методу set:
counter.set(2); counter.set(3);
Наш граф виглядатиме наступним чином:
SIGNAL_NODE counter value: 3, version: 2
Наче все просто і зрозуміло. А тепер розглянемо приклад, коли один сигнал залежить від іншого.
const counter = signal(1); const doubleSignal = computed(() => counter() * 2);
Тут ми вже використовуємо метод computed, який створює COMPUTED_NODE, але значення не обчислюється одразу, doubleSignal зараз має значення Symbol(UNSET) .
Його граф виглядає так:
COMPUTED_NODE doubleSignal value: UNSET version: 0 producerNode: [] producerLastReadVersion: []
Найцікавіше починається, коли ми звертаємось до нашого doubleSignal:
console.log(doubleSignal()); // 1 * 2 = 2
Нас цікавить метод producerUpdateValueVersion:
function producerUpdateValueVersion(node: ReactiveNode): void {
...
if (!node.producerMustRecompute(node)) {
node.dirty = false;
return;
}
node.producerRecomputeValue(node);
...
}
Тут йде перевірка, чи потрібно перерахувати значення, викликаючи метод producerMustRecompute:
function producerMustRecompute(node: ComputedNode<unknown>): boolean {
return node.value === UNSET || node.value === COMPUTING;
}
Як ми бачимо, перша частина виразу вказує на необхідність примусового перерахунку, якщо поточне значення не встановлено.
Також варто звернути увагу на producerRecomputeValue, який викликається у ноди раніше, в методі producerUpdateValueVersion :
function producerRecomputeValue(node: ComputedNode<unknown>): void {
// Перевірка на циклічність
if (node.value === COMPUTING) {
throw new Error('Detected cycle in computations.');
}
const oldValue = node.value;
// Позначаємо значення як обчислюване
node.value = COMPUTING;
const prevConsumer = consumerBeforeComputation(node);
let newValue: unknown;
try {
// Виконання обчислення значення
newValue = node.computation();
} catch (err) {
newValue = ERRORED;
node.error = err;
} finally {
consumerAfterComputation(node, prevConsumer);
}
...
// Оновлюємо значення та версію.
node.value = newValue;
node.version++;
}
На цей момент граф нашого doubleSignal виглядає так:
COMPUTED_NODE doubleSignal value: COMPUTING ...
На етапі consumerBeforeComputation наш сигнал готується до обчислення...
Спочатку індекс скидається до нуля для початку побудови залежностей, потім викликається setActiveConsumer, який встановлює поточну ноду як активну:
function consumerBeforeComputation(node: ReactiveNode|null): ReactiveNode|null {
node && (node.nextProducerIndex = 0);
return setActiveConsumer(node);
}
Тепер давайте повернемось до нашого producerRecomputeValue в місце, де робиться try/catch:
try {
// Виконання обчислення значення
newValue = node.computation();
} catch (err) {...}
Нас цікавить виклик node.computation() — тут виконується обчислення того, що ми передали раніше () => counter() * 2 у doubleSignal.
Коли counter() намагається отримати значення, щоб ми могли його подвоїти, виконується функція producerAccessed:
function signal<T>(initialValue: T, options?: CreateSignalOptions<T>): WritableSignal<T> {
...
function signalFn() {
producerAccessed(node);
return node.value;
}
...
}
Спробуємо розібрати цей метод:
function producerAccessed(node: ReactiveNode): void {
...
if (activeConsumer === null) {
return;
}
const idx = activeConsumer.nextProducerIndex++;
assertConsumerNode(activeConsumer);
...
if (activeConsumer.producerNode[idx] !== node) {
activeConsumer.producerNode[idx] = node;
...
}
activeConsumer.producerLastReadVersion[idx] = node.version;
}
Спочатку перевіряється, чи є активний споживач activeConsumer. Якщо його немає, наприклад, коли ми створюємо сигнал так: const text = signal(), і потім просто викликаємо text(), він одразу повертає значення без додаткових дій. У цьому випадку метод producerAccessed не виконується. Але в нашому випадку споживач є — це doubleSignal.
Спочатку зберігається індекс nextProducerIndex перед початком обчислення. Потім перевіряється, чи є потрібна структура у ноди через assertConsumerNode, і якщо її ще немає, вона створюється.
Далі перевіряється (activeConsumer.producerNode[idx] !== node), чи є поточна нода (наш counter) в масиві producerNode. Оскільки для першого запуску масив порожній, нода додається в цей масив.
Потім зберігається поточна версія нашого counter в масив producerLastReadVersion для doubleSignal, щоб використовувати її при наступних перевірках.
Тепер на цьому етапі створюється зв’язок між двома нодами, і значення counter передається у функцію обчислення, щоб вираз 1 * 2 дав результат 2. Це значення повертається як результат обчислення.
Оновлена структура графа виглядає так:
SIGNAL_NODE
counter
value: 1
version: 0
COMPUTED_NODE
doubleSignal
value: COMPUTING
version: 0
producerNode: [{counter}]
producerLastReadVersion: [0]
Наша наступна зупинка — finally блок у producerRecomputeValue:
...
finally {
consumerAfterComputation(node, prevConsumer);
}
Глянемо, що вона робить:
function consumerAfterComputation(
node: ReactiveNode|null, prevConsumer: ReactiveNode|null): void {
setActiveConsumer(prevConsumer);
...
for (let i = node.nextProducerIndex; i < node.producerNode.length; i++) {
node.producerNode.pop();
node.producerLastReadVersion.pop();
node.producerIndexOfThis.pop();
}
}
Ця функція встановлює переднього споживача, що дозволяє подальші зміни стану або реагування на зміни в залежностях, і очищає дані ноди. Остання частина важлива для випадків, коли сигнал залежить від кількох інших.
Наприклад:
computed(() => isAvailable() ? getPrice() + getDiscount() : getAlternativePrice())
Тут залежності можуть бути на getPrice() та getDiscount(), або ж на getAlternativePrice(). Залежно від значення isAvailable. Коли значення змінюється — видаляються зайві залежності.
Отже, зв’язки між сигналами є ключовою функцією в їх роботі, дозволяючи не тільки надсилати сповіщення про зміни, а й швидко їх перевіряти. Це відбувається надзвичайно швидко, навіть для великих об’єктів з багатьма властивостями та глибокою вкладеністю.
Пам’ятаєте наш приклад з doubleSignal?
const counter = signal(1); const doubleSignal = computed(() => counter() * 2); console.log(doubleSignal());
Граф матиме такий вигляд:
SIGNAL_NODE
counter
value: 1
version: 0
COMPUTED_NODE
doubleSignal
value: 2
version: 1
producerNode: [{counter}]
producerLastReadVersion: [0]
Додамо виклик set і збільшимо значення нашого сигналу на 2:
counter.set(2); console.log(doubleSignal());
Нагадую, що залежність між вузлами вже існує, і при повторному виклику set оновлюються значення value та version. Раніше ми вже розглянули метод signalSetFn, тепер сфокусуємося на тій його частині, яка відповідає за ці оновлення:
function signalSetFn<T>(this: SignalFn<T>, newValue: T) {
const node = this[SIGNAL];
...
if (!node.equal(node.value, newValue)) {
...
signalValueChanged(node);
}
}
Наприкінці метод signalSetFn викликає signalValueChanged, що завершує оновлення стану сигналу. Потім producerNotifyConsumers сповіщає споживачів про зміну їхнього джерела даних. Якщо споживачів немає, функція припиняє виконання:
function signalValueChanged(node) {
node.version++;
producerNotifyConsumers(node);
postSignalSetFn?.();
}
Дивимось граф станом на зараз, щоб зрозуміти, що маємо:
SIGNAL_NODE
counter
value: 2
version: 1
COMPUTED_NODE
doubleSignal
value: 4
version: 2
producerNode: [{counter}]
producerLastReadVersion: [1]
Ми бачимо, що значення producerLastReadVersion у графі залежностей дорівнює 1, оскільки при першому зверненні до doubleSignal версія counter була 0, і ця версія була зафіксована в producerLastReadVersion. В результаті doubleSignal отримує свою версію 1.
Після виклику counter.set(2) оновилися як значення counter, так і його версія, що тепер дорівнює 1. Коли ми вдруге звертаємось до doubleSignal, значення producerLastReadVersion оновлюється відповідно до останньої версії counter, яка дорівнює 1, а сама версія doubleSignal змінюється на 2, оскільки відбулося перерахування значення.
До речі, саме тому перевірка така швидка. Система реактивності порівнює прості числа, а не виконує складні порівняння типу deepEqual або подібні операції:
function consumerPollProducersForChange(node: ReactiveNode): boolean {
assertConsumerNode(node);
for (let i = 0; i < node.producerNode.length; i++) {
const producer = node.producerNode[i];
const seenVersion = node.producerLastReadVersion[i];
// Спочатку перевіряємо версії. Якщо вони не співпадають, це означає, що значення
// змінилося з моменту останнього звернення до нього.
if (seenVersion !== producer.version) {
return true;
}
// Якщо версія producer співпадає з останньою прочитаною, але він сам може бути застарілим,
// ми примусово оновлюємо його значення та версію.
producerUpdateValueVersion(producer);
// Тепер, після оновлення, перевірка версії дасть точну інформацію:
// якщо версії досі співпадають, значення не змінилося з моменту останнього звернення.
if (seenVersion !== producer.version) {
return true;
}
}
// Якщо жоден з producer не змінився, повертаємо false.
return false;
}
Висновок
У цій статті ми заглибилися в архітектуру та реалізацію сигналів Angular. Дослідили, як вони працюють під капотом і створюють залежності між вузлами. Ми побачили, як обробляються зміни і підтримується актуальність даних через зв’язки між сигналами. Сподіваюся, матеріал допоміг краще зрозуміти внутрішню механіку цієї концепції.
7 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів