Замыкания в 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.


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

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

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