5.01. Видимость в JS
Видимость в JS
В JavaScript есть болезненная и связанная тема, которая постоянно мучает новичков. Лично я прочитал столько учебников, но суть не мог понять, пока не разобрал вообще всё целиком с кучей различных материалов, объединив всё в некую общую картину, как детектив. Поэтому давайте сразу с вами разберём фундаментальный и глубокий вопрос, который нельзя изучать поверхностно.
Как мы помним, JavaScript - код интерпретируемый и однопоточный, который разбирает каждую строку по очереди. Фишка в том, что когда пишется код, мы не просто описываем последовательность действий, а создаём целую структуру данных и определяем поведение этих данных, живущих в определённой среде. JS имеет динамическую природу, и эта «динамичность» подразумевает в себе целый набор инструментов.
Лексическое окружение (Lexical Environment) - особый абстрактный объект в движке JavaScript, который создаётся автоматически при выполнении кода. В привычном смысле слова это не объект (вы не увидите его в консоли напрямую), но он реально существует и участвует в управлении переменными. Каждый блок кода {}, будь то функция, условие if, цикл for, или обычный блок {}, будет создавать своё лексическое окружение.
Словом, указывая фигурные скобки, мы автоматически создаём некий контейнер, где будут храниться переменные и функции, доступные в данной области кода.
Каждое лексическое окружение состоит из двух компонентов:
- Environment Record (запись окружения), которая хранит переменные, функции, параметры, объявленные внутри блока. Технически это словарь пар «имя - значение».
- Ссылка на внешнее лексическое окружение (Outer Lexical Environment), указывает на окружение, в котором был объявлен текущий блок (важно - не вызван, а объявлен!). Это и обеспечивает механизм поиска переменных по цепочке.
Давайте для примера возьмём такой код, с переменной x, внешней функцией (outer) и внутренней функцией (inner).
let x = 10;
function outer() {
let y = 20;
function inner() {
let z = 30;
console.log(x, y, z); // 10, 20, 30
}
inner();
}
outer();
Здесь получается, что сначала мы объявили переменную x и присвоили ей значение 10. Это «куда-то записалось». Потом мы создали блок кода с функцией outer, а внутри объявили и присвоили значение переменной y, и создали ещё один вложенный блок кода с функцией inner, где тоже объявили и присвоили значение переменной z. И потом в самом нижнем блоке inner мы обратились к объекту console и вызвали его функцию log, передав ей аргументы x, y, z. И прикол в том, что нижний уровень знает, что означают x и y, хотя они объявлены во внешних блоках!
Функцию inner мы вызываем из блока outer, а функцию outer мы вызываем из глобального окружения.
Технически это выглядеть будет так:

| Глобальное окружение | Outer() | Inner() |
|---|---|---|
| переменные: x: 10 | переменные: y: 20 | переменные: z: 30 |
| функции: outer: function | функции: inner: function | функции: - |
| ссылки: - | ссылки: outer ссылается на глобальное окружение. | ссылки: inner ссылается на outer. |
Словом, когда выполняется inner():
- сначала выполняется поиск z - и находит в своём окружении;
- потом ищет y - не находит в своём и отправляется во внешнее (outer) и находит;
- потом ищет x - не находит, идёт выше - не находит и идет ещё выше, на глобальный уровень - и там находит.
Это и есть цепочка поиска переменных по лексическому окружению.
Как можно заметить, всегда есть глобальное окружение - то есть, весь скрипт (всё что в теге <script></script> или в файле скрипта) является глобальным объектом, а каждый создаваемый блок будет записан в глобальном лексическом окружении.
Область видимости (scope) - не то же самое, что лексическое окружение. Это правило, определяющее, где в коде переменная доступна для использования. Лексическое окружение это реализация механизма видимости, а область видимости - концепция, описывающая как переменные становятся доступными (как правила видимости).
В JS бывают следующие типы областей видимости:
- Глобальная (Global Scope) - переменные доступны везде, создаются в глобальном лексическом окружении.
- Функциональная (Function Scope) - доступны только внутри функции, создаётся при вызове функции (но определяется при объявлении - лексически).
- Блочная (Block Scope) - доступны только внутри
{}блока, используется с ключевыми словамиletиconst. Позже мы изучим переменные и запомните сразу - слово var не создаёт блочную область - только функциональную.
{
var a = 1;
let b = 2;
const c = 3;
}
console.log(a); // 1 — var "просочился" наружу блока
console.log(b); // Ошибка! b не в глобальной области
Глобальное окружение является корневым лексическим окружением, которое существует всегда и не имеет внешнего окружения. В браузере оно связано с объектом window, а в Node.js с объектом global. Все глобальные переменные и функции становятся свойствами этого объекта.
Глобальные значения вроде undefined (неопределенное), NaN (не число), Infinity (бесконечность) являются свойствами глобального объекта, то есть в браузере window. Технически, их можно даже переопределить (хотя так делать нельзя):
window.undefined = "hacked";
console.log(undefined); // всё ещё undefined — в ES5+ это запечатано
Замыкание (Closure) - это комбинация функции и лексического окружения, в котором эта функция была объявлена. Функция «запоминает доступ к переменным из внешнего окружения даже после того, как внешняя функция завершила выполнение.
function createCounter() {
let count = 0;
return function() {
count++;
console.log(count);
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
В выше приведённом примере есть функция createCounter(), в которой объявляется переменная count и ей присваивается значение 0. Затем используется ключевое слово return и указывается, что возвращается функция - которая использует переменную count, добавляя ей +1 (оператор ++, инкремент). Словом, увеличивается счётчик при каждом вызове функции. И обратите внимание - потом мы создали переменную (константу) counter которая равна createCounter(), и обращаемся через counter() снова и снова. И магия в том, что хоть функция в первый раз окончательно завершилась, логически вроде бы должно было всё начаться с нуля, и count будет равным снова 0? Но всё же при каждом вызове счётчик увеличивается. Это благодаря замыканию.
createCounter создаёт переменную count, возвращает внутреннюю функцию, и как раз внутренняя функция захватывает count из внешнего окружения. Даже после завершения createCounter, переменная count не уничтожается, потому что на неё ссылается замыкание.
Чтобы не путаться, можем подчеркнуть, что замыкание работает всегда, а не только со счётчиками:
function test() {
let age = 30;
let maxage = function() {
return (age + 30);
};
return maxage;
}
const result = test();
console.log(result()); // 60
Это классическое замыкание - внутренняя функция maxage объявлена внутри внешней функции test. Она использует переменную age из внешней области. Эта внутренняя функция возвращается и сохраняется вне test (в переменной result), и при этом внешняя функция test уже завершила своё выполнение. Но переменная age всё ещё доступна при вызове result(). Суть замыкания как раз в этом - функция продолжает иметь доступ к переменным из внешнего лексического окружения, даже после того, как это окружение «ушло» из стека вызовов.
Почему age не уничтожается? Обычно да, когда функция завершается, её локальные переменные удаляются из памяти (выбрасываются из стека вызовов). Но если существует функция, которая захватила эти переменные, и эта функция всё ещё может быть вызвана, то движок не удаляет переменные, а сохраняет лексическое окружение, чтобы замыкание могло работать.
Давайте разберем:
Когда вызывается test():
- создаётся окружение
test:{ age: 30, maxage: function }; - возвращается функция
maxage; - функция maxage «помнит» своё внешнее окружение (
test); - даже после завершения test, окружение сохраняется - из-за замыкания.
Это не копия переменной age, а ссылка на неё. Если бы age изменилась, то maxage увидела бы новое значение. А как мы помним, в примере с createCounter() мы увеличивали значение count каждый раз через инкремент ++, а значит значение count менялось снова и снова, а лексическое окружение предоставляет актуальное значение.
Замыкание не будет, если переменная не используется. То есть, оно создаётся только тогда, когда внутренняя функция действительно использует переменные из внешнего окружения.
function test() {
let age = 30;
let greet = function() {
console.log("Привет!");
// Никаких ссылок на age
};
return greet;
}
const fn = test();
fn(); // "Привет!"
Обратите внмание на код выше. Здесь нет замыкания на age, потому что greet не использует age. Даже если переменная объявлена, но не захвачена, она будет удалена при завершении test. Если вы пропишете такой код в IDE, к примеру, VS Code, переменная age даже будет подсвечена серым цветом, мол, дружище, ты объявил переменную но ни разу не использовал её.
Итого, замыкание будет, если внутренняя функция использует переменную извне (даже если переменная одна, значение примитивное и переменная позже изменится), и замыкания не будет, если внутренняя функция не ссылается на внешние переменные или если функция просто объявлена внутри, но не возвращается или не сохраняется.
И как всегда, бывает ньюанс.
function test() {
let secret = "shh";
function reveal() {
console.log(secret);
}
reveal(); // вызов внутри
}
test(); // "shh"
Здесь есть захват переменной secret, но внутренняя функция не возвращается, а после завершения test - всё уничтожается. Это всё равно замыкание, механизм тот же, но эффект временный, так сказать, разовый. Просто мы не видим «долгосрочного удержания» переменной. Словом, test - отдельный независимый блок, а reveal видит secret внутри работы данного test. Да, технически результат примерно одинаковый, но есть вот такая необычная штука - это из-за внутреннего вызова.
Словом, механизм будет работать всегда, главное соблюдать правила работы с областями видимости и понимать логику работы лексического окружения.
Если хотите посмотреть на механизм ещё глубже - можете использовать любой из вышеперечисленных примеров с внутренней функцией, поставить debugger внутри внутренней функции, и в режиме отладки в инструментах разработчика посмотрите на вкладку Scope - увидите Local (свои переменные), Closure (захваченные переменные извне) и Global. Но можете вернуться к этому позже, ведь мы ещё не закончили.
Лексическое окружение есть и в других языках - C#, Python, Java, C++, Go. Со своими ньюансами и разной реализацией, но всё же есть. Но JavaScript — один из самых гибких языков в плане замыканий, потому что позволяет захватывать и изменять переменные из внешнего окружения без жёстких ограничений.
Контекст вызова и this - ещё важные понятия, которые не нужно путать с областью видимости. И снова мы что-то отделяем от области видимости 😊 - scope определяет доступ к переменным, а контекст вызова (this) определяет «кто» вызвал функцию, и на что указывает this внутри неё. Словом, this это не часть лексического окружения - this динамический и зависит от способа вызова, а не от места объявления.
Чтобы разобраться, где и какой this, надо запомнить правила определения this:
| Способ вызова | Значение | this |
|---|---|---|
| Обычный вызов func() | undefined в strict mode; window в non-strict. | |
| Вызов метода объекта obj.method() | obj | |
| Вызов через call, apply, bind | указанное значение | |
| Конструктор через new | новый объект | |
| Стрелочная функция | наследует this от внешней функции (лексически). |
const user = {
name: "Timur",
greet: function() {
console.log(this.name); // this = user
},
arrowGreet: () => {
console.log(this.name); // this = window (в браузере)
}
};
user.greet(); // "Timur"
user.arrowGreet(); // undefined (или window.name)
Внимательно прочитайте код выше. Мы создаём объект user и записываем его в константу. У этого объекта есть name, greet и arrowGreet. И в greet мы вызываем this.name, что означает «возьми name отсюда», и this равен user - текущему объекту. Но если мы возьмём arrowGreet и в ней укажем this.name, то this будет пытаться искать в объекте глобальном - window, то есть window.name - а такого мы не определили, и следовательно получаем «неопределенное значение» - undefined. Собственно, отсюда можно сделать вывод, что стрелочные функции не имеют своего this — они берут его из внешнего лексического окружения.
Учитывая, что способ объявления функции влияет на this, важно сразу выделить три способа объявления функций:
- Function Declaration - именованная функция. Это обычное объявление функции через ключевое слово
function имяФункции() {}. Пример:function greet() { ... }
В FD имеется свой собственный this, который зависит от способа вызова.
2. Function Expression (анонимная или именованная функция в переменной). То же самое, что и первый способ, но только имя функции можно не задавать - имя будет указано в переменной. Синтаксис построения здесь подразумевает объявление переменной, и присвоение ей безымянной (анонимной) функции в качестве значения:
const greet = function() { ... };
В FE this будет динамическим.
- Arrow Function (стрелочная функция), напоминающая FE, но работающая по синтаксису (аргументы)
=>тело.
const greet = () => { ... };
В AF нет своего this, он, как мы отметили ранее, наследуется от внешнего окружения.
Мы упомянули, что this может быть динамическим.
У обычных функций this - динамический (определяется в момент вызова). Момент вызова - это когда мы обращаемся к функции, допустим пишем greet().
У стрелочных функций this - лексический (определяется местом объявления). Место объявления - это когда мы впервые описываем функцию, создаем блок кода (тело функции).
И поскольку стрелочная функция сохраняет текущий контекст и не будет меняться, то её можно использовать в случаях, когда контекст нужно сохранить.
const obj = {
name: "Bob",
regular: function() {
console.log(this.name); // Bob
setTimeout(function() {
console.log(this.name); // undefined (this = window)
}, 100);
},
arrow: function() {
console.log(this.name); // Bob
setTimeout(() => {
console.log(this.name); // Bob (стрелочная функция унаследовала this)
}, 100);
}
};
Давайте разберём. Мы создаём объект obj, записываем в константу, создаём ему name, regular, arrow. Сначала regular, опять же, this.name увидит как obj.name, то есть «Bob». И если мы вызовем this.name внутри новой функции setTimeout, то блок кода, как и контекст, изменится и мы при попытке обращения к this.name не увидим ничего, так как в месте вызова console.log нет name - контекст иной - поэтому получим undefined.
А в arrow, стрелочной функции, мы увидим один и тот же name - потому что стрелочная функция унаследовала this, ибо контекст здесь будет моментом объявления, то есть - объектом obj, и this.name всегда будет эквивалентен obj.name. Почему obj - потому что name на одном уровне с arrow, и место объявления - всегда тот же уровень.