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

Замыкания в лямбда-вычислениях

Замыкания лежать в основе функционального программирования и даже в основе ООП. Изначально они были математической концепцией из области лямбда-вычислений, получившей воплощение во многих языка программирования, особенно в функциональных Haskell, Lisp, ML и т.д.

В упрощённом виде, основные идеи лямбда-вычислений таковы:

  1. Функции - это примитивная единицы вычисления. Все прочие данные кодируются в них;

  2. Единственная задача функции, это применение к аргументу: принять аргумент, вернуть вычисленное с его участием значение;

  3. Функции анонимны, их описывают лямбда-абстракции вида: λx. x + 5 означающие: "функция от x, возвращающая x + 5". Функция состоит из пары: параметры + тело;

  4. Функция возвращающая или принимающая другую функцию, называется функцией высшего порядка.

Разработчик не сильно знакомый с математикой, сейчас такой: "Хмммм где-то я всё это слышал! Но где?!".

Да как-же отцентровать этот div
Да как-же отцентровать этот div

Теперь Вы знайте базу, о функциях лежащий в основе любого языка программирования и которые Вы каждый день пишите! Все их хорошо знакомые особенности, вышли из математической концепции, американского математика Алонзо Чёрча, разработанной в 30-х годах прошлого века.

Алонзо Чёрч
Алонзо Чёрч

Вернёмся к нашим баранам замыканиям. В теории лямбда-исчислений у функции есть окружение состоящее из свободных переменных, которая используется в функции, но не является её параметром и не объявлена внутри неё. Например в выражении λx. x + y:

  1. x - параметр функции (связанная переменная);

  2. y - свободная переменная, откуда-то из внешнего окружения.

Но что если наша лямбда функция λx. x + y попадёт в другой контекст, например станет частью функции высшего порядка? Как быть со свободной переменной y? Тут то на сцене и появляется замыкание, фиксирующее значение свободных переменных, в момент создания функции. Теперь наша функция будет вести себя стабильно и предсказуемо в любом контексте.

Для возникновения замыкания, функцию нужно вызвать, не в той области видимости, в которой она была объявлена. Реализуем пример, рассмотренную выше лямбда функцию на JS:

function counter() {
  let count = 0 // Свободная переменная для внутренней функции
  // Функция лямбда-абстракция
  return function(step) {
    count = count + step
  
    return count
  }
}

const myCounter = counter() // Замыкание состоящее из внутренней функции и её лексического окружения в виде переменной count

console.log(myCounter(1)) // 1
console.log(myCounter(1)) // 2
console.log(myCounter(1)) // 3

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

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

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

Замыкания и ООП

С ООП замыкания связывает некоторые общие принципы. По сути в примере со счётчиком реализованным через замыкания:

  1. Переменная count - это приватное поле;

  2. Сама функция counter - это публичный интерфейс, работающий с приватным состоянием.

Это позволяет нам реализовать полноценный объект с приватным состоянием, при помощи замыканий:

function Counter(initialValue = 0) {
  let count = initialValue // Приватное свойство
  // Публичные методы
  return {
    increment: function(step = 1) {
      return count += step
    },
    decrement: function(step = 1) {
      return count -= step
    },
    getValue: function() {
      return count
    },
    reset: function() {
      return count = initialValue
    }
  }
}

const myCounter = Counter(10) // Создаём экземляр объекта

console.log(myCounter.getValue()) // 10
console.log(myCounter.increment(5)) // 15
console.log(myCounter.increment(2)) // 17
console.log(myCounter.decrement(3)) // 14
console.log(myCounter.reset()) // 10

Выше при помощи связки функция + замыкание мы создали настоящий класс, который в нативном виде выглядел бы так:

class Counter {
  #count
  #initValue

  constructor(initialValue = 0) {
    this.#count = initialValue
    this.#initValue = initialValue
  }

  increment(step = 1) {
    return this.#count += step
  }

  decrement(step = 1) {
    return this.#count -= step
  }

  getValue() {
    return this.#count
  }

  reset() {
    return this.#count = this.#initValue
  }
}

const myCounter = new Counter(10)

Замыкание в фреймворках

Матёрый фронтенедер посмотрев на пример с классом из функции, увидит в нём что-то знакомое и родное. Да это же типичный стор из Pinia или Vuex, где наш пример мог выглядеть так:

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const initValue = 0

  function increment(step) {
    count.value + step
  }

  function decrement(step) {
    count.value - step
  }

  function reset () {
    count.value = initValue
  }

  return {count, increment, decrement, reset}
})

По сути Pinia использует замыкания для инкапсуляции приватного состояния стора внутри объекта синголтона. Вот так вот, мы за несколько шагов дошли от математической абстракции, практически столетней давности, до современного JS фреймворка и всё что их объединяет, это замыкания!

Замыкание в стрелочных функциях

Замыкания в стрелочных функциях работает так-же как и в обычных:

const counter = () => {
  let count = 0

  return (step) => {
    count = count + step

    return count
  }
}

const myCounter = counter()

console.log(myCounter(1)) // 1
console.log(myCounter(1)) // 2
console.log(myCounter(1)) // 3

Замыкание в циклах

Замыкание в цикле for

Появится ли замыкание в цикле for зависит как мы объявим итератор. Если через var, то замыкания не будет:

for (var i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i) // 3, 3, 3 — а не 0, 1, 2
  }, 100)
}

В консоли мы увидим 3 3 3, так как на момент срабатывания setTimeout() цикл завершит свою работу и будет работать с итоговым значением переменной i.

Но вот если переменную объявить через let, то картина будет другой:

for (let i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i) // 0, 1, 2
  }, 100)
}

let создаёт отдельное замыкание, со своим лексическим окружением, для каждой итерации. Поэтому каждая итерация ссылается на своё уникальное состояние. Тоже самое может случиться если навесить обработчик события используя var:

var buttons = document.querySelectorAll('div')

for (var i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener('click', function() {
    console.log('Нажата кнопка ' + i)
  })
}

В примере выше при клике по любому из дивов, всегда будет выводится одно и тоже сообщение: Нажата кнопка 4, так как 4 это последнее значение переменной на момент завершения работы цикла. А если поменять var, на let, то всё будет работать корректно и в консоль будет выводится индекс элемента по которому кликнул пользователь:

var buttons = document.querySelectorAll('div')

for (let i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener('click', function() {
    console.log('Нажата кнопка ' + i)
  })
}

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

Итераторы forEachmapfilter создают замыкания во время своей работы, поэтому примеры с таймером, показывают разные значения на каждой итерации:

[0, 1, 2].forEach(function(i) {
  setTimeout(function() {
    console.log(i) // 0, 1, 2
  }, 100)
})

[0, 1, 2].map(function(i) {
  setTimeout(function() {
    console.log(i); // 0, 1, 2
  }, 100)
})

[0, 1, 2].filter(function(i) {
  setTimeout(function() {
    console.log(i) // 0, 1, 2
  }, 100)

  return true
})

Тут объяснить такое поведение проще простого. Данные методы на каждой итерации вызывают переданную коллбэк функцию, со своим окружением в виде переменной i, на которую и замыкается коллбэк функция внутри setTimeout().

Замыкания в коллбэках

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

Замыкания при Ajax-запросах

Замыкания легко встретить в Ajax-запросах:

function ajaxRequest(url) {
  let requestId = Math.random().toString(36).substring(2) // Генерим уникальный индетификатор для запроса

  fetch(url).then(response => {
      if (!response.ok) {
        throw new Error('Чёт пошло не так!')
      }

      return response.json()
    }).then(data => {
      console.log(`Запрос ${requestId} успешно завершён:`, data) // Стрелочная функция замыкает requestId
    }).catch(error => {
      console.log(`Запрос ${requestId} провалился:`, error.message) // И тут тоже замыкание
    })
}

ajaxRequest('https://jsonplaceholder.typicode.com/todos/1')
ajaxRequest('https://jsonplaceholder.typicode.com/todos/2')

В пример выше коллбэки в then() и catch() замыкаются на переменной requestId из внешней функции и поэтому когда с сервера приходит асинхронный ответ, в консоль выведется айдишник соответствующего запроса.

Замыкание в обработчиках событий

Напишем простой обработчик событий со своим лексическим окружением:

function clickSetup(elemId, message) {
  const elem = document.getElementById(elemId)

  elem.addEventListener('click', function() {
    console.log(message)
  })
}

clickSetup('myElem', 'По элементу кликнули!')

В примере выше коллбэк функция передаваемая обработчику события, замыкается на аргументе message, в момент подписки на событие, выводя его значение при клике, происходящем уже после того как функция clickSetup() отработает.

Оптимизация замыканий

Современные движки JavaScript исключают из области видимости замыкания, любые переменные к котором нет явного обращения. Делается это ради оптимизации, так как замыкания порождаемые функциями обратного вызова, используемыми в JS повсеместно, со временем начинали потреблять слишком много памяти. Это легко проверить запустив код с деббагером:

function greeting() {
  let hello = 'Привет'
  let userName = 'Васятка'

  setTimeout(function() {
    debugger
    
    console.log(hello)
  }, 1000)
}

greeting()

Пример выше нужно запустить с открытыми инструментами разработчика (F12), тогда деббагер остановит его на 6-й строке. Далее нужно перейти на вкладку Источники (бурж. Sources), далее справа открыть Области действия (бурж. Scope), затем открыть Замыкание (бурж. Closure) и там будет только переменная hello:

Оптимизация замыканий
Оптимизация замыканий

Так-же если перейти на вкладку Консоль (бурж. Console), и там ввести имя переменной hello, то в ответ консоль выведет её значение. Если попробовать проделать, тот-же самый трюк с переменной userName, то Вы получите ошибку: Uncaught ReferenceError: userName is not defined. Это говорит о том что она была удалена сборщиком мусора и больше не доступна в приложении.

Короткое замыкание

Если внутренняя функция будет обращаться ко внешнем переменным, но сама не будет вызвана:

function greeting(studentName) {
    return () => {
        console.log(`Привет ${studentName}`)
    }
}

greeting('Васятка') // Здесь нет замыканий!

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

Цепочка замыканий

Замыкания могут выстраиваться в целые цепочки:

function createCalculator(initValue) {
  let value = initValue

  function add(amount) {
    value += amount

    return value
  }

  function get() {
    return value
  }

  function apply(number) {
    if (arg) {
      return add(number)
    } else {
      return get()
    }
  }

  return apply
}

const calc = createCalculator(10)

console.log(calc(5)) // 15
console.log(calc()) // 15
console.log(calc(3)) // 18

В примере выше внешняя функция createCalculator() создаёт лексическое окружение, с переменной value и возвращает внутреннюю функцию apply(). Сама функция apply() не имеет прямого доступа к переменной value, но вызывает другие внутренние функции, имеющие к ней доступ и таким образом получается цепочка замыканий.

Передача замыканий

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

function greeting(helloPhrase) {
  let userName = 'Васятка'

  return function() {
    console.log(`${helloPhrase}, ${userName}!`)
  }
}

function executeCallback(callback) {
  callback()
}

executeCallback(greeting('Hola')) // Передаём замыкание в другую функцию

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

Польза замыканий

Напишем простенький модуль для работы с формой, основанный на замыканиях:

 function createFormController(formName) {
  // Кэшируем все нужные элементы формы
  const form = document.querySelector(`[name="${formName}"]`)
  const name = form.querySelector('[name="name"]')
  const email = form.querySelector('[name="email"]')
  const button = form.querySelector('[type="submit"]')
  const message = form.querySelector('.message')

  let isSubmitting = false

  function showMessage(messageText, isError = false) {
    message.textContent = messageText
    message.style.display = 'block'
  
    if (isError && !message.classList.contains('message_error')) {
      message.classList.add('message_error')
    } else {
      message.classList.remove('message_error')
    }
  }

  function getData() {
    return JSON.stringify({
      name: name.value,
      email: email.value
    })
  }

  function reset() {
    name.value = ''
    email.value = ''
    message.style.display = 'none'
  }

  function setSubmitting(submitting) {
    isSubmitting = submitting

    if (button) {
      button.disabled = submitting
      button.value = submitting ? 'Отправка...' : 'Отправить'
    }
  }

  function validate() { // Простая валидация
    if (!name.value) {
      showMessage('Введите имя', true)

      return false
    }

    if (!email.value.includes('@')) {
      showMessage('Введите корректный email', true)

      return false
    }

    message.style.display = 'none'

    return true
  }

  form.addEventListener('submit', (event) => {
    event.preventDefault()

    if (!validate()) {
      return false
    }

    setSubmitting(true)

    fetch('https://jsonplaceholder.typicode.com/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: getData()
    }).then((result) => {
      setSubmitting(false)
      reset()

      if(result.ok) {
        showMessage('Форма отправлена!')
      } else {
        showMessage('Ошибка при отправке формы!')
      }
    })
  })
} 

const contactForm = createFormController('callback')

Данный модуль отлично демонстрирует все преимущества которые даёт использование замыканий:

  1. Кэширование. Поиск по DOM дереву это дорогая операция, наш модуль знает об этом, поэтому кэширует элементы формы внутри замыкания. Затем методы данного модуля используют эти ссылки, а не выполняют каждый раз поиск по дереву DOM, тем самым экономя ресурсы;

  2. Инкапсуляция. Данный модуль предоставляет для пользования только один внешний интерфейс, в виде функции createFormController(). Вся остальная логика скрыта внутри него, что сильно повышает стабильность кода.

  3. Декомпозиция. По факту наш импровизированный модуль, это обычная функция createFormController(). Но за счёт использования замыкании и внутренних функций, мы смогли избежать спагетти кода, разбив его на небольшие внутренние функции. Это сильно улучшает читаемость кода и позволяет соблюдать SRP (бурж. Single Responsibility Principle - Принцип единой ответственности). Вместо одной мега-функции полностью отвечающей за всё что происходит с формой, код разбит на множество компактных частей, каждая из которых выполняет, только одно небольшое действие.

Вывод

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