Scope, Lexical Environment и Closures в JavaScript

Зачем frontend-разработчику знать эту тему

scope, lexical environment и closures лежат в основе того, как JavaScript работает с переменными, функциями и доступом к данным.

Эта тема важна не только на уровне языка. Она напрямую влияет на повседневный frontend-код:

  • на обработчики событий;
  • на таймеры и асинхронные колбэки;
  • на работу модулей;
  • на инкапсуляцию состояния;
  • на фабрики функций;
  • на composables и hooks-подобные паттерны;
  • на читаемость и предсказуемость поведения кода.

Если не понимать эти механизмы глубоко, легко столкнуться с типичными проблемами:

  • переменная неожиданно недоступна;
  • функция использует “не то” значение;
  • в цикле все колбэки ведут себя одинаково;
  • старые данные удерживаются в памяти дольше, чем нужно;
  • логика становится слишком неявной.

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


Простое объяснение

Начнем с трех базовых понятий.

Scope

Scope — это область видимости переменной.

Он отвечает на вопрос: где в коде к этой переменной можно обратиться.

Простой пример:

const appName = "Knowledge Base"

function printAppName(): void {
  console.log(appName)
}

printAppName()

Здесь appName доступна внутри printAppName, потому что функция находится в области, где эта переменная видна.

Другой пример:

function printUser(): void {
  const userName = "Pavel"

  console.log(userName)
}

printUser()

console.log(userName) // ReferenceError

Переменная userName существует только внутри функции printUser. Снаружи ее нет.


Lexical environment

Lexical environment — это внутренний механизм JavaScript, который хранит переменные текущей области и ссылку на внешнюю область.

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

То есть JavaScript ориентируется на структуру кода.


Closure

Closure — это функция, которая сохраняет доступ к переменным из внешнего lexical environment, в котором она была создана.

Проще говоря:

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

Пример:

function createCounter(): () => number {
  let count = 0

  return function increment(): number {
    count += 1

    return count
  }
}

const counter = createCounter()

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

Хотя createCounter уже завершилась, переменная count продолжает существовать, потому что возвращенная функция использует ее.


Как это работает внутри

Теперь разберем механику подробнее.

1. Во время выполнения кода создаются области видимости

В JavaScript есть несколько уровней областей видимости:

  • глобальная;
  • функциональная;
  • блочная.

Глобальная область

Переменные, объявленные на верхнем уровне, доступны из более внутренних областей.

const apiUrl = "/api"

function fetchData(): void {
  console.log(apiUrl)
}

Функциональная область

Переменные, объявленные внутри функции, видны только внутри этой функции.

function processOrder(): void {
  const orderId = "123"

  console.log(orderId)
}

Блочная область

let и const создают область видимости внутри блока {}.

if (true) {
  const message = "inside block"

  console.log(message)
}

console.log(message) // ReferenceError

2. У каждой функции есть ссылка на внешнее окружение

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

Это и делает возможным поиск переменных по цепочке.

Пример:

const globalValue = "global"

function outer(): void {
  const outerValue = "outer"

  function inner(): void {
    const innerValue = "inner"

    console.log(innerValue)
    console.log(outerValue)
    console.log(globalValue)
  }

  inner()
}

outer()

Функция inner последовательно ищет переменные:

  1. сначала у себя;
  2. потом во внешней функции outer;
  3. потом в глобальной области.

Эта цепочка называется scope chain.


3. Поиск переменной идет снизу вверх

Если переменная не найдена в локальной области, JavaScript идет выше по цепочке.

const theme = "dark"

function renderPage(): void {
  function renderHeader(): void {
    console.log(theme)
  }

  renderHeader()
}

renderPage()

Функция renderHeader не имеет своей переменной theme, поэтому JavaScript ищет ее во внешних областях и находит в глобальной.


4. Closure сохраняет доступ к переменной, а не только значение

Это очень важный момент.

Во многих случаях замыкание хранит не “снимок значения”, а доступ к самой переменной.

function createLogger(): () => void {
  let message = "hello"

  const log = (): void => {
    console.log(message)
  }

  message = "updated"

  return log
}

const logger = createLogger()

logger() // updated

Если бы функция сохранила только начальное значение, в консоль попало бы "hello". Но она обращается к переменной message, которая позже изменилась.


5. Почему переменная не исчезает после завершения функции

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

function createUserStore(): {
  getName: () => string
  setName: (newName: string) => void
} {
  let name = "Alice"

  return {
    getName(): string {
      return name
    },
    setName(newName: string): void {
      name = newName
    }
  }
}

const userStore = createUserStore()

console.log(userStore.getName()) // Alice

userStore.setName("Bob")

console.log(userStore.getName()) // Bob

Функция createUserStore завершилась, но name продолжает жить, потому что методы объекта продолжают к ней обращаться.


6. Почему место объявления важнее места вызова

JavaScript использует lexical scoping. Это значит, что функция ориентируется на место своего объявления, а не на контекст вызова.

const value = "global"

function printValue(): void {
  console.log(value)
}

function wrapper(): void {
  const value = "local"

  printValue()
}

wrapper() // global

Несмотря на то, что printValue вызвана внутри wrapper, она была объявлена в глобальной области, поэтому использует глобальную value.


Базовый пример

Один из лучших базовых примеров — счетчик с приватным состоянием.

function createCounter(): {
  increment: () => number
  decrement: () => number
  getValue: () => number
} {
  let count = 0

  function increment(): number {
    count += 1

    return count
  }

  function decrement(): number {
    count -= 1

    return count
  }

  function getValue(): number {
    return count
  }

  return {
    increment,
    decrement,
    getValue
  }
}

const counterA = createCounter()
const counterB = createCounter()

console.log(counterA.increment()) // 1
console.log(counterA.increment()) // 2
console.log(counterA.getValue()) // 2

console.log(counterB.getValue()) // 0

Подробный разбор

let count = 0

Это локальная переменная внутри createCounter. Снаружи она недоступна.

increment, decrement, getValue

Каждая из этих функций использует переменную count, которая находится во внешней области видимости.

Возврат объекта

Функция возвращает объект с методами, и каждый метод замыкает count.

Независимые экземпляры

counterA и counterB создаются разными вызовами createCounter, поэтому у каждого — свой отдельный lexical environment и свой собственный count.

Это одна из главных сильных сторон closure: можно создавать независимые экземпляры поведения с приватным состоянием.


Несколько практических примеров

1. Фабрика форматтеров

function createPriceFormatter(currency: string): (amount: number) => string {
  return function format(amount: number): string {
    return `${amount} ${currency}`
  }
}

const formatRub = createPriceFormatter("RUB")
const formatUsd = createPriceFormatter("USD")

console.log(formatRub(1500)) // 1500 RUB
console.log(formatUsd(100)) // 100 USD

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

Функция format замыкает переменную currency.

Почему это работает именно так

Каждый вызов createPriceFormatter создает новое окружение, и в нем хранится свое значение валюты.

На что обратить внимание

Это хороший способ настроить функцию один раз и потом переиспользовать ее.

Где можно ошибиться

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


2. Инкапсуляция состояния без классов

function createFeatureFlag(initialValue: boolean): {
  isEnabled: () => boolean
  enable: () => void
  disable: () => void
} {
  let enabled = initialValue

  function isEnabled(): boolean {
    return enabled
  }

  function enable(): void {
    enabled = true
  }

  function disable(): void {
    enabled = false
  }

  return {
    isEnabled,
    enable,
    disable
  }
}

const featureFlag = createFeatureFlag(false)

console.log(featureFlag.isEnabled()) // false

featureFlag.enable()

console.log(featureFlag.isEnabled()) // true

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

Переменная enabled скрыта внутри closure и не может быть изменена напрямую снаружи.

Почему это работает именно так

Методы продолжают иметь доступ к переменной enabled, потому что были созданы в том же окружении.

На что обратить внимание

Это простая и удобная форма инкапсуляции.

Где можно ошибиться

Если такой модуль начинает хранить слишком много состояния и логики, он превращается в неявный “мини-store” с плохой читаемостью.


3. Цикл и setTimeout: классическая ловушка

Плохой пример

for (var index = 0; index < 3; index += 1) {
  setTimeout((): void => {
    console.log(index)
  }, 0)
}

// 3
// 3
// 3

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

Все колбэки используют одну и ту же переменную index.

Почему это работает именно так

var имеет функциональную область видимости, а не блочную. После завершения цикла index уже равен 3, и именно это значение увидят все отложенные колбэки.

Правильный вариант

for (let index = 0; index < 3; index += 1) {
  setTimeout((): void => {
    console.log(index)
  }, 0)
}

// 0
// 1
// 2

На что обратить внимание

let создает новую привязку переменной для каждой итерации цикла.

Где можно ошибиться

Эта проблема особенно часто всплывает в старом коде, legacy-скриптах и неочевидных асинхронных местах.


4. Closure в Vue composable

import { computed, ref } from "vue"

export function useCounter(initialValue = 0): {
  count: ReturnType<typeof ref<number>>
  doubleCount: ReturnType<typeof computed<number>>
  increment: () => void
  reset: () => void
} {
  const count = ref(initialValue)

  const doubleCount = computed(() => {
    return count.value * 2
  })

  function increment(): void {
    count.value += 1
  }

  function reset(): void {
    count.value = initialValue
  }

  return {
    count,
    doubleCount,
    increment,
    reset
  }
}

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

  • increment замыкает count;
  • reset замыкает и count, и initialValue;
  • doubleCount использует реактивное значение через замыкание.

Почему это работает именно так

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

На что обратить внимание

Composables очень часто строятся вокруг closure. Это один из самых естественных способов организовать локальное состояние и поведение.

Где можно ошибиться

Если внутри composable держать подписки, таймеры, WebSocket или тяжелые объекты без очистки, можно получить утечки памяти.


5. Параметризованные валидаторы

function createMinLengthValidator(minLength: number): (value: string) => boolean {
  return function validate(value: string): boolean {
    return value.length >= minLength
  }
}

const validateName = createMinLengthValidator(2)
const validatePassword = createMinLengthValidator(8)

console.log(validateName("A")) // false
console.log(validatePassword("12345678")) // true

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

Функция validate замыкает minLength.

Почему это работает именно так

Каждый валидатор получает свое окружение с нужной конфигурацией.

На что обратить внимание

Это удобный способ создавать переиспользуемую бизнес-логику.

Где можно ошибиться

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


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

1. Использование var вместо let в циклах

Плохо

for (var index = 0; index < 3; index += 1) {
  setTimeout((): void => {
    console.log(index)
  }, 100)
}

Почему это ошибка

Все колбэки ссылаются на одну и ту же переменную index.

Правильно

for (let index = 0; index < 3; index += 1) {
  setTimeout((): void => {
    console.log(index)
  }, 100)
}

Как избежать

В современном коде почти всегда стоит использовать let и const, а var не использовать совсем.


2. Случайное удержание больших данных в памяти

Плохо

function createSearchHandler(hugeList: string[]): () => number {
  return function handleSearch(): number {
    return hugeList.length
  }
}

Почему это ошибка

Если hugeList большой и функция живет долго, массив будет удерживаться в памяти, даже если реально нужен только его размер.

Правильно

function createSearchHandler(totalCount: number): () => number {
  return function handleSearch(): number {
    return totalCount
  }
}

Как избежать

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


3. Неочевидное затенение переменных

Плохо

const status = "global"

function printStatus(): void {
  const status = "local"

  console.log(status)
}

Почему это ошибка

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

Правильно

const globalStatus = "global"

function printStatus(): void {
  const localStatus = "local"

  console.log(localStatus)
}

Как избежать

Использовать более явные имена и не переиспользовать одинаковые идентификаторы без реальной необходимости.


4. Слишком неявные зависимости от внешнего состояния

Плохо

let currentDiscount = 0.1

function createPriceCalculator(): (price: number) => number {
  return function calculate(price: number): number {
    return price - price * currentDiscount
  }
}

Почему это ошибка

Поведение функции зависит от внешнего изменяемого состояния. Такой код сложно тестировать, поддерживать и читать.

Правильно

function createPriceCalculator(discount: number): (price: number) => number {
  return function calculate(price: number): number {
    return price - price * discount
  }
}

Как избежать

Лучше явно передавать зависимости в функцию, чем тянуть их из неочевидного внешнего контекста.


5. Closure с чрезмерной ответственностью

Плохо

function createManager(): {
  save: () => void
  reset: () => void
  sync: () => Promise<void>
} {
  let localState = {}
  let cache = new Map()
  let retries = 0
  let requestQueue: string[] = []
  let analyticsEnabled = true

  async function sync(): Promise<void> {
    retries += 1
    console.log(localState, cache, requestQueue, analyticsEnabled)
  }

  function save(): void {
    console.log("save")
  }

  function reset(): void {
    localState = {}
  }

  return {
    save,
    reset,
    sync
  }
}

Почему это ошибка

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

Правильно

Разделять состояние и поведение на более мелкие функции, модули или composables с одной ответственностью.

Как избежать

Если closure начинает держать слишком много, это сигнал к декомпозиции.


Best practices

1. Использовать closure для локальной инкапсуляции

Замыкания хорошо подходят там, где нужно скрыть внутренние детали и предоставить небольшой понятный API.

Примеры:

  • счетчики;
  • форматтеры;
  • валидаторы;
  • модульные утилиты;
  • composables.

Это делает код компактным и помогает не выставлять лишнее состояние наружу.


2. Замыкать только необходимые данные

Чем меньше переменных и объектов удерживает closure, тем:

  • проще читать код;
  • легче его поддерживать;
  • ниже риск утечек памяти;
  • меньше вероятность случайных зависимостей.

Если функции нужен только id, не стоит замыкать весь большой объект.


3. Предпочитать const и let

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

Это особенно важно в циклах, условных блоках и асинхронных обработчиках.


4. Делать API замыкания явным

Хорошее замыкание обычно легко читается снаружи:

const validator = createMinLengthValidator(5)
const isValid = validator("hello")

Если из кода не видно, откуда берется поведение функции, значит, API слишком неявный.


5. Следить за жизненным циклом долгоживущих функций

Обработчики событий, таймеры, подписки и кэшированные callback-и могут жить долго. Если они удерживают ссылки на большие объекты, DOM-элементы или сервисы, это может стать проблемой.

Особенно важно помнить об этом в UI-коде и composables.


6. Держать функции небольшими

Небольшие функции проще анализировать:

  • какие переменные они используют;
  • что именно они замыкают;
  • сколько живет их состояние;
  • как они взаимодействуют с внешним кодом.

Anti-patterns

1. Closure как скрытое глобальное состояние

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

let activeLocale = "ru"

function createTranslator(): (value: string) => string {
  return function translate(value: string): string {
    return `[${activeLocale}] ${value}`
  }
}

Такой код сложно тестировать и сложно понимать без знания всего окружающего контекста.


2. Слишком глубокая вложенность функций

function createA(): () => () => () => void {
  const a = "a"

  return function createB(): () => () => void {
    const b = "b"

    return function createC(): () => void {
      const c = "c"

      return function log(): void {
        console.log(a, b, c)
      }
    }
  }
}

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


3. Использование closure там, где проще явный объект

Иногда модуль на closure выглядит “умно”, но обычный объект или класс читается лучше.

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


4. Долгоживущие closures, которые удерживают DOM

Если обработчик или модуль хранит ссылку на DOM-элемент, который уже удален из интерфейса, память может освобождаться хуже, чем ожидается.

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


Использование в реальной разработке

1. Обработчики событий

Очень частый сценарий — передача контекста в обработчик.

function createClickHandler(productId: string): () => void {
  return (): void => {
    console.log(`Clicked product: ${productId}`)
  }
}

Такой подход удобен, когда нужно связать UI-действие с конкретной сущностью.


2. Бизнес-логика и фабрики функций

Параметризованные валидаторы, форматтеры и нормализаторы часто строятся именно через closure.

function createTaxCalculator(rate: number): (amount: number) => number {
  return function calculate(amount: number): number {
    return amount + amount * rate
  }
}

Это позволяет один раз задать конфигурацию и потом использовать функцию многократно.


3. API-слой

Замыкания помогают частично настроить поведение клиента.

function createApiClient(baseUrl: string): {
  get: (path: string) => Promise<Response>
} {
  async function get(path: string): Promise<Response> {
    return fetch(`${baseUrl}${path}`)
  }

  return {
    get
  }
}

Здесь get замыкает baseUrl, и это естественный способ сконфигурировать API-клиент.


4. Формы

Фабрики валидаторов и обработчиков очень часто используются в формах:

function createRequiredFieldValidator(fieldName: string): (value: string) => string | null {
  return function validate(value: string): string | null {
    if (value.trim() === "") {
      return `${fieldName} is required`
    }

    return null
  }
}

Это позволяет описывать правила переиспользуемо и без копипаста.


5. Vue 3 composables

Во Vue closure используется постоянно.

import { ref } from "vue"

export function useModal(): {
  isOpen: ReturnType<typeof ref<boolean>>
  open: () => void
  close: () => void
} {
  const isOpen = ref(false)

  function open(): void {
    isOpen.value = true
  }

  function close(): void {
    isOpen.value = false
  }

  return {
    isOpen,
    open,
    close
  }
}

Функции open и close замыкают isOpen, а сам composable дает удобный API для повторного использования логики.


6. Производительность

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

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

Обычно проблема не в самих closures, а в их неаккуратном использовании.


Продвинутый взгляд

Trade-offs

Плюсы

  • позволяют элегантно инкапсулировать состояние;
  • упрощают создание фабрик функций;
  • хорошо подходят для небольших модулей и utilities;
  • естественно работают с callback-ами и composables.

Минусы

  • могут скрывать зависимости;
  • иногда ухудшают читаемость;
  • при неправильном использовании удерживают лишние данные в памяти;
  • не всегда очевидны для дебага.

Ограничения

Closure — не универсальное решение на все случаи.

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

  • классам;
  • обычным объектам;
  • store-подходу;
  • более явной архитектуре модулей.

Альтернативы

Вместо closure иногда лучше использовать:

  • класс, если важна явная структура и жизненный цикл;
  • простой объект с функциями, если состояние не нужно скрывать;
  • state manager, если состояние разделяется между многими частями приложения;
  • явную передачу зависимостей аргументами, если важна прозрачность.

Влияние на поддержку проекта

Хорошее замыкание:

  • уменьшает шум в API;
  • убирает лишние детали наружу;
  • делает код компактным и удобным.

Плохое замыкание:

  • прячет слишком много;
  • делает зависимости неочевидными;
  • затрудняет тестирование и отладку;
  • создает ощущение “магии”.

Влияние на читаемость

Читаемость напрямую зависит от того, насколько явно видно:

  • что именно замыкается;
  • зачем это замыкается;
  • сколько живет это состояние;
  • кто им управляет.

Если без мысленного “разворачивания” внешнего контекста невозможно понять поведение функции, код уже начал терять прозрачность.


Когда подход удачен

Closure особенно хорошо подходит, когда нужно:

  • создать локальный приватный state;
  • настроить функцию параметрами;
  • передать в обработчик нужный контекст;
  • построить небольшой composable или utility.

Когда лучше выбрать другой подход

Лучше не использовать closure как основную форму организации логики, если:

  • состояние общее и большое;
  • нужен явный жизненный цикл;
  • модуль уже стал слишком сложным;
  • требуется высокая прозрачность зависимостей;
  • нужна сложная оркестрация между несколькими частями приложения.

Частые практические вопросы по теме

  1. Что такое scope в JavaScript?
  2. Какие виды областей видимости есть в JavaScript?
  3. Чем отличается var от let и const с точки зрения области видимости?
  4. Что такое lexical environment?
  5. Что такое scope chain?
  6. Почему вложенная функция видит переменные внешней функции?
  7. Что такое closure?
  8. Почему closure продолжает работать после завершения внешней функции?
  9. Что именно сохраняет closure: значение или доступ к переменной?
  10. Почему цикл с var и setTimeout часто дает неожиданный результат?
  11. Где closures встречаются в реальном frontend-коде?
  12. Какие риски и побочные эффекты могут появиться из-за closures?
  13. Когда closure помогает упростить код, а когда делает его менее понятным?
  14. Как closures связаны с composables, event handlers и фабриками функций?
  15. Как closures могут влиять на память и жизненный цикл данных?

Сильные ответы на ключевые вопросы

Что такое scope?

Scope — это область видимости, которая определяет, где переменная доступна в коде. В JavaScript важны глобальная, функциональная и блочная области видимости. От scope зависит, какие переменные функция может использовать и где именно искать идентификаторы.


Что такое lexical environment?

Lexical environment — это внутреннее окружение, которое хранит локальные переменные текущей области и ссылку на внешнее окружение. За счет этого JavaScript понимает, где искать переменные, если они не найдены локально.


Что такое closure?

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


Почему код с var в цикле и setTimeout ведет себя странно?

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


Что closure обычно сохраняет: значение или переменную?

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


Где closures встречаются на практике?

Практически везде: в обработчиках событий, таймерах, debounce/throttle, фабриках валидаторов и форматтеров, в API-утилитах, модулях с приватным состоянием, composables и любой логике, где функция использует внешний контекст.


Какие проблемы могут возникать из-за closures?

Наиболее частые проблемы — скрытые зависимости, stale values, случайное удержание тяжелых объектов, неочевидное поведение в циклах и рост сложности дебага. Обычно это происходит не из-за самого механизма closure, а из-за неаккуратного проектирования кода.


Практическая мини-задача

Нужно реализовать функцию createAccumulator, которая:

  • принимает начальное значение;
  • возвращает объект с методами add, subtract, getValue;
  • хранит состояние приватно;
  • не дает менять значение напрямую снаружи.

Ожидаемое использование:

const accumulator = createAccumulator(10)

accumulator.add(5)
accumulator.subtract(3)

console.log(accumulator.getValue()) // 12

Решение задачи

function createAccumulator(initialValue: number): {
  add: (amount: number) => number
  subtract: (amount: number) => number
  getValue: () => number
} {
  let currentValue = initialValue

  function add(amount: number): number {
    currentValue += amount

    return currentValue
  }

  function subtract(amount: number): number {
    currentValue -= amount

    return currentValue
  }

  function getValue(): number {
    return currentValue
  }

  return {
    add,
    subtract,
    getValue
  }
}

const accumulator = createAccumulator(10)

accumulator.add(5)
accumulator.subtract(3)

console.log(accumulator.getValue()) // 12

Почему это решение хорошее

  • currentValue недоступен напрямую снаружи;
  • все методы работают через closure;
  • API простой и предсказуемый;
  • создаются независимые экземпляры аккумулятора с собственным состоянием.

Как дебажить проблемы по этой теме

Проблемы со scope и closures обычно проявляются не как “ошибка про closure”, а как странное поведение данных.

Типичные симптомы

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

Как искать причину

1. Проверить место объявления функции

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

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


2. Явно выписать, какие внешние переменные использует функция

Полезно задать себе вопросы:

  • какие данные функция берет не из аргументов;
  • из какой области они приходят;
  • меняются ли они позже;
  • кто еще их использует.

3. Проверить, не затеняется ли переменная

Иногда причина багов — не closure как таковой, а одинаковые имена на разных уровнях вложенности.


4. Проверить время жизни функции

Если функция хранится долго — например, как event listener, таймер или callback в кеше — ее окружение тоже может жить долго.

Нужно понять:

  • кто держит ссылку на функцию;
  • когда она должна освобождаться;
  • не удерживает ли она лишние данные.

5. Локально логировать момент создания и момент вызова

Это помогает отличить проблему “функция была создана с одними данными” от проблемы “данные изменились позже”.

function createLogger(label: string): () => void {
  console.log("Created logger with:", label)

  return (): void => {
    console.log("Called logger with:", label)
  }
}

6. Упростить область захвата

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


Краткий конспект

  • scope определяет, где переменная доступна.
  • lexical environment хранит локальные переменные и ссылку на внешнее окружение.
  • JavaScript ищет переменные по scope chain, двигаясь от внутренней области к внешней.
  • Функция ориентируется на место объявления, а не на место вызова.
  • Closure позволяет функции использовать внешние переменные даже после завершения внешней функции.
  • let и const создают блочную область видимости, var — функциональную.
  • Замыкания особенно полезны для инкапсуляции состояния и фабрик функций.
  • Closure может удерживать данные в памяти дольше, чем ожидается, если использовать его неаккуратно.
  • Хорошее замыкание упрощает код, плохое — скрывает зависимости и усложняет поддержку.
  • В реальном frontend-коде closures встречаются постоянно: в обработчиках, composables, валидаторах, API-утилитах и модулях.

Что важно запомнить

  1. Closure — это не редкая особенность языка, а повседневный механизм JavaScript.
  2. Функция видит те переменные, которые доступны в месте ее объявления.
  3. Замыкание обычно работает с переменной из окружения, а не просто с копией значения.
  4. var в циклах и асинхронном коде часто ведет к неочевидным результатам.
  5. Чем меньше данных удерживает closure, тем проще код и безопаснее его поведение.
  6. Если замыкание делает зависимости слишком скрытыми, лучше выбрать более явный подход.

Чек-лист самопроверки

  • Я понимаю, что такое scope.
  • Я понимаю разницу между глобальной, функциональной и блочной областями видимости.
  • Я могу объяснить, что такое lexical environment.
  • Я понимаю, как работает scope chain.
  • Я могу своими словами объяснить, что такое closure.
  • Я понимаю, почему вложенная функция видит внешние переменные.
  • Я понимаю, почему переменная может продолжать жить после завершения внешней функции.
  • Я могу привести пример полезного closure в реальном frontend-коде.
  • Я знаю, почему var в цикле с асинхронностью часто приводит к ошибкам.
  • Я понимаю, как closures используются в composables, обработчиках и фабриках функций.
  • Я знаю типичные ошибки и anti-patterns по этой теме.
  • Я понимаю, как closures могут влиять на читаемость, поддержку и память.