Як працюють Angular Signals під капотом

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

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

Джерела: medium, divotion

👍ПодобаєтьсяСподобалось6
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

В подібних статтях бажано на початку пояснити в загальних термінах що собою являє предмет статті. Яку проблему вирішують ці сигнали і чому їх використання має сенс.

Дякую що підсвітили, ось посилання на попередню статтю, яка розказує що таке сигнали — dou.ua/forums/topic/50927

Зараз працюють над матеріалом, де буде розповідатись більш детально про — де, як і чому краще їх використовувати 🤓

Дякуємо за коментар, врахуємо на майбутнє.

Підписатись на коментарі