Як працює реактивність на JavaScript

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

Привіт! Якщо ви працюєте з сучасними JavaScript-фреймворками, такими як SolidJS, Vue чи Angular, ви точно стикалися з терміном «реактивність». Це та сама магія, яка автоматично оновлює ваш інтерфейс користувача, коли змінюються дані. Але як вона працює? В SolidJS і Angular є сигнали для цього, у Vue теж є схожий підхід — через ref і reactive, які виконують роль реактивних обгорток для даних.

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

Що таке реактивність

У контексті програмування реактивність — це підхід, де система автоматично реагує на зміни даних. Замість того, щоб вручну оновлювати DOM або викликати функції щоразу, коли щось змінюється, ми створюємо залежності між даними та «ефектами» (наприклад, оновленням UI або виведенням у консоль). Коли дані змінюються, залежні від них ефекти спрацьовують автоматично.

Основні елементи

Щоб реалізувати базову реактивність, нам знадобляться:

  1. State (стан). Це дані, за якими ми спостерігаємо. Наприклад, рахунок гри або ім’я користувача на сайті. Це значення, які можуть змінюватися.
  2. Трекінг. Потрібно мати механізм для відстеження. Наприклад, якщо функція оновлює текст на екрані, вона має знати, яке значення (наприклад, ім’я користувача) потрібно показати. Якщо ці дані змінюються, ми повинні оновити відповідну частину інтерфейсу.
  3. Реакція на зміни. Коли дані змінюються (наприклад, оновлюється лічильник), має спрацювати механізм оповіщення. Він перевіряє, хто підписаний на ці конкретні дані, і надсилає їм повідомлення: «Значення оновлено!».

Реалізація

Оголошення глобальних змінних

Першим кроком нам треба зробити визначення двох змінних, які будуть використовуватись для зберігання поточного ефекту та залежностей:

  • currentEffect  —  тут ми будемо зберігати функцію ефекту, який наразі виконується.
  • dependencies  —  а це у нас буде структура даних типу Map, що буде зберігати залежності, яка саме властивість стану пов’язана з якими функціями (ефектами).
let currentEffect = null;
const dependencies = new Map();

Уявіть собі це, як список підписок:

Кожна властивість стану (наприклад, count або text) має свій список методів, які потрібно викликати, коли ця властивість змінюється. Ми використовуємо Map, бо це зручний спосіб зберігати пари «властивість → набір функцій».

Тобто, якщо state.count зміниться, ми заглядаємо в dependencies, бачимо, які функції залежать від count, і запускаємо їх.

Це як система сповіщень: «Змінилася властивість count — отже, потрібно виконати ось ці функції».

Так ми точно знаємо, що оновлювати, коли змінюється стан.

Функція для відстеження залежностей

Далі нам буде потрібна функція track яка відповідає за залежності:

  • Якщо немає активного ефекту (currentEffect), нічого не відбувається.
  • Якщо є поточний ефект, ми додаємо його до пулу всіх інших ефектів, що залежать від цієї властивості.
function track(property) {
  if (!currentEffect) return;
  let effects = dependencies.get(property);
  if (!effects) {
    effects = new Set();
    dependencies.set(property, effects);
  }
  effects.add(currentEffect);
}

Тригер змін

Також нам буде потрібен trigger який запускає усі ефекти:

function trigger(property) {
  const effects = dependencies.get(property);
  if (effects) {
    effects.forEach(effect => effect());
  }
}

Він отримує всі ефекти та виконує кожен з них.

Навчаємо дані реагувати на зміни

Тепер створимо функцію createState. Вона бере звичайний об’єкт і робить так, щоб він міг сам реагувати на зміну своїх властивостей. Для цього ми використовуємо Proxy, який перехоплює читання та запис значень, щоби відстежувати залежності й запускати потрібні ефекти.

  • Коли читається, викликаєтьсяtrack для реєстрації залежностей.
  • Коли значення змінюється, викликається trigger для активації залежних ефектів
function createState(initialState) {
  return new Proxy(initialState, {
    get(target, property) {
      track(property);
      return Reflect.get(target, property);
    },
    set(target, property, value) {
      const success = Reflect.set(target, property, value);
      if (success) {
        trigger(property);
      }
      return success;
    }
  });
}

Онови це, коли щось зміниться

Функція createEffect дозволяє створити реактивність. Тобто, коли певне значення зміниться — ця функція автоматично виконається знову.

  1. Ми тимчасово зберігаємо функцію, яку передали в createEffect, у змінну currentEffect. Це потрібно, щоб знати, що саме вона читає.
  2. Потім виконуємо її одразу. Під час виконання, якщо вона звертається до якихось значень зі стану (наприклад, state.count), система запам’ятовує, що ця функція залежить від цих значень.
  3. Після завершення ми очищаємо currentEffect.
function createEffect(effectFn) {
  currentEffect = effectFn;
  effectFn();
  currentEffect = null;
}

Приклад використання:

Створення нашого state:

const state = createState({
  count: 0,
  text: 'Привіт'
});

Створюємо два ефекти, які залежать від нашого state:

createEffect(() => {
  console.log(`Лічильник зараз: ${state.count}`);
});

createEffect(() => {
  console.log(`Текст зараз: ${state.text}`);
});

Коли ми змінюємо значення властивостей в нашому state, відповідні ефекти автоматично спрацьовують:

state.count++; // Виведе: "Лічильник зараз: 1"
state.text = 'Тест!'; // Виведе: "Текст зараз: Тест!"
state.count = 10; // Виведе: "Лічильник зараз: 10"

Погляньмо, як усе працює разом

  • createState — перетворює звичайний об’єкт у «реактивний»: коли ми читаємо або змінюємо його властивості.
  • track — запам’ятовує, яка функція (ефект) використовує які саме дані, щоб потім знати, що потрібно оновити.
  • trigger — запускає потрібні функції щоразу, коли змінюється якась частина даних.
  • createEffect — дозволяє нам сказати: «Запусти ось цю функцію, коли зміняться певні значення».

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

Чудово, тепер нам треба реалізувати базовову функцію рендерингу, щоб побачити роботу реактивності в дії.

Реалізація компонента

Створимо HTML-обгортку:

<body>
  <div id="app"></div>
</body>

Далі отримуємо DOM-елемент, куди будемо рендерити UI:

const container = document.getElementById('app');

Тепер напишимо наш render():

function render() {
  container.innerHTML = `
    <div>
      <h2>${state.text}</h2>
      <p>Лічильник: ${state.count}</p>
      <button data-action="increment">Збільшити</button>
      <button data-action="changeText">Змінити текст</button>
    </div>
  `;
}
  • Ми вставляємо в нього актуальні значення state.text і state.count.

Нам треба буде два ефекти для логування змін наших count та text , та окремий ефект для render() :

createEffect(() => {
  console.log(`Лічильник змінено: count = ${state.count}`);
});

createEffect(() => {
  console.log(`Текст змінено: ${state.text}`);
});

createEffect(() => {
  render()
});
  1. render() малює UI, зчитуючи state.count і state.text.
  2. createEffect(() => render()) запам’ятовує ці залежності.
  3. Якщо змінюється count або text  —  автоматично запускається render().
  4. Водночас інші createEffect логують зміни конкретних полів.

Встановлення слухачів на наш контейнер:

Замість того, щоб додавати окремий слухач подій для кожної кнопки, ми встановлюємо один слухач на контейнер (елемент з id="app").

Це дозволяє обробляти події для всіх дочірніх елементів (в тому числі кнопок), зменшуючи кількість слухачів і покращуючи продуктивність.

const container = document.getElementById('app');

// Встановлюємо делегування подій один раз на контейнер
container.addEventListener('click', (event) => {
  const target = event.target;

  if (target.tagName === 'BUTTON') {
    const action = target.dataset.action;

    if (action === 'increment') {
      state.count++;
    } else if (action === 'changeText') {
      state.text = state.text === 'Привіт 👋' ? 'Як справи?' : 'Привіт 👋';
    }
  }
});

При натисканні:

  • increment → збільшує лічильник на 1;
  • changeText → перемикає текст між «Привіт 👋» та «Як справи?».

Після виклику CounterComponent() все починає працювати!

// Компонент
    function CounterComponent() {
      const state = createState({
        count: 0,
        text: 'Привіт 👋'
      });

      const container = document.getElementById('app');

      function render() {
        container.innerHTML = `
          <div>
            <h2>${state.text}</h2>
            <p>Лічильник: ${state.count}</p>
            <button data-action="increment">Збільшити</button>
            <button data-action="changeText">Змінити текст</button>
          </div>
        `;
      }

      createEffect(() => {
        console.log(`Ререндер: count = ${state.count}`);
      });

      createEffect(() => {
        console.log(`Текст змінено: ${state.text}`);
      });

      createEffect(() => {
        render();
      });

      // Встановлюємо делегування подій один раз на контейнер
      container.addEventListener('click', (event) => {
        const target = event.target;

        if (target.tagName === 'BUTTON') {
          const action = target.dataset.action;

          if (action === 'increment') {
            state.count++;
          } else if (action === 'changeText') {
            state.text = state.text === 'Привіт 👋' ? 'Як справи?' : 'Привіт 👋';
          }
        }
      });
    }

    // Запуск
    CounterComponent();

createEffect спрацьовує автоматично, рендерить DOM, коли змінюється стан — компонент перерендерюється, а ми не викликаємо render() вручну — це робить реактивна система.

Демо на Stackblitz

Висновок

Ми створили просту реактивну систему на JavaScript без сторонніх бібліотек. Це дозволяє на прикладі зрозуміти базові принципи реактивності, які лежать в основі тогож Vue.js  —  як відбувається відстеження залежностей, оновлення стану та автоматичний ререндер.

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

«Самопідписування» при створенні ефекту — цікавий прийом, втім «для повноти картини» бракує ще й «відписування»))

Я початківець в JS. Не можу знайти в тексті статті, де «action» у кнопок ініціалізується значеннями «increment» та «changeText»

Вибачаюсь. Якщо переглянеш приклад на Stackblitz, побачиш, що кнопки мають атрибути:

<button data-action="increment">Збільшити</button>  
<button data-action="changeText">Змінити текст</button>
Я забув оновити в тексті статті.
Дякую, що звернув на це увагу!

Там невірно вибирається, повинно із датасету з імʼям екшн, а у маркапі там айді замість цього стоїть

Дякую. Дуже гарно написана стаття.

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