Замыкания в JavaScript: задачи для реального понимания

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

Разбираем 12 задач, которые действительно проверяют понимание замыканий.


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

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

Лексическое окружение

Каждый вызов функции создаёт лексическое окружение (lexical environment) — объект, в котором хранятся переменные этой функции и ссылка на лексическое окружение родителя.

function outer() {
  const x = 10;

  function inner() {
    console.log(x); // x берётся из лексического окружения outer
  }

  inner();
}

outer();

При вызове outer() создаётся окружение { x: 10, parent: globalEnv }. Когда inner ищет x, она смотрит в своё окружение (пустое), потом в outer (находит x = 10).

Цепочка областей видимости

Поиск переменных идёт вверх по цепочке лексических окружений:

const global = "global";

function level1() {
  const level1Var = "level1";

  function level2() {
    const level2Var = "level2";

    function level3() {
      console.log(global); // → globalEnv
      console.log(level1Var); // → level1Env
      console.log(level2Var); // → level2Env
    }

    level3();
  }

  level2();
}

level1();

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

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

Замыкание создаётся в момент объявления функции, а не вызова:

function create() {
  const secret = 42;

  return function () {
    return secret;
  };
}

// Здесь secret уже «запомнена»
const fn = create();

// А вызываем мы функцию позже
fn(); // 42

В момент return function () { ... } движок JavaScript связывает эту функцию с лексическим окружением create. Даже после завершения create окружение не удаляется — на него ссылается возвращённая функция.

По ссылке, а не по значению

Замыкание хранит ссылку на переменную, а не её копию:

function counter() {
  let count = 0;

  return {
    increment: () => ++count,
    getCount: () => count,
  };
}

const c = counter();
c.increment();
c.increment();
c.getCount(); // 2

Оба метода (increment и getCount) ссылаются на одну и ту же переменную count. Изменяя count в increment, мы видим это в getCount.

Вот обратный пример — замыкание захватывает ссылку, а значение вычисляется позже:

function createFunctions() {
  const arr = [];

  for (var i = 0; i < 3; i++) {
    arr.push(() => i);
  }

  return arr;
}

const [f0, f1, f2] = createFunctions();
f0(); // 3
f1(); // 3
f2(); // 3

Все три функции ссылаются на одну i. К моменту вызова цикл завершился, и i === 3. Чтобы исправить — используйте let (создаёт новую переменную на каждой итерации) или IIFE (создаёт новое окружение).

Зачем нужны замыкания

Инкапсуляция — приватные переменные без классов:

function createBankAccount(initial) {
  let balance = initial;

  return {
    deposit: (amount) => (balance += amount),
    getBalance: () => balance,
  };
}

const account = createBankAccount(100);
account.deposit(50);
account.getBalance(); // 150
account.balance; // undefined

Фабрики функций — генерация функций с предустановленными параметрами:

function createMultiplier(n) {
  return (x) => x * n;
}

const double = createMultiplier(2);
const triple = createMultiplier(3);
double(5); // 10
triple(5); // 15

Колбэки и обработчики — функция «помнит» контекст:

function setupButton(name) {
  const button = document.querySelector(`#${name}`);

  button.addEventListener("click", () => {
    console.log(`Button ${name} clicked`);
  });
}

setupButton("submit"); // name запомнена в замыкании

Частичное применение — предустановка аргументов:

function log(level, message) {
  console.log(`[${level}] ${message}`);
}

function createLogger(level) {
  return (message) => log(level, message);
}

const errorLog = createLogger("ERROR");
errorLog("Something broke"); // [ERROR] Something broke

Задача 1: Простой счётчик

Создайте функцию makeCounter, которая возвращает объект с методами increment, decrement и getCount. Счётчик начинается с 0.

function makeCounter() {
  let count = 0;

  return {
    increment: () => ++count,
    decrement: () => --count,
    getCount: () => count,
  };
}

const counter = makeCounter();
counter.increment(); // 1
counter.increment(); // 2
counter.decrement(); // 1
counter.getCount(); // 1

Почему это работает: count живёт в области видимости makeCounter. После вызова makeCounter() функция завершается, но count не удаляется — на него ссылаются три метода через замыкание.


Задача 2: Счётчик с начальным значением

Добавьте параметр start в makeCounter. Счётчик начинается с start.

function makeCounter(start) {
  let count = start;

  return {
    increment: () => ++count,
    decrement: () => --count,
    getCount: () => count,
  };
}

const counter = makeCounter(10);
counter.increment(); // 11
counter.getCount(); // 11

Ключевой момент: start — это параметр функции, он тоже попадает в замыкание. Но count — это отдельная переменная, которую можно менять независимо от start.


Задача 3: Замыкание в цикле (var)

Что выведет этот код?

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Вывод: 3, 3, 3

Почему: var создаёт одну переменную i на всю функцию. К моменту срабатывания setTimeout цикл уже завершился, и i === 3.


Задача 4: Замыкание в цикле (let)

Теперь с let:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Вывод: 0, 1, 2

Почему: let создаёт новую переменную i на каждой итерации. У каждого колбэка — своя копия i.


Задача 5: Замыкание в цикле с задержкой

Создайте массив функций, каждая из которых возвращает свой индекс. Используя var.

function createFunctions() {
  const functions = [];

  for (var i = 0; i < 3; i++) {
    functions.push(() => i);
  }

  return functions;
}

const [f0, f1, f2] = createFunctions();
f0(); // 3
f1(); // 3
f2(); // 3

Все три функции ссылаются на одну i, которая к моменту вызова равна 3.


Задача 6: Исправление замыкания в цикле

Исправьте задачу 5, чтобы f0() возвращал 0, f1() — 1, f2() — 2. Три варианта:

Вариант 1 — IIFE:

for (var i = 0; i < 3; i++) {
  functions.push(
    (
      (j) => () =>
        j
    )(i),
  );
}

Вариант 2 — bind:

functions.push((() => i).bind(null));
// Но bind не захватывает i по значению, это не сработает.
// Правильный bind-вариант:
for (var i = 0; i < 3; i++) {
  functions.push(
    (
      (val) => () =>
        val
    )(i),
  );
}

Вариант 3 — let (самый простой):

for (let i = 0; i < 3; i++) {
  functions.push(() => i);
}

Задача 7: Приватные переменные

Создайте BankAccount с приватным балансом, методами deposit, withdraw и getBalance. Баланс нельзя изменить напрямую.

function BankAccount(initialBalance) {
  let balance = initialBalance;

  return {
    deposit(amount) {
      if (amount > 0) balance += amount;
    },
    withdraw(amount) {
      if (amount > 0 && amount <= balance) balance -= amount;
    },
    getBalance() {
      return balance;
    },
  };
}

const account = BankAccount(1000);
account.deposit(500);
account.withdraw(200);
account.getBalance(); // 1300
account.balance; // undefined — нельзя получить напрямую

Зачем: До классов с # приватными полями это был стандартный способ инкапсуляции. Замыкание изолирует balance от внешнего мира.


Задача 8: Фабрика функций

Создайте фабрику createMultiplier, которая возвращает функцию-умножитель.

function createMultiplier(multiplier) {
  return (number) => number * multiplier;
}

const double = createMultiplier(2);
const triple = createMultiplier(3);

double(5); // 10
triple(5); // 15

Паттерн: Замыкание как способ «запомнить» параметр. Функция double не знает про createMultiplier, но multiplier живёт внутри неё.


Задача 9: Замыкание и this

Что выведет этот код?

function createGreeter(greeting) {
  return function (name) {
    console.log(`${greeting}, ${name}!`);
  };
}

const greetHello = createGreeter("Hello");
greetHello("Alice"); // "Hello, Alice!"

А теперь с объектом:

const user = {
  greeting: "Hi",
  greet: createGreeter(this.greeting),
};

user.greet("Bob"); // "undefined, Bob!"

Почему: this.greeting вычисляется в момент вызова createGreeter, когда this — глобальный объект (или undefined в strict mode). Замыкание запомнило undefined, а не "Hi".


Задача 10: Рекурсивное замыкание (независимые счётчики)

Создайте функцию makeCounters(n), которая возвращает массив из n независимых счётчиков.

function makeCounters(n) {
  const counters = [];

  for (let i = 0; i < n; i++) {
    let count = 0;
    counters.push({
      increment: () => ++count,
      getCount: () => count,
    });
  }

  return counters;
}

const [c1, c2, c3] = makeCounters(3);
c1.increment();
c1.increment();
c1.increment();
c2.increment();
c3.increment();
c3.increment();

c1.getCount(); // 3
c2.getCount(); // 1
c3.getCount(); // 2

Каждый count — отдельная переменная в отдельном замыкании.


Задача 11: Замыкание в async/await

function createFetcher(url) {
  return async () => {
    const response = await fetch(url);
    return response.json();
  };
}

const fetchUsers = createFetcher("https://api.example.com/users");
const user = await fetchUsers();

Что происходит: createFetcher возвращает функцию, которая захватывает url. Позже, при вызове, она использует этот url для запроса. Замыкание работает даже с async/await.


Задача 12: Замыкание + memoize

Реализуйте мемоизацию с помощью замыкания:

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

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

    if (cache.has(key)) {
      return cache.get(key);
    }

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

const expensiveAdd = memoize((a, b) => {
  console.log("Computing...");
  return a + b;
});

expensiveAdd(1, 2); // "Computing..." → 3
expensiveAdd(1, 2); // 3 (без вычисления)

Замыкание хранит cache — и внешний мир не может до него добраться.


Задача 13: Замыкание и замыкание

Что выведет этот код?

function outer() {
  let x = 10;

  function inner() {
    console.log(x);
  }

  x = 20;
  return inner;
}

outer()(); // 20

Почему: Замыкание захватывает ссылку на переменную, а не значение. Когда inner вызывается, x уже равно 20.


Задача 14 (бонус): Замыкание с промисами

function createTask(name, delay) {
  return () =>
    new Promise((resolve) => {
      setTimeout(() => resolve(name), delay);
    });
}

const task1 = createTask("first", 100);
const task2 = createTask("second", 50);

const results = await Promise.all([task1(), task2()]);
results; // ["first", "second"]

Каждая задача «запомнила» своё имя и задержку.


Типичные ошибки

Забыли про var в цикле

// Плохо
for (var i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 100);
}
// Выведет 5, 5, 5, 5, 5

// Хорошо
for (let i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 100);
}
// Выведет 0, 1, 2, 3, 4

Перезаписали замыкание

function createFunctions() {
  const result = [];

  for (let i = 0; i < 3; i++) {
    let fn = () => i;
    result.push(fn);
    fn = null; // Перезаписали — но result уже хранит ссылку на fn
  }

  return result;
}

createFunctions()[0](); // 0 — работает, потому что result[0] ссылка на fn

Утечка памяти

function createHeavy() {
  const hugeArray = new Array(1000000).fill("data");

  return () => hugeArray.length;
}

const fn = createHeavy(); // hugeArray живёт, пока жив fn

Если fn больше не нужен — обнулите его: fn = null.


Где практиковаться

Замыкания — это один из тех концептов, которые «понятны в теории, но ломаются на практике». Попробуйте решить задачи на Девстанции — там есть десятки задач, которые проверяют, как вы понимаете область видимости и замыкания в реальных ситуациях.

А если готовитесь к собеседованию — посмотрите чеклист из 50 вопросов и 10 задач с разбором.