Як працюють 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. Дослідили, як вони працюють під капотом і створюють залежності між вузлами. Ми побачили, як обробляються зміни і підтримується актуальність даних через зв’язки між сигналами. Сподіваюся, матеріал допоміг краще зрозуміти внутрішню механіку цієї концепції.
3 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів