В Уроке 17 мы договорились не углубляться в разницу между var и let внутри цикла for, пообещав вернуться к этому в отдельном уроке. В Уроке 19 мы увидели, как стрелочные функции по-особому обращаются с this. Оба этих момента — часть одной большой темы: где именно в коде видна та или иная переменная, и что происходит, когда функция "запоминает" переменные из места своего создания даже после того, как это место перестало существовать.
Сегодня разберём обе темы — области видимости (scope) и замыкания (closures) — только на конкретных, запускаемых примерах, без абстрактных рассуждений.
const siteName = 'MovieGrid'; // объявлено вне любой функции и любого блока — глобальная переменная
function printSiteName() {
console.log(siteName); // доступна отовсюду, включая любую функцию
}
printSiteName(); // MovieGrid
console.log(siteName); // MovieGridПеременная, объявленная вне какой-либо функции или блока { }, видна везде в файле — это глобальная область видимости. Чем больше переменных объявлено глобально, тем выше риск случайного конфликта имён между разными частями большого проекта — поэтому на практике глобальных переменных стараются избегать, вынося логику внутрь функций и модулей.
function calculatePrice() {
const basePrice = 400; // видна только внутри calculatePrice
console.log(basePrice);
}
calculatePrice(); // 400
console.log(basePrice); // ReferenceError: basePrice is not definedПеременная, объявленная внутри функции, видна только внутри этой функции (и вложенных в неё функций) — снаружи к ней обратиться невозможно. Это работает предсказуемо для всех трёх способов объявления переменных (var, let, const).
if (true) {
const message = 'Привет из блока if';
console.log(message); // Привет из блока if
}
console.log(message); // ReferenceError: message is not definedБлок — это любая пара фигурных скобок { }: тело if, for, while, или даже просто самостоятельные { } без какой-либо конструкции перед ними. let и const видны только внутри того блока, где они объявлены — это и называется блочной областью видимости.
Вот тот самый пример, который мы откладывали с Урока 17 — и именно здесь становится видно, почему разница между var и let — не формальность, а источник реальных багов.
if (true) {
var message = 'Привет из блока if';
}
console.log(message); // Привет из блока if — сработало! var "утёк" из блока наружу!var игнорирует блочную область видимости — он видит только функцию (или глобальную область, если объявлен вне функции) как границу. Переменная, объявленная через var внутри if, for или while, оказывается доступна снаружи этого блока — что почти никогда не является ожидаемым и желаемым поведением.
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 100);
}
// Через 100мс выведет: 3, 3, 3 — а не 0, 1, 2, как многие интуитивно ожидают!setTimeout(callback, ms) — встроенная функция браузера, которая выполняет переданную функцию через указанное количество миллисекунд, не останавливая выполнение остального кода (мы вернёмся к этому подробнее в теме асинхронности, но для сегодняшнего примера достаточно знать, что колбэк выполнится "чуть позже", когда for-цикл уже полностью завершится).
Почему получается 3, 3, 3: var i создаёт одну-единственную переменную на весь цикл — у неё нет блочной области видимости, поэтому все три функции внутри setTimeout смотрят на одну и ту же переменную i в памяти. К моменту, когда все три setTimeout-колбэка реально выполнятся (через 100мс), цикл уже полностью завершился, и i уже равна 3 (значению, при котором условие i < 3 стало ложным и цикл остановился) — именно это значение и увидят все три колбэка, потому что они смотрят на одну общую ячейку памяти.
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 100);
}
// Через 100мс выведет: 0, 1, 2 — именно то, что интуитивно ожидаетсяlet в цикле for — особый случай: на каждой итерации цикла создаётся новая, отдельная переменная i с блочной областью видимости, привязанная именно к этому конкретному проходу цикла. Каждый колбэк внутри setTimeout "запоминает" ту переменную i, которая существовала именно на его итерации — отсюда и корректный результат 0, 1, 2.
Практический вывод: это одна из главных причин, по которой в современном JS-коде var не используется вообще — let/const с их предсказуемой блочной областью видимости устраняют целый класс подобных неочевидных ошибок сами по себе, без необходимости держать в голове особые случаи.
Мы только что видели пример замыкания, даже не назвав его так явно. Дадим определение и разберём его на более простом, изолированном примере.
Замыкание — это функция, которая сохраняет доступ к переменным из той области видимости, где она была создана, даже после того, как эта область видимости формально завершила выполнение.
function createCounter() {
let count = 0; // переменная "живёт" внутри createCounter
return function () {
count++; // внутренняя функция обращается к count из внешней функции
return count;
};
}
const counter = createCounter(); // createCounter() уже отработала и завершилась
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3На первый взгляд это должно быть невозможно: функция createCounter уже полностью выполнилась и завершилась к моменту, когда мы вызываем counter() — казалось бы, переменная count должна была "исчезнуть" вместе с завершением функции. Но этого не происходит: возвращённая внутренняя функция замкнула в себе доступ к count — она продолжает "помнить" эту переменную и может её изменять, даже когда внешняя функция, где count была объявлена, давно завершила работу.
function createCounter() {
let count = 0;
return {
increment: () => {
count++;
return count;
},
reset: () => {
count = 0;
},
getValue: () => count,
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getValue()); // 2
counter.reset();
console.log(counter.getValue()); // 0
console.log(counter.count); // undefined — к переменной count напрямую снаружи обратиться невозможно!Заметьте: переменная count физически недоступна снаружи createCounter — никакого способа обратиться к counter.count напрямую не существует, единственный доступ к этому значению — через специально предоставленные методы (increment, reset, getValue). Это очень похоже на то, что мы делали в прошлом уроке через приватные поля класса (#rating) — только здесь используется другой инструмент: не класс с #-полем, а обычная функция, "запечатывающая" переменную внутри себя через замыкание. Это более старый, но всё ещё широко используемый в JavaScript паттерн инкапсуляции — он появился в языке задолго до приватных полей классов (#), которые мы разбирали в прошлом уроке.
Соберём практический пример, знакомый по нашему сквозному проекту — управление списком избранных фильмов, скрывая внутреннее хранилище от прямого доступа снаружи.
function createFavoritesStore() {
let favoriteIds = []; // приватное состояние — недоступно напрямую снаружи
return {
add: (movieId) => {
if (!favoriteIds.includes(movieId)) {
favoriteIds.push(movieId);
}
},
remove: (movieId) => {
favoriteIds = favoriteIds.filter((id) => id !== movieId);
},
has: (movieId) => favoriteIds.includes(movieId),
getAll: () => [...favoriteIds], // возвращаем КОПИЮ массива, а не сам favoriteIds
};
}
const favorites = createFavoritesStore();
favorites.add(1);
favorites.add(5);
favorites.add(1); // повторное добавление — includes() не даст добавить дубликат
console.log(favorites.has(1)); // true
console.log(favorites.has(2)); // false
console.log(favorites.getAll()); // [1, 5]
favorites.remove(1);
console.log(favorites.getAll()); // [5]
console.log(favorites.favoriteIds); // undefined — прямого доступа к переменной нетВажная деталь: метод getAll возвращает [...favoriteIds] — копию массива через spread-оператор (Урок 18), а не сам favoriteIds. Если бы мы вернули favoriteIds напрямую, вызывающий код получил бы ссылку на тот же самый массив, что хранится внутри замыкания, и мог бы изменить его напрямую (например, через .push()), в обход методов add/remove — а значит, "приватность" оказалась бы чисто формальной. Возврат копии — практическая привычка, которая действительно защищает внутреннее состояние от случайного внешнего изменения.
Ещё один частый практический паттерн — функция, которая создаёт другую, настроенную функцию, "запоминающую" параметр:
function createDiscountCalculator(discountPercent) {
return function (price) {
return price - (price * discountPercent) / 100;
};
}
const applyStudentDiscount = createDiscountCalculator(15);
const applySeniorDiscount = createDiscountCalculator(30);
console.log(applyStudentDiscount(400)); // 340 — 15% скидка от 400
console.log(applySeniorDiscount(400)); // 280 — 30% скидка от 400
console.log(applyStudentDiscount(1000)); // 850 — та же функция, тот же процент, другая ценаcreateDiscountCalculator вызывается один раз с конкретным процентом скидки — и возвращает новую функцию, которая "запомнила" именно этот процент через замыкание. Дальше эту готовую функцию можно многократно применять к разным ценам, ни разу больше не упоминая исходный процент — он уже "зашит" внутрь через замыкание.
for (var i = 0; i < 3; i++) {
// что-то делаем
}
console.log(i); // 3 — var "утёк" из цикла, доступен снаружи, в отличие от letРазобрано подробно в разделе 2 — если в коде используется var, помните, что он не уважает границы if/for/while — только границы функции (или глобальную область, если объявлен вне функции). Практический вывод остаётся прежним: не используйте var в новом коде, используйте let/const.
function createGreeter() {
let name = 'Гость';
const greet = () => console.log(`Привет, ${name}!`);
name = 'Мария'; // меняем name ПОСЛЕ создания функции greet, но ДО её вызова
return greet;
}
const greeter = createGreeter();
greeter(); // Привет, Мария! — а не "Привет, Гость!", как могло бы показаться на первый взглядЗамыкание хранит ссылку на переменную, а не значение, которое было у неё в момент создания функции. Если переменная изменится после создания замыкания, но до его вызова — замыкание "увидит" уже новое, актуальное значение. Это то же самое поведение, что мы наблюдали в примере с var i и setTimeout — только там на "одну и ту же ячейку" случайно смотрели сразу несколько колбэков одновременно, что и создавало баг.
let count = 10;
let count = 20; // ❌ SyntaxError: Identifier 'count' has already been declared
var total = 10;
var total = 20; // ✅ Не вызовет ошибку — var разрешает повторное объявление в той же области, молча переписывая значениеЕщё одно отличие в пользу let/const: попытка повторно объявить переменную с тем же именем в той же области видимости для let/const — это ошибка на этапе разбора кода (её видно сразу, до выполнения). var разрешает это молча — что на практике может маскировать опечатку или случайное повторное объявление, которое иначе сразу бросилось бы в глаза как ошибка.
Область видимости определяет, где именно в коде "видна" переменная. Глобальная область видит вся программа; функциональная — только сама функция и то, что вложено внутрь неё; блочная (только для let/const) — только конкретный блок { }, будь то if, for, while или произвольные фигурные скобки. var игнорирует блочную область видимости — переменная, объявленная им внутри if/for/while, "утекает" за пределы блока, что часто приводит к неожиданному поведению кода, особенно в циклах с отложенным выполнением (setTimeout).
Замыкание — это функция, которая сохраняет доступ к переменным из места своего создания даже после того, как то место (обычно — внешняя функция) формально завершило выполнение. Это не абстрактная особенность языка "для галочки", а практический инструмент: замыкания позволяют создавать приватное состояние без использования классов (как в примере с createFavoritesStore), а также создавать "настроенные" функции через фабрики (createDiscountCalculator), которые запоминают переданный параметр и переиспользуют его снова и снова.
В следующем уроке — DOM и BOM: мы наконец подключим JavaScript к реальной странице, научимся находить элементы, изменять их содержимое и реагировать на действия пользователя. Тема сегодняшнего урока станет особенно заметной именно там — обработчики событий кнопок массово используют именно замыкания, часто незаметно для того, кто их пишет.
- В чём разница между функциональной и блочной областью видимости?
- Почему переменная, объявленная через
varвнутриif, остаётся доступной снаружи этого блока? - Почему цикл
for (var i = 0; ...)сsetTimeoutвнутри выводит одно и то же последнее значение для всех итераций, аfor (let i = 0; ...)— разные значения по порядку? - Дайте определение замыкания своими словами и приведите пример без готового кода.
- Как замыкания позволяют реализовать приватное состояние без использования классов и приватных полей (
#)? - В примере с
createDiscountCalculator(discountPercent)— что именно "запоминает" возвращённая функция, и почему её можно вызывать многократно с разными ценами? - Почему метод
getAll()в примереcreateFavoritesStoreвозвращает[...favoriteIds], а не самfavoriteIdsнапрямую?
Напишите два коротких скрипта для наглядной демонстрации разницы:
- Скрипт, в котором
varобъявлен внутри блокаif (true) { ... }, и покажите черезconsole.logза пределами блока, что переменная всё же доступна (выведите её значение). - Аналогичный скрипт с
letвместоvar— покажите, что попытка обратиться к переменной снаружи блока вызываетReferenceError(оберните обращение вtry/catchи выведите текст ошибки черезconsole.log(error.message), чтобы скрипт не "падал" целиком).
Создайте функцию createViewCounter(movieTitle), которая возвращает объект с методами:
addView()— увеличивает внутренний счётчик просмотров на 1 и возвращает новое значение.getViews()— возвращает текущее количество просмотров без изменения.getSummary()— возвращает строку вида"Матрица: 5 просмотров", используяmovieTitle(параметр самойcreateViewCounter) и текущее значение счётчика.
Создайте два независимых счётчика для двух разных фильмов и убедитесь, что они не влияют друг на друга.
Создайте функцию createRatingsStore(), реализующую приватное хранилище пользовательских оценок фильмов (пары { movieId, rating }), используя замыкание:
rate(movieId, rating)— добавляет новую оценку в приватный массив (если оценка для этогоmovieIdуже существует — замените её новым значением вместо добавления дубликата, используйтеfindдля проверки существования иmapдля замены при необходимости).getRating(movieId)— возвращает оценку для конкретного фильма илиnull, если оценки ещё нет (используйтеfind).getAverage()— возвращает средний рейтинг по всем выставленным оценкам (используйтеforEachили цикл для суммирования, как в Уроке 19).getAll()— возвращает копию всего массива оценок (не сам приватный массив напрямую).