Замыкания и области видимости в JavaScript
JavaScript - очень свободный язык, не похожий на остальные. Здесь можно скопировать функции в переменные, передавать их в качестве аргументов или даже создавать на лету. Гибкость делает JavaScript мощным инструментом, но требует понимания того, как устроены области видимости и, в частности, замыкания
Области видимости определяют, где и когда переменная будет доступна, а механизм замыканий позволяет функциям запоминать внешний контекст. В этой статье мы разберем, как и почему это работает
Области видимости
Scope (Ско́уп) определяет, где в коде доступны переменные и функции. Он устанавливает границы видимости и регулирует доступ к разным частям программы
JavaScript ищет нужные сущности изнутри наружу - вложенные функции могут получать доступ к переменным из внешних областей, но не наоборот. Не переживайте, дальше обязательно разберем это на примерах
На просторах интернета вы можете встретить упоминания о том, что JavaScript использует лексическую область видимости. Однако, это скорее свойство работы механизма, описанного выше, а не отдельный уровень. Поэтому здесь я выделю только три области видимости - Глобальная, Функциональная и Блочная
Глобальная
Здесь все просто. К глобальной области видимости получает доступ вообще любой участок кода. Стандартная библиотека языка предоставляет доступ ко многим функциям и объектам (parseInt, setTimeout, Math, Map), хотя, говоря о глобальном скоупе в JavaScript, обычно подразумевают глобальные объекты - window в браузере и global в Node.js
const a = () => {
const b = () => {
const c = () => window.open('https://google.com', '_blank');
c();
}
b();
}
a(); // ✅ В новой вкладке откроется страница google.comconst a = () => {
const b = () => {
const c = () => console.log(global);
c();
}
b();
}
a(); // ✅ В консоли появится информация об объекте globalГлобальные объекты можно модифицировать, но пользоваться такой возможностью почти всегда плохая идея: конфликты имен, отсутствие изоляции и гарантированная катастрофа во время отладки и поддержки
Функциональная
Функциональная область видимости, собственно, ограничена функцией, в которой объявлена переменная. Лучше сразу на примерах:
Переменная внутри функции недоступна снаружи
В этом примере message определен внутри функции sendGreeting, поэтому последний console.log(message) не имеет к ней доступа
const sendGreeting = () => {
const message = 'Привет из функции!';
console.log(message);
}
sendGreeting(); // ✅ Привет из функции!
console.log(message); // ❌ Error: message is not defined
Вложенные функции видят переменные родительской функции
Здесь функция inner начинает искать определение в своей области видимости, ничего не находит и поднимается во внешний контекст - функцию outer. Искомая переменная найдена, поэтому console.log успешен!
const outer = () => {
const outerVar = 'Я из outer!';
const inner = () => console.log(outerVar);
inner();
}
outer(); // ✅ Я из outer!Переменные внутри функции не влияют на внешнюю область
Пространство имен для каждого скоупа независимо. Конечно, правильнее избегать повторений имен, чтобы человек, читающий код, не запутался. Но в случае когда понятно о чем идет речь, лучше наоборот оставить единое название
// Переменная x во внешнем скоупе
const x = 10;
const test = () => {
/*
* Переменная x объявлена в скоупе функции test
* Конфликта имен нет!
*/
const x = 20;
console.log(x);
}
test(); // 20
console.log(x); // 10Пожалуй, еще один более реальный пример. Допустим, есть цена товара, а нам нужно применить к ней скидку:
const price = 100;
const applyDiscount = (price, discount) => {
return price - (price * discount) / 100;
}
const priceWithDiscount = applyDiscount(price, 20);
// 80 (Цена со скидкой)
console.log(priceWithDiscount)
// 100 (Исходная цена осталась неизменной)
console.log(price);Функция applyDiscount принимает price как аргумент и создает внутри себя локальную переменную с тем же именем и не конфликтует с ценой, объявленной выше
Блочная
Эта область видимости характерна для переменных, объявленных через let и const. Такой скоуп определяет, где переменные доступны внутри фигурных скобок. Посмотрим на примерах
const x = 10;
if (true) {
const x = 20;
console.log(x); // 20
}
console.log(x); // 10
for (let i = 0; i < 3; i++) {
console.log(i); // 0, 1, 2
}
console.log(i); // ❌ Error: i is not definedПричем блок кода - не обязательно специальная конструкция! Это может быть и просто участок кода, отделенный фигурными скобками. Полезно для понимания, но, само собой, в реальном коде лучше так не баловаться
{
const test = 0;
console.log(test); // 0
}
console.log(test); // ❌ Error: test is not definedМодульная
Ранее я отмечал, что существует только три типа областей видимости, но здесь почему-то еще один! Не совсем так. Дело в том, что модули - особый случай, а не отдельный тип
Каждый модуль создает для себя изолированное окружение, по сути относящееся к функциональной области видимости
// Допустим, это файл utils.js
const isDefined = (value) => value !== undefined;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export { isDefined, sleep }Для простоты восприятия можно представить, что этот код на самом деле завернут в функцию, вызываемую на месте - IIFE (Immediately Invoked Function Expression, (Имми́диатли инво́укд фа́нкшн экспре́шн), дословно - функциональное выражение, вызываемое на месте)
// utils.js
(function () {
const isDefined = (value) => value !== undefined;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
window.utils = { isDefined, sleep }
})();Все модули объявлены на одном уровне, поэтому каждый модуль получает доступ только к глобальной области видимости и собственным переменным и функциям. Конечно, модули умеют обмениваться кодом, но это уже тема другой статьи :)
Замыкания
Наконец, разберемся с замыканиями. Если постараться дать определение, то получится что-то вроде:
Closure (Кло́ужер) - способность функции запоминать переменные из внешней области видимости, даже если эта внешняя область уже завершила выполнение
И как это понимать? Давайте разберемся на примерах:
const createCounter = () => {
let count = 0;
return () => {
count++;
console.log(count);
};
};
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3Функция createCounter создает локальную переменную count и возвращает другую функцию, которая увеличивает count и выводит его в консоль. Внутренняя функция ссылается на переменную count и изменяет ее значение с каждым новым вызовом, несмотря на то, что ее внешний контекст (createCounter) уже отработал!
Другими словами, если функция ссылается на внешнюю переменную, она ее не потеряет - переменная будет доступна до тех пор, пока на нее ссылаются
Как это можно применить на практике? Например, фабрика функций
const createMultiplier = (factor) => {
return (num) => num * factor;
};
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // ✅ 10
console.log(triple(5)); // ✅ 15Вот пример логгера, который выводит сообщения с задержкой в 1 секунду
const createDelayedLogger = (message, delay) => () => {
setTimeout(() => console.log(message), delay);
};
const logHello = createDelayedLogger('Hello, world!', 1000);
logHello(); // Выведет 'Hello, world!' через 1 секунду
Приближенный к реальности пример: можно сделать конструктор для переводчика текста на разные языки в зависимости от языка в браузере пользователя
const DEFAULT_LANGUAGE = 'en';
const createTranslator = () => {
const translations = {
en: { hello: 'Hello', goodbye: 'Goodbye' },
ru: { hello: 'Привет', goodbye: 'До свидания' },
es: { hello: 'Hola', goodbye: 'Adiós' }
};
const [userLanguage] = navigator.language.split('-');
return (key) => (
translations[userLanguage]?.[key] ||
translations[DEFAULT_LANGUAGE]?.[key] ||
key
);
};
const translate = createTranslator();
// Выведет "Привет", если у пользователя русский язык
console.log(translate('hello'));
// Выведет "Adiós", если у пользователя испанский
console.log(translate('goodbye'));Замыкания позволяют сохранять доступ к переменным даже после завершения работы их родительской функции. Причем это работает не только для соседнего уровня вложенности
const outer = () => {
const a = 'Я из самого внешнего замыкания';
const middle = () => {
const b = 'Я из среднего уровня';
const inner = () => {
const c = 'Я из самого вложенного уровня';
console.log(a); // Доступ к переменной из outer
console.log(b); // Доступ к переменной из middle
console.log(c); // Доступ к своей локальной переменной
}
return inner;
}
return middle();
}
const fn = outer(); // fn теперь хранит функцию inner
fn();Почему это работает?
Дальше мы заглянем под капот JavaScript. Без низкоуровневых знаний представление останется неполным, однако я постараюсь выжать максимум полезной информации из той, что знаю, в минимум текста. Если сейчас не готовы - можете вернуться к этой главе позже. Поехали!
В JavaScript для каждой области видимости (глобальная, функциональная, блочная) создается скрытый объект, который называется LexicalEnvironment (Ле́ксикал Инва́йронмент) - Лексическое Окружение. Эта сущность определяет какие переменные и функции доступны в текущем контексте выполнения
Лексическое окружение - это сущность спецификации языка и описана только теоретически. На такое окружение нельзя ссылаться в коде и изменять. По сути, эта концепция существует чтобы объяснить принцип связи между областями видимости
Лексическое окружение состоит из двух частей:
- Environment Record (Инва́йронмент Ре́корд) - хранит локальные переменные (
let,const), параметры функции и объявления (Function Declaration) - Ссылка на внешнее лексическое окружение (Outer Reference). Для глобального лексического окружения такой ссылки не будет
Когда код выполняется, движок JavaScript сначала ищет переменную в текущем лексическом окружении. Если не нашел, идет выше по цепочке вплоть до глобального лексического окружения. Иными словами, формируется связный список лексических окружений (Scope Chain) по которым и происходит поиск
Начинает складываться картинка? Если нет, ничего страшного, мы еще не закончили 😀 Зафиксируем то, что мы только что узнали и попробуем визуализировать, как бы выглядело такое лексическое окружение на каком-нибудь примере
/**
* Здесь создается глобальное лексическое окружение
* Назовем его GlobalLE
*
* GlobalLE = {
* environmentRecord: {
* globalVar: 'Я глобальная!',
* // Объявление функции
* outerFunction: FunctionDefinition
* },
* // Это глобальный скоуп
* outer: null
* }
*/
const globalVar = 'Я глобальная!';
const outerFunction = () => {
/**
* Здесь создается лексическое окружение
* для функции outerFunction
*
* OuterLE = {
* environmentRecord: {
* outerVar: 'Я из outer!',
* // Объявление функции
* innerFunction: FunctionDefinition
* },
* // Ссылка на глобальный скоуп
* outer: GlobalLE
* }
*/
const outerVar = 'Я из outer!';
const innerFunction = () => {
/**
* А здесь для функции innerFunction
*
* InnerLE = {
* environmentRecord: {
* innerVar: 'Я из inner!'
* },
* // Ссылка на окружение outerFunction()
* outer: OuterLE
* }
*/
const innerVar = 'Я из inner!';
console.log(globalVar);
console.log(outerVar);
console.log(innerVar);
}
innerFunction();
}
outerFunction();Чтобы найти определение globalVar в функции, движок проследует от лексического окружения функции innerFunction() до глобального скоупа. Если бы переменная globalVar не была бы определена - мы бы получили ошибку ReferenceError, так как у глобального лексического окружения outerRef: null
InnerLE → OuterLE → GlobalLE → null
В нашем случае, связный список лексических окружений можно представить в формате объекта JavaScript так:
const LexicalEnvironments = {
GlobalLE: {
environmentRecord: {
globalVar: 'Я глобальная!',
outerFunction: FunctionDefinition,
},
outer: null,
},
OuterLE: {
environmentRecord: {
outerVar: 'Я из outer!',
innerFunction: FunctionDefinition,
},
outer: GlobalLE,
},
InnerLE: {
environmentRecord: {
innerVar: 'Я из inner!',
},
outer: OuterLE,
}
};Именно это и позволяет реализовать механизм замыканий для функций. Лексическое окружение живет, пока на него ссылаются, а хранящиеся в environmentRecord переменные и функции будут доступны
Заключение
Приветствую самых стойких - время подводить итоги! Читая эту статью, могло показаться, что JavaScript довольно капризный. Уверяю, это вам не показалось 😀 Одновременно с этим, JavaScript еще и очень мощный инструмент!
Области видимости и замыкания - ключ к написанию чистого кода и важный этап в понимании языка. Хотел бы я сказать, что этот этап позади, но понадобится время, чтобы закрепить материал на практике
Что мы изучили сегодня:
- Области видимости - механизм управления доступом к сущностям в коде
- Глобальная - доступ откуда угодно
- Функциональная - доступ в рамках функции
- Блочная - доступ в рамках блока кода
- Замыкания - способность функции запоминать внешние переменные
- Лексическое окружение - загадочное нечто, позволяющее всему этому работать
Практикуйтесь, экспериментируйте – и JavaScript станет гораздо понятнее!