Загружаем научный разбор
Подготавливаем текст, источники и редакционные примечания без изменения разметки страницы.
Подготавливаем текст, источники и редакционные примечания без изменения разметки страницы.
Автор: Казачкин Даниил Михайлович · Обновлено
Модель замыканий через Environment Records: эксперименты с общей привязкой, снимком поля и окружениями итераций; граница между семантикой и удержанием памяти.
Функция продолжает читать локальную переменную после завершения внешнего вызова. Это знакомое поведение обычно объясняют словом «замыкание», а затем предлагают представить рюкзак с сохранёнными значениями. Но метафора рюкзака недостаточна для двух функций, которые меняют одно состояние. Она также не объясняет, почему повторный вызов фабрики создаёт независимый счётчик и почему замыкание иногда удерживает большой объект, а иногда движок вовсе не хранит ненужные локальные данные.
Попробуем более точную модель: функция имеет связь с окружением, окружение содержит привязки имён, а привязка может хранить ссылку на изменяемый объект. Это авторский разбор семантики и серия небольших проверок, а не измерение реализации всех JavaScript-движков. Практические приёмы создания счётчиков и обработчиков разобраны в [уроке о замыканиях](/lessons/javascript/javascript-for-developers/javascript-closures). Здесь предмет другой: какие предсказания позволяет сделать модель и где заканчивается её применимость.
В актуальном тексте ECMAScript используются Environment Records. Запись окружения связывает идентификаторы с привязками и имеет связь с внешним окружением. Функциональный объект хранит внутренний слот [[Environment]], который используется при определении внешнего окружения его исполнения. Эти конструкции задают наблюдаемое поведение языка; программа не получает обычный объект, в котором можно прочитать или заменить такой слот. ECMAScript: Environment Records, ECMAScript: Function Objects.
Из этого следует полезное разделение вопросов. «Какое значение будет прочитано?» — вопрос семантики. «Сколько байтов и каких структур выделит движок?» — вопрос реализации. Возврат внешней функции завершает её вызов, но не обязан делать недоступными привязки, которыми ещё может воспользоваться возвращённая функция. Рисовать сохранённый целиком кадр стека для объяснения этого поведения не требуется.
Для дальнейших опытов достаточно условной схемы ниже. Стрелки обозначают связи в нашей модели, а не буквальные адреса объектов внутри V8. Именно такое ограничение позволит использовать схему и при оптимизированном исполнении.
переменная reader → функция → внешнее окружение → привязка count → число
переменная writer → функция → то же окружение → та же привязка countСравним две гипотезы. Первая: при создании функции каждое используемое значение копируется и больше не меняется. Вторая: функции одного вызова фабрики обращаются к одной изменяемой привязке. Следующий фрагмент различает гипотезы без таймеров, DOM и асинхронности.
function createGauge(initial) {
let value = initial;
return {
read: () => value,
set: (next) => { value = next; },
};
}
const first = createGauge(4);
const second = createGauge(4);
first.set(9);
console.log(first.read(), second.read());Предсказание второй гипотезы — 9 4. Первые read и set относятся к одному вызову фабрики и разделяют состояние. Функции второго вызова связаны с другим окружением, хотя исходный текст у них тот же. Если бы сохранялась неизменяемая копия числа, первый read продолжал бы возвращать 4. Так небольшая программа устраняет неоднозначность популярного объяснения.
Здесь также видно, почему замыкание не означает глобальную переменную с красивым синтаксисом. Одинаковое имя value не объединяет состояния разных вызовов. И наоборот, разные имена свойств read и set не делают состояние независимым. Для анализа нужно установить, при каком вычислении были созданы функции и к каким привязкам они обращаются.
Усложним модель на один уровень. Пусть привязка содержит объект, а из его поля заранее извлекается строка. В обоих случаях функция использует имя из внешнего контекста, однако наблюдения после изменения исходного объекта будут различаться.
function watchTitle(document) {
const titleAtCreation = document.title;
return {
current: () => document.title,
snapshot: () => titleAtCreation,
};
}
const draft = { title: 'Черновик' };
const watch = watchTitle(draft);
draft.title = 'Опубликовано';
console.log(watch.current(), watch.snapshot());Ожидаем Опубликовано Черновик. Первая функция при каждом вызове читает поле объекта, доступного через привязку document. Вторая читает отдельную привязку строки, и никакого автоматического повторения операции document.title не происходит. Обе функции являются замыканиями. Различие создаёт структура вычисления, а не наличие или отсутствие специального механизма памяти.
Так можно разбирать «устаревшие данные» в обработчиках. Важно спросить, что именно было вычислено до регистрации callback: ссылка на контейнер, значение свойства или результат сложного преобразования. Если зарегистрирована функция, возвращающая готовую строку, последующее изменение исходной модели не пересчитает эту строку. Решение зависит от контракта: иногда нужен снимок на момент действия, иногда актуальное значение. Автоматически заменять одно другим нельзя.
Популярная ошибка возникает, когда разработчик ожидает, что функция прочитает одноимённую переменную своего вызывающего кода. Проверим это в изолированном примере. Здесь нет параметра с названием label, поэтому источник свободного имени должен определяться окружением создания.
function buildReader() {
const label = 'создание';
return () => label;
}
function runReader(reader) {
const label = 'вызов';
return [reader(), label];
}
console.log(runReader(buildReader()).join(' / '));Предсказание — создание / вызов. Если перенести вызов reader в другой модуль, семантический источник первого слова от этого не изменится. Передача функции переносит возможность выполнить её код с доступом к соответствующему окружению, а не только текст её тела. Это полезная модель для передачи callback библиотеке: библиотека выбирает момент вызова, но не переписывает лексические привязки функции.
Отдельно стоит рассматривать this. У обычной функции способ вызова влияет на его значение; стрелочная функция использует лексическое поведение. Совмещать это различие с поиском произвольного имени в одной метафоре неудобно. При диагностике сначала выясните, читает ли код идентификатор label или выражение this.label, и лишь затем выбирайте правило. Формальная модель функциональных объектов различает эти механизмы. ECMAScript: Function Objects.
Создание трёх функций не обязательно означает создание трёх независимых привязок счётчика. Варианты ниже отличаются объявлением переменной цикла, но намеренно не используют таймеры: отложенность можно воспроизвести простым сохранением функций в массив.
const shared = [];
for (var index = 0; index < 3; index++) shared.push(() => index);
const separate = [];
for (let index = 0; index < 3; index++) separate.push(() => index);
console.log(shared.map((read) => read()).join(','));
console.log(separate.map((read) => read()).join(','));Ожидаются строки 3,3,3 и 0,1,2. Для формы цикла с лексическим объявлением стандарт описывает создание окружений итераций и перенос текущих значений привязок между ними. У варианта с var используемая привязка общая. Поэтому выражение «let просто сохраняет текущий счётчик в функцию» скрывает существенную часть механизма. ECMAScript: CreatePerIterationEnvironment.
Этот опыт помогает отделить две причины ошибки, которые часто смешивают. Задержка callback определяет, когда будет прочитана привязка. Область действия определяет, какая именно привязка будет прочитана. В исходном примере оба фактора видны без асинхронных API: чтение происходит после цикла, а набор доступных привязок различается. Добавление таймера меняет расписание, но не создаёт проблему заново.
Допустим, фабрика создаёт большой массив и возвращает функцию, читающую только маленькое число. Было бы поспешно утверждать, что весь массив обязательно остаётся в памяти навсегда. Спецификация прямо не требует физического соответствия Environment Record структуре реализации. Оптимизатор может учитывать доступные наблюдения. Из текстовой вложенности нельзя вывести точный retained size или обещать одинаковую картину во всех версиях движка. ECMAScript: Environment Records.
Обратная ошибка — считать, что маленький callback всегда удерживает только маленькое значение. Несколько замыканий могут разделять окружение, а ссылка на объект может открывать путь к крупному графу. Материал V8 о слабых ссылках специально рассматривает тонкости общих окружений. Это наблюдение реализации помогает формулировать гипотезу удержания, но конкретную программу всё равно нужно исследовать отдельно. V8: Weak references and finalizers.
Для практической проверки сначала установите владельца функции: глобальная коллекция, подписка, активный компонент или временный вызов. Затем найдите путь к данным и событие, после которого он должен исчезнуть. Снимок кучи позволяет исследовать удерживающие ссылки; сама возможность прочитать нужное поле в callback уже означает, что данные должны оставаться доступными по семантике. Отсутствие объекта в одном снимке, напротив, требует осторожного толкования условий опыта. Chrome DevTools: Heap snapshots.
Четыре фрагмента автоматически проверены 8 сентября 2026 года в Chromium 151 и Node.js 24.13.0. Наблюдались именно предсказанные значения, включая различие общей и отдельной привязки в циклах. Размер окружений и объём удерживаемой памяти в этой серии не измерялись: вывод относится к наблюдаемой семантике чтения, а не к внутреннему устройству движка.
Возьмём редактор с историей изменений. Функция отмены может намеренно хранить снимок предыдущего текста: замыкание здесь реализует продуктовый контракт. Обработчик индикатора размера, напротив, должен читать актуальный текст. Перед реализацией полезно записать оба требования словами и выбрать момент вычисления. Так исчезает соблазн назвать любое старое значение ошибкой замыкания или любое удержание данных утечкой.
Для самопроверки измените второй опыт: вместо строки сохраняйте новый объект { title: document.title }, затем отдельно изменяйте исходный и сохранённый объекты. Нарисуйте две привязки и два объекта, предскажите результаты каждого чтения. После этого замените копирование на присваивание самого document. Разница в поведении должна объясняться количеством объектов и путями к ним, без новых правил о замыканиях.
Модель особенно полезна, когда объяснение остаётся коротким после усложнения программы. [Урок о this](/lessons/javascript/javascript-for-developers/javascript-this) отделяет контекст вызова от лексических имён, [урок о копировании объектов](/lessons/javascript/javascript-for-developers/javascript-object-copy-transfer) разбирает снимки данных, а [урок о памяти](/lessons/javascript/javascript-for-developers/javascript-memory) переносит связи на жизненный цикл ресурсов. В каждом случае сначала определяем наблюдаемую обязанность функции, затем проверяем конкретный путь к состоянию.
Авторский русскоязычный разбор. Перечисленные первичные источники использованы для проверки технических утверждений и библиографии; материал не является переводом или перепечаткой источников. Собственные примеры и рассуждения отделены от опубликованных эмпирических результатов.
Иллюстрации, таблицы и код первоисточников не воспроизводятся. Примеры и текстовые схемы созданы для этой статьи. Ссылки и библиографические сведения не предоставляют прав на перепубликацию материалов источников.