Skip to content

Latest commit

 

History

History
331 lines (222 loc) · 26.1 KB

lesson20.md

File metadata and controls

331 lines (222 loc) · 26.1 KB

Урок 20. Области видимости и замыкания: практические примеры

Долг, который мы обещали закрыть

В Уроке 17 мы договорились не углубляться в разницу между var и let внутри цикла for, пообещав вернуться к этому в отдельном уроке. В Уроке 19 мы увидели, как стрелочные функции по-особому обращаются с this. Оба этих момента — часть одной большой темы: где именно в коде видна та или иная переменная, и что происходит, когда функция "запоминает" переменные из места своего создания даже после того, как это место перестало существовать.

Сегодня разберём обе темы — области видимости (scope) и замыкания (closures) — только на конкретных, запускаемых примерах, без абстрактных рассуждений.


Три уровня области видимости

Глобальная область видимости (global scope)

const siteName = 'MovieGrid'; // объявлено вне любой функции и любого блока — глобальная переменная

function printSiteName() {
  console.log(siteName); // доступна отовсюду, включая любую функцию
}

printSiteName(); // MovieGrid
console.log(siteName); // MovieGrid

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

Функциональная область видимости (function scope)

function calculatePrice() {
  const basePrice = 400; // видна только внутри calculatePrice
  console.log(basePrice);
}

calculatePrice(); // 400
console.log(basePrice); // ReferenceError: basePrice is not defined

Переменная, объявленная внутри функции, видна только внутри этой функции (и вложенных в неё функций) — снаружи к ней обратиться невозможно. Это работает предсказуемо для всех трёх способов объявления переменных (var, let, const).

Блочная область видимости (block scope)

if (true) {
  const message = 'Привет из блока if';
  console.log(message); // Привет из блока if
}

console.log(message); // ReferenceError: message is not defined

Блок — это любая пара фигурных скобок { }: тело if, for, while, или даже просто самостоятельные { } без какой-либо конструкции перед ними. let и const видны только внутри того блока, где они объявлены — это и называется блочной областью видимости.


Ключевое отличие var: у него нет блочной области видимости

Вот тот самый пример, который мы откладывали с Урока 17 — и именно здесь становится видно, почему разница между var и let — не формальность, а источник реальных багов.

if (true) {
  var message = 'Привет из блока if';
}

console.log(message); // Привет из блока if — сработало! var "утёк" из блока наружу!

var игнорирует блочную область видимости — он видит только функцию (или глобальную область, если объявлен вне функции) как границу. Переменная, объявленная через var внутри if, for или while, оказывается доступна снаружи этого блока — что почти никогда не является ожидаемым и желаемым поведением.

Тот самый классический баг: var в цикле с отложенным выполнением

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 стало ложным и цикл остановился) — именно это значение и увидят все три колбэка, потому что они смотрят на одну общую ячейку памяти.

Решение — let

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 с их предсказуемой блочной областью видимости устраняют целый класс подобных неочевидных ошибок сами по себе, без необходимости держать в голове особые случаи.


Замыкания (closures) — функция, которая "помнит" своё окружение

Мы только что видели пример замыкания, даже не назвав его так явно. Дадим определение и разберём его на более простом, изолированном примере.

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

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 вызывается один раз с конкретным процентом скидки — и возвращает новую функцию, которая "запомнила" именно этот процент через замыкание. Дальше эту готовую функцию можно многократно применять к разным ценам, ни разу больше не упоминая исходный процент — он уже "зашит" внутрь через замыкание.


Подводные камни

Ожидание, что var ведёт себя как let в блоках

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 к реальной странице, научимся находить элементы, изменять их содержимое и реагировать на действия пользователя. Тема сегодняшнего урока станет особенно заметной именно там — обработчики событий кнопок массово используют именно замыкания, часто незаметно для того, кто их пишет.


Вопросы для проверки

  1. В чём разница между функциональной и блочной областью видимости?
  2. Почему переменная, объявленная через var внутри if, остаётся доступной снаружи этого блока?
  3. Почему цикл for (var i = 0; ...) с setTimeout внутри выводит одно и то же последнее значение для всех итераций, а for (let i = 0; ...) — разные значения по порядку?
  4. Дайте определение замыкания своими словами и приведите пример без готового кода.
  5. Как замыкания позволяют реализовать приватное состояние без использования классов и приватных полей (#)?
  6. В примере с createDiscountCalculator(discountPercent) — что именно "запоминает" возвращённая функция, и почему её можно вызывать многократно с разными ценами?
  7. Почему метод getAll() в примере createFavoritesStore возвращает [...favoriteIds], а не сам favoriteIds напрямую?

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

Задание 1. Демонстрация утечки var и её отсутствия у let

Напишите два коротких скрипта для наглядной демонстрации разницы:

  1. Скрипт, в котором var объявлен внутри блока if (true) { ... }, и покажите через console.log за пределами блока, что переменная всё же доступна (выведите её значение).
  2. Аналогичный скрипт с let вместо var — покажите, что попытка обратиться к переменной снаружи блока вызывает ReferenceError (оберните обращение в try/catch и выведите текст ошибки через console.log(error.message), чтобы скрипт не "падал" целиком).

Задание 2. Счётчик просмотров фильма (замыкание)

Создайте функцию createViewCounter(movieTitle), которая возвращает объект с методами:

  1. addView() — увеличивает внутренний счётчик просмотров на 1 и возвращает новое значение.
  2. getViews() — возвращает текущее количество просмотров без изменения.
  3. getSummary() — возвращает строку вида "Матрица: 5 просмотров", используя movieTitle (параметр самой createViewCounter) и текущее значение счётчика.

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


Задание 3. Хранилище оценок пользователя (объединяющая практика: замыкание + методы массива)

Создайте функцию createRatingsStore(), реализующую приватное хранилище пользовательских оценок фильмов (пары { movieId, rating }), используя замыкание:

  1. rate(movieId, rating) — добавляет новую оценку в приватный массив (если оценка для этого movieId уже существует — замените её новым значением вместо добавления дубликата, используйте find для проверки существования и map для замены при необходимости).
  2. getRating(movieId) — возвращает оценку для конкретного фильма или null, если оценки ещё нет (используйте find).
  3. getAverage() — возвращает средний рейтинг по всем выставленным оценкам (используйте forEach или цикл для суммирования, как в Уроке 19).
  4. getAll() — возвращает копию всего массива оценок (не сам приватный массив напрямую).

Предыдущий урок | Следующий урок