Главная / Статьи

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

Узнайте, как замыкания поддерживают приватное состояние в фабриках JavaScript, почему они приводят к устаревшим значениям в таймерах React, и как функции debounce, rate limiting и undo зависят от них.

Издатель: 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 просто перемещают 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 при каждом отрисовывании создаются новые переменные, поэтому любой обратный вызов, существующий дольше своего отрисовывания, видит устаревшую версию данных, если только вы не предоставите ему способ прочитать текущую версию.