Замыкания в JavaScript: что это и как использовать на практике

Когда функция помнит всё

Замыкания в JavaScript: что это и как использовать на практике

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

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

Вот про всё это сегодня и поговорим.

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

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

Определение и основные понятия

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

Чтобы это стало по-настоящему понятно, нужно познакомиться с такими ключевыми понятиями:

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

Всё это дальше разберём пошагово: от простых понятий до практических примеров.

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

Когда в коде создаётся функция, она не просто появляется в вакууме. Вместе с ней создаётся невидимый «контекст» — объект, в котором хранятся все переменные, доступные на момент создания функции. Этот объект называется лексическим окружением (Lexical Environment).

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

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

Если коротко:

  • Лексическое окружение создаётся при каждом запуске функции.
  • Функция всегда замыкает переменные из того контекста, в котором объявлена, а не вызвана.

Область видимости

Мы разобрались, что функции цепляются за окружение, а теперь посмотрим, где эти переменные вообще живут и когда к ним можно обратиться.

Глобальная и локальная область видимости

Область видимости (scope) — это та зона, в которой доступна переменная. 

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

const planet = 'Земля';
function greet() {
// Видит planet
  console.log(`Привет, ${planet}`); 
}

Переменная planet живёт глобально, и её видно отовсюду. Даже внутри функций.

А вот локальная область видимости появляется внутри функций:

function greet() {
  const name = Элли;
  console.log(`Привет, ${name}`);
}
// ReferenceError: name is not defined
console.log(name); 

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

Функциональная и блочная область видимости

Раньше в JavaScript были только два типа области видимости:

  • глобальная (всё снаружи);
  • функциональная (всё внутри функции).

Но потом в ES6 добавили let и const, и появилась блочная область видимости. Суть в том, что переменные, объявленные с let и const, живут только внутри блока, в котором они были созданы — между {}. Это могут быть условия (if), циклы (for, while) или просто любые фигурные скобки:

if (true) {
  let secret = 'никому не рассказывай';
}
// ReferenceError
console.log(secret); 

Как только закрылась фигурная скобка в условии, переменная, которая там объявлена, перестала быть видна. И больше к ней не добраться.

А вот с var это бы сработало, потому что var не видит блоки — только функции:

if (true) {
  var legacy = 'это работает';
}
console.log(legacy);

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

Вложенные функции и их контексты

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

// внешняя функция
function outer() {
// объявили переменную во внешней функции
  const hero = 'Сэм';
// внутренняя функция
  function inner() {
    const tool = 'меч';
    // Сэм использует меч
    console.log(`${hero} использует ${tool}`); 
  }
  inner();
}

Но наоборот это не работает. Внешняя функция не видит то, что внутри вложенной:

function outer() {
  function inner() {
// объявили переменную во внутренней функции
    const secret = 'тс-с-с!';
  }
  console.log(secret); // ReferenceError
}

Такое поведение и делает возможными замыкания: внутренняя функция запоминает всё, что было вокруг неё в момент создания.

Любая вложенная функция может стать замыканием — если используется позже и продолжает ссылаться на переменные родительской области.

Вам может быть интересно:

Как работают замыкания

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

Контекст выполнения

Когда JavaScript запускает код, он создаёт так называемый контекст выполнения (execution context). Это «среда обитания» для каждой функции, где хранится всё, что ей нужно: переменные, аргументы, ссылки на внешние вещи.

Контекст выполнения бывает двух типов:

  • Глобальный — создаётся один раз при запуске скрипта.
  • Функциональный — создаётся каждый раз при вызове функции.

Внутри каждого контекста создаётся лексическое окружение — то самое, где хранятся переменные. Когда функция вызывается, она получает ссылку на своё окружение и цепочку внешних окружений.

Посмотрим на примере:

function outer() {
  const message = 'Я из внешней функции';
  function inner() {
   // inner «видит» message, потому что замкнул окружение
    console.log(message); 
  }
  return inner;
}
const fn = outer();
// Я из внешней функции
fn();

Вот что произошло:

  • Сначала выполнился outer(), в его лексическом окружении появилась переменная message.
  • Затем мы вернули функцию inner, но она не потеряла доступ к message, потому что замкнула его в своём контексте.

Алгоритм выполнения функций

Чтобы понять замыкания на уровне движка, нужно знать, что происходит при вызове функции:

  1. Создаётся новый контекст выполнения.
  2. Внутри него формируется лексическое окружение, где:
  3. В первый блок попадают локальные переменные (environment record).
  4. Во второй — ссылка на внешнее окружение (enclosing environment).
  5. При обращении к переменной движок идёт по цепочке областей видимости (scope chain) — от внутреннего окружения наружу, пока не найдёт нужную переменную или не дойдёт до глобального уровня.

Посмотрим на пример кода:

let a = "Hey";
function first() {
  let b = "Hello";
  function second() {
    let c = "Hi";
    console.log(a + b + c);
  }
  second();
}
first();

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

Когда вызывается first(), создаётся новое лексическое окружение (область видимости функции first()). В нём есть доступ к переменным:

  • b — локальная переменная first
  • a — глобальная переменная

Затем внутри first() вызывается second(). И у second() тоже появляется своё окружение:

  • c — локальная переменная second
  • b — переменная из first()
  • a — переменная из глобальной области.

Когда second() выполняется и доходит до console.log(a + b + c), она ищет каждую переменную в своём лексическом окружении:

  • cв second()
  • bв first()
  • a глобальной области

Это и есть scope chain — цепочка, по которой движок поднимается, чтобы найти нужные переменные.

Когда мы создаём second() внутри first(), она живёт внутри лексического окружения функции first. Как только first завершает выполнение, её внутренние переменные (включая second) исчезают из области видимости.

Примеры использования замыканий

Замыкания реально нужны в повседневной работе, потому что с их помощью можно делать много всего:

  • создавать фабрики функций и возвращать настраиваемое поведение;
  • инкапсулировать данные, эмулируя приватные переменные и методы (особенно до появления # в классах);
  • сохранять значения в асинхронных операциях и циклах, где важно захватить состояние в нужный момент;
  • строить модули с приватной логикой, не засоряя глобальное пространство;
  • и просто писать чистый, безопасный код, где посторонние не смогут влезть и что-то поломать.

Дальше рассмотрим пару практических примеров.

Пример 1: Приватные переменные

В JS нет настоящих приватных переменных (если не использовать # в классах), но замыкания позволяют их эмулировать.

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

function createClickTracker() {
// Эта переменная — приватная. Она живёт внутри функции и недоступна извне
  let count = 0; 
  return {
    // Метод для увеличения счётчика
    increment() {
      count++;
      console.log(`Кликов: ${count}`);
    },
    // Метод для сброса счётчика
    reset() {
      count = 0;
      console.log("Счётчик сброшен");
    }
  };
}
const tracker = createClickTracker();
// Кликов: 1
tracker.increment(); 
// Кликов: 2
tracker.increment(); 
// undefined — переменная count остаётся приватной
console.log(tracker.count); 

Что тут происходит:

  • Переменная count создаётся внутри createClickTracker и остаётся «живой» после её выполнения, потому что на неё ссылаются возвращённые методы (increment, reset).
  • Это и есть замыкание — функции помнят своё окружение.
  • Снаружи нельзя изменить count, только вызывать методы, которые мы разрешили. Это защищает данные от случайных (или преднамеренных) изменений.

Такой паттерн отлично работает, когда нужно скрыть внутренние данные модуля, чтобы не засорять глобальное пространство имён. Или когда вы пишете утилиту, которая должна управлять состоянием, но не давать к нему прямой доступ.

Пример 2: Эмуляция частных методов

Иногда нужно не просто скрыть переменную, но и спрятать внутреннюю логику — функцию, которую не должны вызывать напрямую. Такой себе приватный метод. Например, в Java или C# это делается с модификатором private, а JS — через замыкания. 

Допустим, мы создаём модуль, который генерирует одноразовый токен. Пользователь может получить его только один раз. Внутренняя логика генерации и проверки не должна вылезать наружу — только публичный метод getToken().

function createTokenManager() {
  // сгенерировали токен
  const token = generateToken(); 
   // флаг, чтобы отследить одноразовость 
  let used = false;
  // приватная функция, её не видно снаружи
  function generateToken() {
    // псевдоуникальный токен
    return Math.random().toString(36).substring(2); 
  }
  return {
    getToken() {
      if (used) {
    // токен уже использован
        return null; 
      }
      used = true;
      return token;
    }
  };
}
const manager = createTokenManager();
console.log(manager.getToken()); // случайный токен
console.log(manager.getToken()); // null — повторно получить нельзя

Что здесь происходит:

  • generateToken() и переменная used недоступны извне — они приватные благодаря замыканию.
  • Публичный метод getToken() имеет к ним доступ и сам решает, когда и что можно отдать.
  • Всё похоже на односторонний шлюз: можно вызвать функцию, но нельзя лезть в её внутренности.

В реальном проекте это нужно для безопасности, контроля состояния и вынесения логики в отдельный модуль. Так мы можем спрятать сложную внутреннюю реализацию за лаконичным публичным API.

Пример 3: Замыкания в циклах

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

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

// Получаем все кнопки на странице
const buttons = document.querySelectorAll("button");
// Проходим по каждой кнопке
for (let i = 0; i < buttons.length; i++) {
  // Вешаем обработчик клика
  buttons[i].addEventListener("click", () => {
    // При клике выводим номер кнопки (i)
    console.log("Нажата кнопка №", i);
  });
}

Здесь всё работает как положено: каждая функция получает своё значение i — потому что let создаёт новую область видимости на каждой итерации. Это и есть замыкание в действии: обработчик замыкается на своё значение i.

Ошибки и распространённые проблемы

Хотя замыкания — полезный инструмент, с ними легко переборщить. Особенно это касается производительности. Обычно проблемы такие:

  1. Избыточные замыкания — когда создаются новые функции в цикле, в конструкторе или при каждом рендере, хотя можно было обойтись одной. Это расходует лишнюю память и снижает производительность.
  2. Утечки памяти — когда замыкаются «тяжёлые» объекты или DOM-элементы, которые потом уже не нужны, но всё ещё висят в памяти из-за ссылки внутри замыкания.

В общем, не всё, что можно замкнуть, нужно замыкать.

Ошибка: все обработчики показывают одно и то же

Допустим, мы делаем форму с полями и хотим, чтобы при фокусе на поле показывалась подсказка. Логично? Логично. И вот как это может выглядеть:

function showHelp(text) {
  document.getElementById("help").textContent = text;
}
function setupHelp() {
  // Массив с id полей и их подсказками
  const helpText = [
    { id: "email", help: "Ваш мейл" },
    { id: "name", help: "Ваше имя" },
    { id: "age", help: "Ваш возраст (16+)" },
  ];
 // переменная объявлена один раз, вне цикла
  var item; 
  for (var i = 0; i < helpText.length; i++) {
   // на каждой итерации item просто перезаписывается
    item = helpText[i]; 
    // Вешаем обработчик на фокус элемента
    document.getElementById(item.id).onfocus = function () {
      // Здесь происходит замыкание на переменную item,
      // но так как она одна на весь цикл, все функции будут видеть последнее значение `item`
      showHelp(item.help);
    };
  }
}

Возникает проблема — несмотря на то что мы ожидаем разную подсказку на каждом поле, при фокусе на любом из них показывается последнее сообщение («Ваш возраст (16+)»).

Почему так происходит:

  • Мы используем var, а значит, переменная item — одна и та же на каждую итерацию.
  • К моменту, когда пользователь фокусируется на поле, цикл уже давно закончился, а item остался на последнем значении.
  • Все обработчики ссылаются на один и тот же item — и, соответственно, на один и тот же item.help.

Самое простое решение — использовать let, который создаёт новую переменную в каждой итерации цикла:

 for (let i = 0; i < helpText.length; i++) {
        // каждая итерация создаёт свою копию item
       const item = helpText[i]; 
       document.getElementById(item.id).onfocus = function () {
        // каждая функция помнит свой item
         showHelp(item.help); 
       };
     }
   }

Или использовать метод forEach, где каждая итерация — это новая функция и свой контекст:

helpText.forEach(item => {
  document.getElementById(item.id).onfocus = function () {
    showHelp(item.help);
  };
});

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

Ошибка: замыкание на DOM-элемент снаружи функции

Допустим, мы хотим по клику на кнопку показать её текст через setTimeout с небольшой задержкой:

const buttons = document.querySelectorAll("button");
buttons.forEach((btn) => {
  const text = btn.textContent;
  btn.addEventListener("click", () => {
    setTimeout(() => {
      alert(`Вы нажали: ${text}`);
    }, 500);
  });
});

На первый взгляд всё норм: мы замкнули text и через 500 мс покажем, что именно нажали. Но вот в чём подвох.

Если textContent этой кнопки изменится до выполнения setTimeout — например, другой скрипт изменил текст, или пользователь что-то сделал, — то text уже не будет содержать старое значение, которое мы ожидали.

Всё потому, что textContent — это не «зафиксированная копия», а ссылка на живое свойство DOM-элемента, и если оно изменится — мы увидим изменённое значение.

Если взять значение textContent прямо внутри setTimeout — это не решит проблему, если текст уже поменялся. Чтобы избежать этого, нужно сохранить значение в примитив (строку, число и т. д.) заранее — тогда оно будет надёжно замкнуто и не изменится:

const buttons = document.querySelectorAll("button");
buttons.forEach((btn) => {
  btn.addEventListener("click", () => {
   // копируем именно в момент клика
    const text = btn.textContent; 
    setTimeout(() => {
      alert(`Вы нажали: ${text}`);
    }, 500);
  });
});

Теперь text — это локальная переменная, созданная внутри обработчика, а не снаружи. Она фиксирует значение textContent на момент клика, и даже если потом текст изменится — в setTimeout мы получим именно нужное.

Оптимизация производительности

Подытожим:

Не создавайте замыкания в цикле без необходимости. Если можно вынести функцию наружу — выносим. Каждый новый проход цикла + новая функция = лишняя память.

Избегайте замыканий на «тяжёлые» объекты. Например, не замыкайте на DOM-элементы или большие структуры данных, если они скоро будут удалены. Это создаёт риск утечки памяти.

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

Следите за таймерами, обработчиками событий и колбэками. Если замыкаете данные внутри setTimeout, addEventListener, setInterval — не забывайте убирать эти обработчики, если элемент удаляется.

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

Бонус для читателей

Если вам интересно погрузиться в мир ИТ и при этом немного сэкономить, держите наш промокод на курсы Практикума. Он даст вам скидку при оплате, поможет с льготной ипотекой и даст безлимит на маркетплейсах. Ладно, окей, это просто скидка, без остального, но хорошая. 

Вам слово

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

Обложка:

Алексей Сухов

Корректор:

Александр Зубов

Вёрстка:

Егор Степанов

Соцсети:

Юлия Зубарева

Вам может быть интересно
hard
[anycomment]
Exit mobile version