Як працюють Angular Signals під капотом. Частина 2

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

Це продовження серії «Як працюють Signal під капотом».

Як я згадував наприкінці попереднього тексту, я збираюся розглянути ще кілька складніших прикладів і хочу відповісти на такі запитання:

  • Як граф реактивності підтримує свою узгодженість автоматично і чому він настільки швидкий?
  • Як система реактивності уникає зайвих непотрібних обчислень?

Давайте розбиратися!

Як система реактивності уникає зайвих обчислень

const text = signal('xyz');
const containsA = computed(() => text().includes('a'));
const message = computed(() => containsA() ? 'has a' : 'no a');
console.log(message()); // виведе: no a
text.set('xyz1');
console.log(message()); // виведе: no a

Ви бачите, що після того, як ми змінюємо text на 'xyz1', результат у message() не змінюється — він залишається 'no a'. Чому так?

Все дуже просто: коли ми оновлюємо text за допомогою text.set('xyz1'), система реактивності позначає containsA та message як потенційно застарілі/брудні, тому що вони залежать від text. Це перший крок.

Але потім вона перевіряє containsA(). Хоча text змінився, 'a' все ще немає у новому значенні ('xyz1'), тому containsA() продовжує повертати false.

Тепер найцікавіше: чи буде обчислюватися message() знову? Ні!

Система знає, що значення containsA() не змінилося (воно як було false, так і залишилося). Тому, щоб не робити зайвої роботи, вона не перераховує message(), адже його результат точно буде таким самим.

Це дозволяє уникнути непотрібних обчислень і робить систему дуже ефективною!

Давайте трохи змінимо нашcomputed:

const message = computed(() => {
  console.log('in computed');
  return containsA() ? 'has a' : 'no a'
});

Коли запускається шостий рядок коду, ви не бачите повідомлення in computed. Чому так відбувається?

Уся справа в тому, як система реактивності «запам’ятовує» попередні значення. Вона має спеціальний механізм, який допомагає їй зрозуміти: чи змінилися дані, від яких залежить поточний елемент, і чи потрібно його обчислювати заново, чи ні. Це дозволяє уникнути зайвої роботи!

Давайте перевіримо поточний стан графу реактивності після 3-го рядка:

SIGNAL_NODE (text):
name: text
value: 'xyz'
version: 0

COMPUTED_NODE (containsA):
name: containsA
value: UNSET
version: 0
producerNode: []
producerLastReadVersion: []
computation: Fn // Посилання на функцію обчислення

COMPUTED_NODE (message):
name: message
value: UNSET
version: 0
producerNode: []
producerLastReadVersion: []
computation: Fn // Посилання на функцію обчислення

Що відбувається, коли ми вперше викликаємо наш message

При першому виклику message() система:

  • Обчислює значення message (а перед цим — containsA).
  • Створює зв’язки, тобто «запам’ятовує», що message залежить від containsA, а containsA від text.

Це допомагає системі знати, що і коли обчислювати.

const computed = () => {
    //   -> СПОЧАТКУ: Система перевіряє, чи не застаріло значення цього обчислюваного сигналу.
    //      Якщо так, вона ПЕРЕОБЧИСЛЮЄ його ПРЯМО ЗАРАЗ, щоб отримати найсвіжіший результат.
    //      Це допомагає уникнути зайвих обчислень, якщо залежності насправді не змінилися;
    producerUpdateValueVersion(node);
    //   -> ПОТІМ: Система ЗАПИСУЄ, що хтось (можливо, інший computed або ефект)
    //      тільки що "прочитав" це значення. Це потрібно, щоб створити зв'язок:
    //      якщо значення цього computed колись зміниться, то ті, хто його "читав",
    //      будуть знати, що їм теж, можливо, потрібно оновитися.
    producerAccessed(node);
    if (node.value === ERRORED) {
      throw node.error;
    }
    // > ПОВЕРНЕННЯ ЗНАЧЕННЯ
    return node.value;
};

Таким чином, при першому обчисленні система не тільки отримує результат, а й будує карту залежностей (textcontainsAmessage), щоб знати, що і коли оновлювати в майбутньому.

Ось як виглядає реактивний Граф після першого виклику message():

SIGNAL_NODE: {
  name: "text",
  value: "xyz",
  version: 0
}

COMPUTED_NODE: {
  name: "containsA",
  value: false,
  version: 1,
  producerNode: [
    { name: "text" }
  ],
  producerLastReadVersion: [0], // `containsA` бачив `text` з версією 0
  computation: Fn // Посилання на функцію обчислення
}

COMPUTED_NODE: {
  name: "message",
  value: "no a",
  version: 1,
  producerNode: [
    { name: "containsA" }
  ],
  producerLastReadVersion: [1], // `message` бачив `containsA` з версією 1
  computation: Fn // Посилання на функцію обчислення
}

Далі на 5-му рядку коду ми робимо text.set('xyz1'). Це означає, що:

  • Значення сигналу text оновлюється на 'xyz1'.
  • Його внутрішня версія також змінюється.
  • Але оскільки зараз ніхто активно не «користується» цим сигналом (немає «живих» споживачів), його зміна позначає залежні сигнали (containsA, message) як потенційно застарілі/брудні, але вони ще не переобчислюються негайно, оскільки їхнє значення наразі не запитується.

Ось як виглядає реактивний граф після цієї дії:

SIGNAL_NODE: {
  name: "text",
  value: "xyz1",
  version: 1
}

COMPUTED_NODE: {
  name: "containsA",
  value: false,
  version: 1,
  producerNode: [
    { name: "text" }
  ],
  producerLastReadVersion: [0], // `containsA` все ще пам'ятає `text` з версією 0
  computation: Fn // Посилання на функцію обчислення
}

COMPUTED_NODE: {
  name: "message",
  value: "no a",
  version: 1,
  producerNode: [
    { name: "containsA" }
  ],
  producerLastReadVersion: [1], // `message` все ще пам'ятає `containsA` з версією 1
  computation: Fn // Посилання на функцію обчислення
}

На 6-му рядку ми знову бачимо виклик message(). Здавалося б, message має переобчислитися, адже text змінився (‘xyz’ на ‘xyz1’).

Але пам’ятаєте, що messageне виводить in computed (тобто, його функція не виконується знову)? Це відбувається тому, що хоча text змінився, значення containsA()(яке message використовує) залишилося таким же (false), бо ‘xyz1’ все ще не містить літери ‘a’.

Як система це розуміє?

Коли ми знову викликаємо message(), система «заходить» всередину message і запускає свою перевірку (функцію producerUpdateValueVersion). Саме в ній система приймає розумне рішення: вона бачить, що хоча text змінився, але containsA (основна залежність message) не змінило свого значення.

Тому щоб не робити зайвої роботи, система не запускає повний перерахунок функції. Вона просто повертає старе, але все ще актуальне значення.

Ось як це працює:

function producerUpdateValueVersion(node) {
    // Якщо вузол НЕ позначений як "брудний" (потребує перерахунку) АБО
    // якщо фактичні ЗНАЧЕННЯ його залежностей НЕ змінилися (навіть якщо їх версії змінилися):
    if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) {
        // Тоді ми тут! Це означає, що перерахунок НЕ ПОТРІБЕН.
        node.dirty = false; // Позначаємо вузол як "чистий" (актуальний)
        return; // Виходимо з функції, повертаючи поточне (актуальне) значення без зайвої роботи.
    }
    // Якщо ми дійшли сюди, значить, хоча б одна з умов вище була хибною,
    // і ПЕРЕРАХУНОК ДІЙСНО ПОТРІБЕН.
    node.producerRecomputeValue(node); // Запускаємо фактичну функцію обчислення вузла, щоб отримати нове значення.
    // Після того, як значення було перераховано, вузол більше не "брудний".
    node.dirty = false; // Позначаємо вузол як "чистий" (актуальний).
}

Коли ми викликаємо message() вдруге (після зміни text на 'xyz1'):

  • Система спочатку перевіряє message за допомогою producerMustRecompute. Цей метод каже: «Ні, message зараз не обчислюється і не є повністю порожнім», тому ми переходимо до наступної перевірки.
  • Тепер запускається функція consumerPollProducersForChange. Її мета — перевірити, чи ДІЙСНО змінилися значення тих сигналів, від яких залежить message (у нашому випадку це containsA).
function consumerPollProducersForChange(node) {
    // Перевіряємо всіх producer (залежності) поточного вузла (`node`, зараз це `message`).
    for (let i = 0; i < node.producerNode.length; i++) {
        const producer = node.producerNode[i];           // Поточний "producer" (наприклад, `containsA`).
        const seenVersion = node.producerLastReadVersion[i]; // Версія producer, яку `node` бачив востаннє (для `containsA` це 1).
        // 1. Швидка перевірка: Чи відрізняється поточна версія producer від останньої відомої?
        if (seenVersion !== producer.version) {
            // Якщо так, значить producer змінився, і споживач потребує перерахунку.
            // У нашому випадку (`message` перевіряє `containsA`), 1 !== 1 є FALSE, тому цей блок пропускається.
            return true;
        }
        // 2. Якщо версія не змінилася, просимо producer оновити себе (рекурсивно).
        // Тут `producerUpdateValueVersion` викликається для `containsA`.
        // `containsA` перерахується, але його ЗНАЧЕННЯ (`false`) і ВЕРСІЯ (1) залишаться незмінними,
        // оскільки його залежність (`text`) змінилася, але не вплинула на його логічний результат.
        producerUpdateValueVersion(producer);
        // 3. Повторна перевірка: Чи змінилася версія виробника ПІСЛЯ його оновлення?
        if (seenVersion !== producer.version) {
            // Якщо так, значить його значення дійсно змінилося, і споживач потребує перерахунку.
            // У нашому випадку, 1 !== 1 все ще FALSE, тому цей блок пропускається.
            return true;
        }
    }
    // Якщо жодна залежність не змінила своєї версії (тобто свого фактичного значення),
    // то (message) не потребує перерахунку.
    return false;
}

Оскільки сигнал containsA був переобчислений, але його версія, як ми вже з’ясували, залишилася незмінною, у функції consumerPollProducersForChange відбувається ще одне, останнє порівняння версій. Ця перевірка знову показує, що версії збігаються.

SIGNAL_NODE: {
  name: "text",
  value: "xyz1",
  version: 1
}

COMPUTED_NODE: {
  name: "containsA",
  value: false,
  version: 1,
  producerNode: [
    { name: "text" }
  ],
  producerLastReadVersion: [1], // `containsA` тепер бачив `text` з версією 1
  computation: Fn // Посилання на функцію обчислення
}

COMPUTED_NODE: {
  name: "message",
  value: "no a",
  version: 1,
  producerNode: [
    { name: "containsA" }
  ],
  producerLastReadVersion: [1], // `message` все ще бачить `containsA` з версією 1
  computation: Fn // Посилання на функцію обчислення
}

Саме тому так важливо порівнювати значення до і після обчислення. Реактивна система побудована так, щоб:

  • Виконувати тільки потрібні перерахунки: containsA переобчислився, бо text дійсно змінив свою версію.
  • message не переобчислювався, бо версія containsA була такою ж, і система це підтвердила.

Завдяки цим властивостям реактивна система Angular працює надзвичайно швидко.

Також я ще пишу у своєму Medium, тому буду радий, якщо підпишетесь.

👍ПодобаєтьсяСподобалось3
До обраногоВ обраному1
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

А про механізм оновлення DOM на сигналах ти розкажеш? )

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