Замыкания в JavaScript: от основ до уверенного применения

Курс для frontend-разработчиков, которые хотят разобраться в замыканиях без лишней теории. Только код, примеры из реальной практики и разбор типичных ошибок. После курса вы будете уверенно использовать замыкания в React, Vue и Angular.

Что такое замыкание и как оно создаётся

Что такое замыкание и как оно создаётся

Представьте: вы пишете компонент на React, добавляете обработчик события внутри useEffect, и вдруг замечаете, что он читает устаревшее значение стейта. Или создаёте несколько кнопок в цикле, и все они ведут себя одинаково — хотя должны делать разное. За обоими сценариями стоит одна и та же механика: замыкание (closure). Понять его — значит перестать бороться с JavaScript и начать использовать его в свою пользу.

Функция внутри функции — вот где всё начинается

Замыкание возникает, когда функция «запоминает» переменные из того места, где она была создана, — даже если это место уже завершило выполнение. Звучит абстрактно, поэтому сразу к коду.

function makeGreeting(name) {
  // переменная name живёт в области видимости makeGreeting
  return function () {
    console.log(`Привет, ${name}!`);
  };
}

const greetAlice = makeGreeting('Alice');
greetAlice(); // "Привет, Alice!"

makeGreeting уже завершила работу, но возвращённая функция всё равно знает, кто такой name. Она не скопировала значение — она держит живую ссылку на лексическое окружение (lexical environment) родительской функции. Это и есть замыкание.

Замыкание — это функция вместе с лексическим окружением, в котором она была создана.

Как JavaScript хранит переменные: лексическое окружение

Каждый раз, когда вызывается функция, JavaScript создаёт для неё лексическое окружение — внутреннюю структуру данных, которая хранит все локальные переменные этого вызова. Когда функция завершается, это окружение обычно уничтожается сборщиком мусора. Но если внутри была создана другая функция, которая ссылается на переменные родителя — окружение остаётся живым, пока жива внутренняя функция.

Вот как это выглядит пошагово:

  1. Вызывается makeGreeting('Alice') — создаётся лексическое окружение с name = 'Alice'.
  2. Внутри создаётся анонимная функция, которая захватывает это окружение.
  3. makeGreeting возвращает анонимную функцию и завершает работу.
  4. Лексическое окружение с name не уничтожается, потому что на него ссылается возвращённая функция.
  5. При вызове greetAlice() функция находит name в захваченном окружении.

Именно поэтому каждый вызов makeGreeting создаёт независимое замыкание:

const greetAlice = makeGreeting('Alice');
const greetBob = makeGreeting('Bob');

greetAlice(); // "Привет, Alice!"
greetBob();   // "Привет, Bob!"

greetAlice и greetBob — две разные функции с двумя разными лексическими окружениями. Они не делят переменную name между собой.

Три условия, при которых возникает замыкание

Замыкание создаётся автоматически — вам не нужно ничего специально делать. Оно появляется всегда, когда выполняются три условия:

  • Есть внешняя функция с локальными переменными.
  • Внутри неё создаётся другая функция.
  • Внутренняя функция обращается к переменным внешней.

Это не экзотика — это происходит в вашем коде постоянно. Каждый колбэк в addEventListener, каждый обработчик в useCallback, каждая функция внутри другой функции — потенциальное замыкание.

Замыкание — это не копия, а ссылка

Здесь кроется важный нюанс, который часто путает разработчиков. Замыкание захватывает не значение переменной в момент создания, а саму переменную — живую ссылку на ячейку памяти. Это означает, что если переменная изменится, замыкание увидит новое значение.

function makeCounter() {
  let count = 0;

  return {
    increment() { count++; },
    get()       { return count; },
  };
}

const counter = makeCounter();
counter.increment();
counter.increment();
console.log(counter.get()); // 2

Обе функции — increment и get — замкнуты на одну и ту же переменную count. Когда increment меняет count, get видит изменение. Это не баг, это фича: замыкания позволяют нескольким функциям разделять общее состояние.

Замыкание без вложенных функций — миф

Иногда можно услышать, что замыкание — это «функция, возвращающая функцию». Это неточно. Замыкание создаётся в момент определения внутренней функции, а не в момент возврата. Вот пример, где функция никуда не возвращается, но замыкание всё равно работает:

function setup() {
  let message = 'Готово';

  setTimeout(function () {
    console.log(message); // "Готово" — через 1 секунду
  }, 1000);
}

setup();

Колбэк setTimeout замкнут на message. setup давно завершилась, но когда через секунду сработает таймер — message будет доступна. Именно поэтому в React-компонентах обработчики событий видят пропсы и стейт: они замкнуты на них в момент рендера.

Почему это важно для React-разработчика

В React каждый рендер — это новый вызов функции-компонента. Каждый раз создаются новые замыкания, которые захватывают актуальные значения пропсов и стейта на момент этого рендера. Вот почему useCallback и useMemo существуют: они позволяют контролировать, какие замыкания пересоздаются, а какие — нет.

function SearchComponent({ query }) {
  // Каждый рендер создаёт новое замыкание с актуальным query
  const handleSearch = () => {
    console.log('Ищем:', query);
  };

  return <button onClick={handleSearch}>Найти</button>;
}

Без понимания замыканий поведение useEffect с зависимостями, «устаревшие» значения в обработчиках и бесконечные циклы ре-рендеров выглядят как магия. С пониманием — это предсказуемая механика.

Замыкание — не абстрактная концепция из учебника. Это инструмент, который JavaScript использует под капотом каждый раз, когда вы пишете функцию внутри функции. Следующий шаг — разобраться, как именно работают области видимости и что значит «захватить переменную».

Области видимости и захват переменных

Области видимости и захват переменных

Когда вы пишете let x = 5 внутри функции, где именно живёт эта переменная? Почему функция видит переменные родителя, но не видит переменные соседней функции? И почему var ведёт себя не так, как let? Всё это — вопросы об областях видимости (scope), и без чёткого понимания этой механики замыкания будут казаться непредсказуемыми.

Область видимости — это не про файлы, а про вложенность

Область видимости — это часть кода, в которой переменная доступна. В JavaScript есть три уровня:

Уровень Где создаётся Пример
Глобальная Вне любых функций и блоков var x = 1 на верхнем уровне
Функциональная Внутри функции (var, function) function f() { var y = 2; }
Блочная Внутри {} (let, const) if (true) { let z = 3; }

Ключевое правило: внутренняя область видит внешнюю, но не наоборот. Функция видит переменные своего родителя, дедушки и так далее вплоть до глобального уровня. Но родитель не видит переменные дочерней функции.

const globalVar = 'глобальная';

function outer() {
  const outerVar = 'внешняя';

  function inner() {
    const innerVar = 'внутренняя';
    console.log(globalVar); // ✓ видит
    console.log(outerVar);  // ✓ видит
    console.log(innerVar);  // ✓ видит
  }

  console.log(innerVar); // ✗ ReferenceError
}

Цепочка областей видимости: как JavaScript ищет переменную

Когда функция обращается к переменной, JavaScript не просто смотрит в текущую область — он идёт вверх по цепочке областей видимости (scope chain). Сначала ищет в текущей функции, потом в родительской, потом в родителе родителя, и так до глобального объекта. Если нигде не нашёл — ReferenceError.

const x = 'глобальный';

function level1() {
  const x = 'level1';

  function level2() {
    // x не объявлен здесь — идём выше
    function level3() {
      console.log(x); // "level1" — нашли на уровне level1
    }
    level3();
  }

  level2();
}

level1();

Важно: JavaScript ищет переменную по лексической цепочке — по тому, где функция написана в коде, а не откуда она вызвана. Это называется лексической областью видимости (lexical scoping).

const value = 'глобальный';

function getValue() {
  console.log(value); // всегда "глобальный"
}

function wrapper() {
  const value = 'локальный'; // не влияет на getValue!
  getValue(); // "глобальный"
}

wrapper();

getValue написана на глобальном уровне, поэтому её цепочка видимости идёт к глобальному value, а не к локальному value внутри wrapper. Место вызова не имеет значения.

Что именно захватывает замыкание

Как уже было сказано в первой статье, замыкание захватывает не значение, а переменную. Но что это означает на практике? Рассмотрим пример с изменяемой переменной:

function makeAdder(x) {
  return function (y) {
    return x + y;
  };
}

const add5 = makeAdder(5);
const add10 = makeAdder(10);

console.log(add5(3));  // 8
console.log(add10(3)); // 13

Каждый вызов makeAdder создаёт новое лексическое окружение с собственным x. add5 и add10 — независимые замыкания с разными x. Это фундаментальный момент: каждый вызов функции — новое окружение.

Теперь посмотрим, что происходит, когда несколько замыканий разделяют одну переменную:

function makeSharedCounter() {
  let count = 0;

  const increment = () => ++count;
  const decrement = () => --count;
  const getCount  = () => count;

  return { increment, decrement, getCount };
}

const counter = makeSharedCounter();
counter.increment(); // count = 1
counter.increment(); // count = 2
counter.decrement(); // count = 1
console.log(counter.getCount()); // 1

Три функции замкнуты на одно и то же count. Это не три копии переменной — это три ссылки на одну ячейку памяти.

var, let и const: принципиальная разница для замыканий

var объявляет переменную в функциональной области видимости. let и const — в блочной. Эта разница критична в циклах и условиях.

// var — одна переменная на всю функцию
function withVar() {
  for (var i = 0; i < 3; i++) {
    // i здесь — одна и та же переменная
  }
  console.log(i); // 3 — i видна за пределами цикла!
}

// let — новая переменная на каждую итерацию блока
function withLet() {
  for (let j = 0; j < 3; j++) {
    // j здесь — новая переменная на каждой итерации
  }
  console.log(j); // ReferenceError — j не видна снаружи
}

Именно это различие порождает классическую ошибку с замыканиями в циклах — она подробно разобрана в статье про типичные ошибки. Здесь важно зафиксировать принцип: let создаёт новую переменную на каждую итерацию блока, var — нет.

Hoisting: переменные поднимаются, но не все одинаково

Hoisting (поднятие) — механизм, при котором объявления переменных и функций перемещаются в начало своей области видимости до выполнения кода. Это влияет на то, что именно захватывает замыкание.

function example() {
  console.log(x); // undefined, не ReferenceError
  var x = 5;
  console.log(x); // 5
}

var x поднимается в начало функции и инициализируется как undefined. Само присвоение = 5 остаётся на месте. С let и const поднятие тоже происходит, но переменная попадает в временную мёртвую зону (Temporal Dead Zone, TDZ) — обращение к ней до объявления вызывает ReferenceError.

function example() {
  console.log(y); // ReferenceError: Cannot access 'y' before initialization
  let y = 5;
}

Для замыканий это означает: если вы создаёте функцию до того, как переменная инициализирована, и вызываете её после — всё будет работать корректно. Замыкание захватывает переменную, а не её текущее значение.

function delayedAccess() {
  let result;

  const getResult = () => result; // замыкание создано, result = undefined

  result = 'готово'; // теперь присваиваем значение

  return getResult;
}

const fn = delayedAccess();
console.log(fn()); // "готово" — замыкание видит актуальное значение

Модуль как замыкание: паттерн, который вы уже используете

Понимание областей видимости объясняет, почему модульный паттерн (module pattern) работает именно так. Каждый ES-модуль (import/export) создаёт собственную область видимости. Переменные, не экспортированные явно, недоступны снаружи — они замкнуты внутри модуля.

// counter.js
let count = 0; // приватная переменная модуля

export const increment = () => ++count;
export const getCount  = () => count;
// count недоступна снаружи напрямую

Это не магия модульной системы — это замыкание на уровне файла. increment и getCount замкнуты на count, которая живёт в области видимости модуля.

Понимание того, как JavaScript строит цепочку областей видимости и что именно захватывают замыкания, — это фундамент. Теперь можно переходить к практике: счётчикам, кэшированию и обработчикам событий.

Практические примеры: счётчики, кэш, обработчики событий

Практические примеры: счётчики, кэш, обработчики событий

Замыкания — не академическая абстракция. Они решают конкретные задачи, с которыми вы сталкиваетесь каждый день: изолировать состояние, не засорять глобальное пространство имён, запоминать результаты дорогих вычислений, создавать гибкие обработчики. Разберём каждый паттерн на реальных примерах.

Счётчик с приватным состоянием

Самый классический пример замыкания — счётчик. Но не просто счётчик, а счётчик, чьё состояние нельзя изменить снаружи напрямую.

function createCounter(initialValue = 0, step = 1) {
  let count = initialValue;

  return {
    increment() { count += step; },
    decrement() { count -= step; },
    reset()     { count = initialValue; },
    value()     { return count; },
  };
}

const pageViews = createCounter(0, 1);
pageViews.increment();
pageViews.increment();
console.log(pageViews.value()); // 2

// Нельзя сделать pageViews.count = 1000 — count недоступна снаружи

Переменная count живёт в замыкании и доступна только через методы объекта. Это инкапсуляция без классов и private-полей. В React этот паттерн лежит в основе useReducer — редьюсер замкнут на текущее состояние и возвращает новое.

Параметры initialValue и step тоже захватываются замыканием. Это позволяет создавать независимые счётчики с разными настройками:

const likeCounter   = createCounter(0, 1);
const scoreCounter  = createCounter(100, 10);

likeCounter.increment();  // 1
scoreCounter.increment(); // 110
scoreCounter.increment(); // 120

Мемоизация: кэш результатов через замыкание

Мемоизация (memoization) — техника оптимизации, при которой функция запоминает результаты предыдущих вызовов и возвращает их без повторных вычислений. Замыкание — идеальный инструмент для хранения этого кэша.

function memoize(fn) {
  const cache = new Map(); // кэш живёт в замыкании

  return function (...args) {
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      console.log('Из кэша:', key);
      return cache.get(key);
    }

    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

function slowFibonacci(n) {
  if (n <= 1) return n;
  return slowFibonacci(n - 1) + slowFibonacci(n - 2);
}

const fastFibonacci = memoize(slowFibonacci);
fastFibonacci(40); // считает ~1 секунду
fastFibonacci(40); // мгновенно — из кэша

cache — это Map, который живёт в лексическом окружении memoize. Каждый вызов memoize создаёт новый независимый кэш. Возвращённая функция замкнута на этот кэш и обращается к нему при каждом вызове.

В React этот паттерн реализован в хуке useMemo. Под капотом — то же самое: результат вычисления хранится между рендерами и возвращается без пересчёта, если зависимости не изменились.

Важный нюанс: JSON.stringify(args) как ключ кэша работает для примитивов, но не для объектов (два разных объекта с одинаковым содержимым дадут одинаковый ключ, что может быть нежелательно). В продакшн-реализациях используют более сложные стратегии ключей.

Частичное применение функций

Частичное применение (partial application) — создание новой функции путём фиксации части аргументов исходной. Замыкание захватывает зафиксированные аргументы.

function multiply(a, b) {
  return a * b;
}

function partial(fn, ...presetArgs) {
  return function (...laterArgs) {
    return fn(...presetArgs, ...laterArgs);
  };
}

const double = partial(multiply, 2);
const triple = partial(multiply, 3);

console.log(double(5));  // 10
console.log(triple(5));  // 15
console.log(double(21)); // 42

presetArgs захватывается замыканием. double — это multiply с зафиксированным первым аргументом 2. Это мощный инструмент для создания специализированных функций из общих.

Реальный кейс из React: обработчики событий с параметрами.

function ItemList({ items, onDelete }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>
          {item.name}
          {/* Замыкание захватывает item.id */}
          <button onClick={() => onDelete(item.id)}>
            Удалить
          </button>
        </li>
      ))}
    </ul>
  );
}

Каждая стрелочная функция () => onDelete(item.id) — замыкание, захватывающее конкретный item.id. Без замыканий пришлось бы передавать id через data-атрибуты и читать его из события.

Обработчики событий: замыкания в DOM

Классический паттерн: нужно добавить обработчик, который знает о контексте своего создания.

function setupButton(buttonId, message) {
  const button = document.getElementById(buttonId);

  button.addEventListener('click', function () {
    // Замыкание захватывает message и buttonId
    console.log(`Кнопка ${buttonId}: ${message}`);
  });
}

setupButton('btn-save',   'Данные сохранены');
setupButton('btn-cancel', 'Действие отменено');

Каждый обработчик замкнут на свои buttonId и message. Функция setupButton давно завершила работу, но обработчики продолжают иметь доступ к этим переменным.

Важный момент: если вы добавляете обработчик и потом хотите его удалить — нужна именованная функция, потому что removeEventListener требует ссылку на ту же функцию.

function setupRemovableButton(buttonId) {
  const button = document.getElementById(buttonId);
  let clickCount = 0;

  function handleClick() {
    clickCount++;
    console.log(`Кликов: ${clickCount}`);

    if (clickCount >= 3) {
      button.removeEventListener('click', handleClick);
      console.log('Обработчик удалён');
    }
  }

  button.addEventListener('click', handleClick);
}

handleClick замкнута на clickCount и на саму себя (через имя функции). После трёх кликов она удаляет себя из обработчиков.

Once: функция, которая выполняется только один раз

Паттерн once — утилита, которая гарантирует однократное выполнение функции. Используется для инициализации, подписок, аналитических событий.

function once(fn) {
  let called = false;
  let result;

  return function (...args) {
    if (!called) {
      called = true;
      result = fn(...args);
    }
    return result;
  };
}

const initApp = once(function () {
  console.log('Инициализация приложения');
  return { initialized: true };
});

initApp(); // "Инициализация приложения"
initApp(); // тишина — функция не вызывается повторно
initApp(); // тишина

called и result живут в замыкании. Флаг called гарантирует однократность, result позволяет вернуть то же значение при повторных вызовах.

Debounce и throttle: управление частотой вызовов

Debounce откладывает выполнение функции до тех пор, пока не пройдёт заданное время после последнего вызова. Throttle ограничивает частоту вызовов. Оба паттерна используют замыкания для хранения таймера.

function debounce(fn, delay) {
  let timeoutId; // живёт в замыкании

  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn(...args), delay);
  };
}

const handleSearch = debounce(function (query) {
  console.log('Поиск:', query);
  // fetch(`/api/search?q=${query}`)
}, 300);

// Пользователь быстро набирает — запрос отправится только через 300мс после паузы
input.addEventListener('input', e => handleSearch(e.target.value));

timeoutId — это переменная в замыкании, которая хранит идентификатор таймера между вызовами. Каждый новый вызов отменяет предыдущий таймер и создаёт новый. Без замыкания пришлось бы хранить timeoutId в глобальной переменной или в объекте.

Все эти паттерны — счётчики, мемоизация, частичное применение, debounce — объединяет одна идея: замыкание как хранилище состояния, которое живёт между вызовами функции и недоступно снаружи. Это чище глобальных переменных и проще классов для многих задач.

Типичные ошибки с замыканиями и как их исправить

Типичные ошибки с замыканиями и как их исправить

Замыкания работают предсказуемо — но только если понимать их механику. Большинство ошибок возникают не потому, что замыкания «сломаны», а потому что разработчик ожидает одного поведения, а получает другое. Разберём самые распространённые ловушки с конкретными диагнозами и лечением.

Классика: var в цикле

Это самая известная ошибка с замыканиями, и она до сих пор встречается в реальных проектах.

// Ожидание: кнопки выводят 0, 1, 2
// Реальность: все выводят 3
for (var i = 0; i < 3; i++) {
  document.getElementById(`btn-${i}`).addEventListener('click', function () {
    console.log(i);
  });
}

Почему так происходит? var создаёт одну переменную i в области видимости функции (или глобальной). Все три обработчика замкнуты на одну и ту же переменную. К моменту клика цикл уже завершился, и i равно 3. Каждый обработчик читает актуальное значение i — а оно одно для всех.

Решение 1: заменить var на let

for (let i = 0; i < 3; i++) {
  document.getElementById(`btn-${i}`).addEventListener('click', function () {
    console.log(i); // 0, 1, 2 — каждая итерация создаёт новую переменную i
  });
}

let создаёт новую переменную i на каждую итерацию блока. Каждый обработчик замкнут на свою собственную копию i.

Решение 2: IIFE для создания нового окружения (исторический подход)

До появления let разработчики использовали IIFE (Immediately Invoked Function Expression — немедленно вызываемое функциональное выражение):

for (var i = 0; i < 3; i++) {
  (function (capturedI) {
    document.getElementById(`btn-${capturedI}`).addEventListener('click', function () {
      console.log(capturedI); // 0, 1, 2
    });
  })(i);
}

IIFE создаёт новое лексическое окружение на каждой итерации с параметром capturedI, который получает текущее значение i. Сегодня этот подход устарел — используйте let.

Решение 3: вынести создание обработчика в отдельную функцию

function createHandler(index) {
  return function () {
    console.log(index);
  };
}

for (var i = 0; i < 3; i++) {
  document.getElementById(`btn-${i}`).addEventListener('click', createHandler(i));
}

Каждый вызов createHandler(i) создаёт новое замыкание с собственным index.

Устаревшие значения в React: stale closure

В React-разработке самая частая проблема с замыканиями — stale closure (устаревшее замыкание). Функция захватывает значение стейта в момент создания, но к моменту вызова стейт уже изменился.

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

  useEffect(() => {
    const interval = setInterval(() => {
      // Проблема: count всегда 0 — замыкание захватило начальное значение
      setCount(count + 1);
    }, 1000);

    return () => clearInterval(interval);
  }, []); // пустой массив зависимостей — эффект запускается один раз

  return <div>{count}</div>;
}

Эффект запускается один раз при монтировании. Колбэк setInterval замкнут на count = 0 из первого рендера. Каждую секунду он делает setCount(0 + 1) — счётчик застревает на 1.

Решение 1: функциональное обновление стейта

setInterval(() => {
  setCount(prev => prev + 1); // prev — всегда актуальное значение
}, 1000);

Когда передаёте функцию в setCount, React передаёт в неё актуальное значение стейта. Замыкание больше не нужно захватывать count.

Решение 2: добавить зависимость и пересоздавать эффект

useEffect(() => {
  const interval = setInterval(() => {
    setCount(count + 1);
  }, 1000);

  return () => clearInterval(interval);
}, [count]); // эффект пересоздаётся при каждом изменении count

Теперь при каждом изменении count эффект очищается и запускается заново с актуальным значением. Но это создаёт новый интервал каждую секунду — для таймеров лучше первый подход.

Решение 3: useRef для хранения актуального значения

function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count; // всегда актуально

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(countRef.current + 1); // читаем из ref, не из замыкания
    }, 1000);

    return () => clearInterval(interval);
  }, []);
}

useRef создаёт мутабельный объект, который не пересоздаётся между рендерами. Замыкание захватывает countRef (ссылку на объект), а countRef.current всегда содержит актуальное значение.

Утечки памяти через замыкания

Замыкание удерживает лексическое окружение живым. Если это окружение содержит большие объекты, а замыкание живёт долго — возникает утечка памяти (memory leak).

function setupHeavyHandler() {
  const hugeData = new Array(1000000).fill('данные'); // 1 млн элементов

  document.getElementById('btn').addEventListener('click', function () {
    // hugeData захвачена замыканием, даже если не используется напрямую
    console.log('Клик!');
  });
}

Обработчик события живёт, пока существует элемент DOM. hugeData живёт, пока живёт обработчик. Если элемент не удаляется — hugeData никогда не будет собрана сборщиком мусора.

Решение: не захватывать лишнее

function setupHeavyHandler() {
  const hugeData = new Array(1000000).fill('данные');
  const summary = hugeData.length; // вычисляем только нужное

  // hugeData больше не нужна
  document.getElementById('btn').addEventListener('click', function () {
    console.log(`Элементов: ${summary}`); // захватываем только summary
  });
}

Или явно удалять обработчик, когда он больше не нужен:

const handler = function () { console.log('Клик!'); };
document.getElementById('btn').addEventListener('click', handler);

// Когда элемент удаляется:
document.getElementById('btn').removeEventListener('click', handler);

Случайное разделение состояния

Иногда разработчики создают функцию один раз и используют её в нескольких местах, не подозревая, что все они разделяют одно замыкание.

// Ошибка: один кэш на всё приложение
const memoizedFetch = memoize(fetchUserData);

// Компонент A и Компонент B используют один и тот же кэш
// Данные пользователя из компонента A могут «утечь» в компонент B

Если это нежелательно — создавайте отдельные экземпляры:

// Каждый компонент получает свой кэш
function UserComponent({ userId }) {
  const memoizedFetch = useMemo(() => memoize(fetchUserData), []);
  // ...
}

Замыкание в асинхронном коде

Асинхронные операции выполняются позже — и к этому моменту переменные в замыкании могут измениться.

async function processItems(items) {
  for (var i = 0; i < items.length; i++) {
    await fetch(`/api/item/${items[i].id}`);
    console.log(`Обработан элемент ${i}`); // i уже изменился!
  }
}

С var в асинхронном цикле i изменяется до того, как await завершится. Решение — let или деструктуризация:

async function processItems(items) {
  for (const item of items) {
    await fetch(`/api/item/${item.id}`);
    console.log(`Обработан: ${item.name}`); // item захвачен корректно
  }
}

Диагностика: как понять, что проблема в замыкании

Когда что-то работает не так, как ожидается, задайте себе три вопроса:

  1. Когда создана функция? — Что именно она захватила в момент создания?
  2. Когда вызвана функция? — Изменились ли захваченные переменные к этому моменту?
  3. Сколько экземпляров замыкания существует? — Одно или несколько? Делят ли они переменную?

Большинство ошибок с замыканиями укладываются в одну из двух категорий: либо все замыкания разделяют одну переменную (проблема с var), либо замыкание захватило устаревшее значение (stale closure в React). Понимание этих двух паттернов закрывает 90% проблем.

Задачи для самостоятельного решения

Задачи для самостоятельного решения

Теория без практики не работает. Эта статья — набор задач разного уровня сложности, которые проверяют понимание замыканий с разных сторон: от базовой механики до реальных паттернов из продакшн-кода. Для каждой задачи есть условие, подсказка и разбор решения.

Уровень 1: Механика замыканий

Задача 1. Что выведет код?

Прочитайте код и предскажите вывод до запуска. Затем проверьте в консоли.

function outer() {
  let x = 10;

  function inner() {
    let x = 20;
    return function () {
      return x;
    };
  }

  return inner()();
}

console.log(outer());

Подсказка: Обратите внимание на то, какой x захватывает самая внутренняя функция — тот, что в outer, или тот, что в inner?

Разбор: Самая внутренняя функция function() { return x; } создаётся внутри inner, поэтому она захватывает x = 20 из inner, а не x = 10 из outer. Вывод: 20.


Задача 2. Счётчик с ограничением

Напишите функцию createBoundedCounter(max), которая возвращает счётчик. Счётчик не должен превышать max. При достижении максимума метод increment ничего не делает.

const counter = createBoundedCounter(3);
counter.increment(); // 1
counter.increment(); // 2
counter.increment(); // 3
counter.increment(); // 3 — не увеличивается
console.log(counter.value()); // 3

Подсказка: Храните count и max в замыкании. В increment добавьте проверку перед увеличением.

Решение:

function createBoundedCounter(max) {
  let count = 0;

  return {
    increment() {
      if (count < max) count++;
    },
    value() {
      return count;
    },
    reset() {
      count = 0;
    },
  };
}

Задача 3. Предсказание с var

Что выведет этот код? Объясните почему.

const funcs = [];

for (var i = 0; i < 5; i++) {
  funcs.push(function () {
    return i;
  });
}

console.log(funcs[0]()); // ?
console.log(funcs[2]()); // ?
console.log(funcs[4]()); // ?

Затем исправьте код так, чтобы funcs[0]() вернул 0, funcs[2]()2, funcs[4]()4.

Разбор: Все три вызова вернут 5. var создаёт одну переменную i для всего цикла. К моменту вызова функций цикл завершился и i = 5. Исправление — заменить var на let.

Уровень 2: Практические паттерны

Задача 4. Функция once с аргументами

Реализуйте функцию once(fn), которая возвращает обёртку. Обёртка вызывает fn только при первом вызове и возвращает его результат. При последующих вызовах — возвращает тот же результат, не вызывая fn повторно.

const initialize = once(function (config) {
  console.log('Инициализация с конфигом:', config);
  return { ready: true, config };
});

const result1 = initialize({ theme: 'dark' });
// "Инициализация с конфигом: { theme: 'dark' }"

const result2 = initialize({ theme: 'light' }); // fn не вызывается
console.log(result1 === result2); // true — тот же объект

Подсказка: Нужны две переменные в замыкании: флаг called и cachedResult.

Решение:

function once(fn) {
  let called = false;
  let cachedResult;

  return function (...args) {
    if (!called) {
      called = true;
      cachedResult = fn(...args);
    }
    return cachedResult;
  };
}

Задача 5. Генератор уникальных ID

Напишите createIdGenerator(prefix), который возвращает функцию. Каждый вызов возвращаемой функции генерирует уникальный ID вида prefix-1, prefix-2 и т.д. Разные генераторы должны иметь независимые счётчики.

const userId   = createIdGenerator('user');
const orderId  = createIdGenerator('order');

console.log(userId());   // "user-1"
console.log(userId());   // "user-2"
console.log(orderId());  // "order-1"
console.log(userId());   // "user-3"
console.log(orderId());  // "order-2"

Решение:

function createIdGenerator(prefix) {
  let counter = 0;

  return function () {
    counter++;
    return `${prefix}-${counter}`;
  };
}

Каждый вызов createIdGenerator создаёт новое замыкание с независимым counter.


Задача 6. Частичное применение с несколькими аргументами

Реализуйте partial(fn, ...args) — функцию частичного применения. Возвращённая функция должна принимать оставшиеся аргументы и вызывать fn с полным набором.

function greet(greeting, name, punctuation) {
  return `${greeting}, ${name}${punctuation}`;
}

const sayHello = partial(greet, 'Привет');
const sayHelloToAlice = partial(greet, 'Привет', 'Alice');

console.log(sayHello('Bob', '!'));       // "Привет, Bob!"
console.log(sayHelloToAlice('!'));       // "Привет, Alice!"
console.log(sayHelloToAlice('...'));     // "Привет, Alice..."

Решение:

function partial(fn, ...presetArgs) {
  return function (...laterArgs) {
    return fn(...presetArgs, ...laterArgs);
  };
}

Уровень 3: Сложные случаи

Задача 7. Мемоизация с инвалидацией кэша

Расширьте функцию memoize из третьей статьи: добавьте метод cache.clear() для очистки кэша и cache.size для получения количества закэшированных значений.

const memoizedFn = memoize(expensiveCalculation);

memoizedFn(42);  // вычисляет
memoizedFn(42);  // из кэша
memoizedFn(100); // вычисляет

console.log(memoizedFn.cache.size);  // 2
memoizedFn.cache.clear();
console.log(memoizedFn.cache.size);  // 0
memoizedFn(42);  // вычисляет снова

Подсказка: Возвращаемая функция — это объект. К функциям в JavaScript можно добавлять свойства.

Решение:

function memoize(fn) {
  const cacheMap = new Map();

  function memoized(...args) {
    const key = JSON.stringify(args);
    if (cacheMap.has(key)) return cacheMap.get(key);
    const result = fn(...args);
    cacheMap.set(key, result);
    return result;
  }

  memoized.cache = {
    get size() { return cacheMap.size; },
    clear()    { cacheMap.clear(); },
  };

  return memoized;
}

Задача 8. Debounce с немедленным первым вызовом

Реализуйте debounce(fn, delay, immediate). Если immediate = true, функция вызывается немедленно при первом вызове, а затем блокируется на delay миллисекунд.

const handleInput = debounce(function (value) {
  console.log('Обработка:', value);
}, 300, true);

handleInput('a'); // вызывается немедленно: "Обработка: a"
handleInput('ab'); // блокируется
handleInput('abc'); // блокируется
// через 300мс после последнего вызова — разблокируется
handleInput('abcd'); // вызывается немедленно: "Обработка: abcd"

Решение:

function debounce(fn, delay, immediate = false) {
  let timeoutId;

  return function (...args) {
    const shouldCallNow = immediate && !timeoutId;

    clearTimeout(timeoutId);

    timeoutId = setTimeout(() => {
      timeoutId = null;
      if (!immediate) fn(...args);
    }, delay);

    if (shouldCallNow) fn(...args);
  };
}

Задача 9. Конвейер функций

Реализуйте pipe(...fns) — функцию, которая принимает набор функций и возвращает новую функцию. Возвращённая функция последовательно применяет все переданные функции к значению.

const process = pipe(
  x => x * 2,
  x => x + 10,
  x => `Результат: ${x}`,
);

console.log(process(5));  // "Результат: 20"
console.log(process(10)); // "Результат: 30"

Подсказка: fns захватывается замыканием. Используйте Array.prototype.reduce.

Решение:

function pipe(...fns) {
  return function (value) {
    return fns.reduce((acc, fn) => fn(acc), value);
  };
}

Замыкание захватывает массив fns. При каждом вызове возвращённой функции reduce последовательно применяет каждую функцию из массива к накопленному результату.


Задача 10. Исправьте баг

Найдите и исправьте проблему в этом React-компоненте:

function NotificationList({ notifications }) {
  const [dismissed, setDismissed] = useState([]);

  useEffect(() => {
    const timer = setInterval(() => {
      if (notifications.length > dismissed.length) {
        setDismissed(dismissed.concat(notifications[dismissed.length].id));
      }
    }, 2000);

    return () => clearInterval(timer);
  }, []); // eslint-disable-next-line react-hooks/exhaustive-deps

  return (
    <ul>
      {notifications
        .filter(n => !dismissed.includes(n.id))
        .map(n => <li key={n.id}>{n.text}</li>)}
    </ul>
  );
}

Разбор: Классический stale closure. notifications и dismissed захвачены в момент первого рендера и никогда не обновляются внутри setInterval. Компонент будет работать некорректно при изменении notifications или dismissed.

Исправление:

useEffect(() => {
  const timer = setInterval(() => {
    // Используем функциональное обновление для dismissed
    setDismissed(currentDismissed => {
      if (notifications.length > currentDismissed.length) {
        return [...currentDismissed, notifications[currentDismissed.length].id];
      }
      return currentDismissed;
    });
  }, 2000);

  return () => clearInterval(timer);
}, [notifications]); // добавляем notifications в зависимости

Функциональное обновление setDismissed получает актуальное currentDismissed от React, а notifications добавляется в зависимости эффекта — при его изменении интервал пересоздаётся.

Как проверить своё понимание

После решения каждой задачи задайте себе три вопроса:

  • Какие переменные захвачены в замыкании?
  • Когда создаётся замыкание и когда вызывается?
  • Есть ли риск stale closure или случайного разделения состояния?

Если вы можете ответить на эти вопросы для любого фрагмента кода — замыкания больше не будут источником неожиданных багов. Они станут инструментом, которым вы управляете осознанно.