Головна / Статті

Фабрики з підтримкою закриття: приватна держава, затримка відповідей, обмеження частоти та скасування дій

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

Видавець: REACTAPP.TOP7 хв читання1631 слів

Джерела: Closures in JavaScript and React, Explained Simply

Закриття є основою для значної кількості щоденних функцій JavaScript: лічильників, входів із затримкою обробки, механізмів контролю кількості запитів, стеків скасування дій та майже кожного хука React. Вони також є причиною цілої низки помилок, коли функція-колбек повідомляє про значення, яке більше не є істинним. У цьому посібнику наводиться функціональне визначення закриття, пояснюється, як це впливає на стан React, а також розглядаються чотири практичні шаблони створення таких функцій, включаючи обмеження, про які слід знати перед їх використанням у продакшені.

Функціональне визначення

Закриття — це функція, яка поєднується з змінними, що були у сфері видимості місця її визначення. Ця функція зберігає доступ до цих змінних навіть після того, як зовнішня функція, що їх оголосила, повернулася. Прикладом може слугувати функція, яка «виходить з дому», беручи з собою «сумку» зі змінними, на які вона посилається; де б її потім не викликали, вона завжди користується саме цією «сумкою».

Шаблон створення лічильника

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

function makeCounter() {
  let count = 0; // lives inside makeCounter

  function increment() {
    count = count + 1;
    console.log(count);
  }

  return increment;
}

const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3

Крок за кроком відбувається наступне:

  • makeCounter() виконується один раз та оголошує count.
  • Вона повертає increment, який посилається на count, після чого makeCounter завершується.
  • Кожен виклик counter() читає та оновлює саме цю count, тому результат зростає від 1 до 3.

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

Чому стан React стає застарілим у таймерах

React не додає нічого особливого до клоузур; він просто покладається на них скрізь, особливо у хуках. Саме ця залежність пояснює одне з найпоширеніших сюрпризів у коді компонентів:

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setTimeout(() => {
      console.log("Count is: " + count);
    }, 3000);
  }

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Add</button>
      <button onClick={handleClick}>Log count in 3 seconds</button>
    </div>
  );
}

Почніть з значення count у рівні 0. Натисніть „Записати лічильник через 3 секунди“, а потім тричі натисніть „Додати“ до того, як спрацює таймер. У консолі буде виведено 0, а не 3.

Причина в тому, що кожне оновлення елемента Counter є новим викликом функції. Кожен виклик отримує власну константу count та визначає власну функцію handleClick. Функція-колбек setTimeout була створена під час оновлення, коли значення count було 0, і це єдине значення count, яке вона може бачити.

Ця модель пояснює більшість запитань на кшталт „Чому мій стан застарілий?“, і саме для цього існує масив залежностей у useEffect: він повідомляє React, від яких значень залежить цей ефект, щоб React міг перезапустити його з актуальними значеннями при їх зміні. Якщо вам справді потрібне найновіше значення у затриманому калеці, зазвичай використовуються функціональні оновлення, такі як setCount(c => c + 1) для запису, або ref, який потрібно оновлювати для читання. Варіанти вибору між цими підходами розглядаються у snapshot versus live values in delayed callbacks.

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

Інкапсульований стан без класів

До того, як у мові з’явилися поля із префіксом #, замикання були стандартним способом приховування даних, і сьогодні вони залишаються легким рішенням. Усе, що оголошується у фабриці, доступне лише через функції, які вона повертає:

function createBankAccount(balance) {
  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    }
  };
}

const account = createBankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined — can't touch it directly

balance не є властивістю повернутого об’єкта, тому account.balance дорівнює undefined. Єдиний спосіб змінити його — через deposit та withdraw.

Затримка виконання дії при введенні пошуку

Затримка виконання дії відкладає її до того моменту, коли введення припиниться на певний час. Саме це запобігає надсиланню запиту при кожному натисканні клавіш у полі пошуку:

function debounce(func, delay) {
  let timer; // remembered between calls
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => func(...args), delay);
  };
}

const searchInput = document.getElementById("search");
const handleSearch = debounce((e) => {
  fetch(`/api/search?q=${e.target.value}`);
}, 300);

searchInput.addEventListener("input", handleSearch);

Ключовою деталлю є місце оголошення timer: один раз, всередині функції debounce, поза повернутою функцією. Кожен натиск клавіші викликає повернуту функцію, яка скасовує поточний таймер та заплановує новий, тож запит надсилається лише через 300 мс без додаткових натискань. Якби timer було оголошено всередині повернутої функції, кожен виклик отримував би нову змінну, попередній таймер ніколи не можна було б скасувати, і кожен натиск клавіші все одно створював би запит.

У компоненті React ця логіка застосовується з одним додатковим кроком: функція з ефектом затримки має бути створена один раз та залишатися стабільною (наприклад, за допомогою useMemo або ref), інакше кожне оновлення створює нову функцію з новим timer, і ефект затримки починає працювати некоректно.

Простий обмежувач запитів

Та сама техніка може обмежувати частоту обслуговування кінцевої точки:

function createRateLimiter(maxRequests, windowMs) {
  let requestCount = 0;
  setInterval(() => (requestCount = 0), windowMs);

  return function () {
    requestCount++;
    return requestCount <= maxRequests;
  };
}

const rateLimiter = createRateLimiter(5, 60000); // 5 requests per minute

app.get("/api/data", (req, res) => {
  if (!rateLimiter()) {
    return res.status(429).send("Too many requests");
  }
  res.send("Here's your data");
});

requestCount використовується приватно кожним викликом rateLimiter, і його значення скидається кожного інтервалу. Ця версія — це лічильник із фіксованим інтервалом, який зберігається в пам’яті, тож слід пам’ятати про його обмеження:

  • він підраховує всі виклики разом, тож один клієнт може вичерпати ліміт для всіх; справжні обмежувачі ведуть підрахунок за користувачем, токеном чи IP
  • стан зберігається в одному процесі, тож кілька інстанцій сервера можуть дозволяти повний ліміт кожна окремо
  • фіксовані інтервали дозволяють сплески викликів на межі інтервалу — до двох разів ліміту за короткий проміжок часу
  • функція setInterval ніколи не скасовується, що має значення під час тестування або коли обмежувачі створюються динамічно

Історія скасувань та повторень

Менеджер історії зберігає свою стек-структуру та курсор приватно для кожної інстанції:

function createHistoryManager() {
  let history = [];
  let currentIndex = -1;

  return {
    save(state) {
      history = history.slice(0, currentIndex + 1);
      history.push(state);
      currentIndex++;
    },
    undo() {
      if (currentIndex > 0) currentIndex--;
      return history[currentIndex];
    },
    redo() {
      if (currentIndex < history.length - 1) currentIndex++;
      return history[currentIndex];
    }
  };
}

save спочатку видаляє все, що знаходиться після поточної позиції, тому запис нової зміни після виконання операції undo призводить до видалення гілки redo, що відповідає поведінці більшості редакторів. undo та redo просто змінюють значення currentIndex в межах допустимих значень та повертають стан у цей момент. Зауважте, що збережені стани мають бути незмінними знімками; якщо ви збережете об’єкт, а потім зміните його, кожен запис історії буде посилатися на ті самі змінені дані.

Швидкі методи для виявлення замикань

  • Внутрішня функція використовує зовнішню. Функція, визначена всередині іншої функції та яка посилається на змінні зовнішньої функції, є замиканням.
  • Він фіксує зв’язок, а не живе потокове передавання нових результатів рендерингу. У React кожен рендер створює нові зв’язки, тож старий кбекфункція зберігає значення з власного рендеру. Строго кажучи, закриття зберігає саму змінну, тож якщо ця ж змінна пізніше переатрибується, як count у фабриці лічильника, закриття бачить нове значення.
  • Функція, яка повертається, — це чіткий натяк. Коли функція повертає іншу функцію, майже завжди задіяне закриття.
  • Пастка var у циклі

    Змінні циклу найяскравіше демонструють правило зв’язку:

    for (var i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 1000);
    }
    // Logs: 3, 3, 3 — var is shared across the whole loop
    
    for (let i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 1000);
    }
    // Logs: 0, 1, 2 — let creates a new i on every iteration
    

    За допомогою var існує єдиний змінний i для всієї функції, і до моменту виконання таймаутів цей змінний вже має значення 3, тому всі три калебеки виводять 3. За допомогою let кожна ітерація отримує власне значення i, і кожен калебек працює з різним значенням, тому виводяться 0, 1 та 2.

    Підсумок

    Замикання є механізмом, який лежить в основі приватного стану, затриманих введень, обмежувачів кількості запитів, стеків скасувань та користувацьких гаків, а також багатьох проблем із застарілим станом. Два запитання допомагають розвіяти більшість непорозумінь: яку змінну зафіксувала ця функція та чи була вона замінена на новішу з того часу? У звичайних фабриках JavaScript зафіксована змінна є спільною та оновлюється на місці. У React кожне відображення створює нові змінні, тож будь-який калебек, який існує довше, ніж його відображення, бачить старий стан, якщо тільки ви навмисно не надасте йому можливість читати поточний стан.