Замыкания — это одна из фундаментальных концепций JavaScript, вызывающая сложности у многих новичков, знать и понимать которую должен каждый JS-программист. Хорошо разобравшись с замыканиями, вы сможете писать более качественный, эффективный и чистый код. А это, в свою очередь, будет способствовать вашему профессиональному росту.
Материал, перевод которого мы публикуем сегодня, посвящён рассказу о внутренних механизмах замыканий и о том, как они работают в JavaScript-программах.
Что такое замыкание?
Замыкание — это функция, у которой есть доступ к области видимости, сформированной внешней по отношению к ней функции даже после того, как эта внешняя функция завершила работу. Это значит, что в замыкании могут храниться переменные, объявленные во внешней функции и переданные ей аргументы. Прежде чем мы перейдём, собственно, к замыканиям, разберёмся с понятием «лексическое окружение».
Что такое лексическое окружение?
Понятие «лексическое окружение» или «статическое окружение» в JavaScript относится к возможности доступа к переменным, функциям и объектам на основе их физического расположения в исходном коде. Рассмотрим пример:
let a = 'global';
function outer() {
let b = 'outer';
function inner() {
let c = 'inner'
console.log(c); // 'inner'
console.log(b); // 'outer'
console.log(a); // 'global'
}
console.log(a); // 'global'
console.log(b); // 'outer'
inner();
}
outer();
console.log(a); // 'global'
Здесь у функции inner()
есть доступ к переменным, объявленным в её собственной области видимости, в области видимости функции outer()
и в глобальной области видимости. Функция outer()
имеет доступ к переменным, объявленным в её собственной области видимости и в глобальной области видимости.
Цепочка областей видимости вышеприведённого кода будет выглядеть так:
Global {
outer {
inner
}
}
Обратите внимание на то, что функция inner()
окружена лексическим окружением функции outer()
, которая, в свою очередь, окружена глобальной областью видимости. Именно поэтому функция inner()
может получить доступ к переменным, объявленным в функции outer()
и в глобальной области видимости.
Практические примеры замыканий
Рассмотрим, прежде чем разбирать тонкости внутреннего устройства замыканий, несколько практических примеров.
▍Пример №1
function person() {
let name = 'Peter';
return function displayName() {
console.log(name);
};
}
let peter = person();
peter(); // 'Peter'
Здесь мы вызываем функцию person()
, которая возвращает внутреннюю функцию displayName()
, и сохраняем эту функцию в переменной peter
. Когда мы, после этого, вызываем функцию peter()
(соответствующая переменная, на самом деле, хранит ссылку на функцию displayName()
), в консоль выводится имя Peter
.
При этом в функции displayName()
нет переменной с именем name
, поэтому мы можем сделать вывод о том, что эта функция может каким-то образом получать доступ к переменной, объявленной во внешней по отношению к ней функции, person()
, даже после того, как эта функция отработала. Возможно это так из-за того, что функция displayName()
, на самом деле, является замыканием.
▍Пример №2
function getCounter() {
let counter = 0;
return function() {
return counter++;
}
}
let count = getCounter();
console.log(count()); // 0
console.log(count()); // 1
console.log(count()); // 2
Тут, как и в предыдущем примере, мы храним ссылку на анонимную внутреннюю функцию, возвращённую функцией getCounter()
, в переменной count
. Так как функция count()
представляет собой замыкание, она может обращаться к переменной counter
функции getCount()
даже после того, как функция getCounter()
завершила работу.
Обратите внимание на то, что значение переменной counter
не сбрасывается в 0 при каждом вызове функции count()
. Может показаться, что оно должно сбрасываться в 0, как могло бы быть при вызове обычной функции, но этого не происходит.
Всё работает именно так из-за того, что при каждом вызове функции count()
для неё создаётся новая область видимости, но существует лишь одна область видимости для функции getCounter()
. Так как переменная counter
объявлена в области видимости функции getCounter()
, её значение между вызовами функции count()
сохраняется, не сбрасываясь в 0.
Как работают замыкания?
До сих пор мы говорили о том, что такое замыкания, и рассматривали практические примеры. Теперь поговорим о внутренних механизмах JavaScript, обеспечивающих их работу.
Для того чтобы понять замыкания, нам нужно разобраться с двумя важнейшими концепциями JavaScript. Это — контекст выполнения (Execution Context) и лексическое окружение (Lexical Environment).
▍Контекст выполнения
Контекст выполнения — это абстрактное окружение, в котором вычисляется и выполняется JavaScript-код. Когда выполняется глобальный код, это происходит внутри глобального контекста выполнения. Код функции выполняется внутри контекста выполнения функции.
В некий момент времени может выполняться код лишь в одном контексте выполнения (JavaScript — однопоточный язык программирования). Управление этими процессами ведётся с использованием так называемого стека вызовов (Call Stack).
Стек вызовов — это структура данных, устроенная по принципу LIFO (Last In, First Out — последним вошёл, первым вышел). Новые элементы можно помещать только в верхнюю часть стека, и только из неё же элементы можно изымать.
Текущий контекст выполнения всегда будет в верхней части стека, и когда текущая функция завершает работу, её контекст выполнения извлекается из стека и управление передаётся контексту выполнения, который был расположен ниже контекста этой функции в стеке вызовов.
Рассмотрим следующий пример для того, чтобы лучше разобраться в том, что такое контекст выполнения и стек вызовов:
Пример контекста выполнения
Когда выполняется этот код, JavaScript-движок создаёт глобальный контекст выполнения для выполнения глобального кода, а когда встречает вызов функции first()
, создаёт новый контекст выполнения для этой функции и помещает его в верхнюю часть стека.
Стек вызовов этого кода выглядит так:
Стек вызовов
Когда завершается выполнение функции first()
, её контекст выполнения извлекается из стека вызовов и управление передаётся контексту выполнения, находящемуся ниже его, то есть — глобальному контексту. После этого будет выполнен оставшийся в глобальной области видимости код.
▍Лексическое окружение
Каждый раз, когда JS-движок создаёт контекст выполнения для выполнения функции или глобального кода, он создаёт и новое лексическое окружение для хранения переменных, объявляемых в этой функции в процессе её выполнения.
Лексическое окружение — это структура данных, которая хранит сведения о соответствии идентификаторов и переменных. Здесь «идентификатор» — это имя переменной или функции, а «переменная» — это ссылка на объект (сюда входят и функции) или значение примитивного типа.
Лексическое окружение содержит два компонента:
- Запись окружения (environment record) — место, где хранятся объявления переменных и функций.
- Ссылка на внешнее окружение (reference to the outer environment) — ссылка, позволяющая обращаться к внешнему (родительскому) лексическому окружению. Это — самый важный компонент, с которым нужно разобраться для того, чтобы понять замыкания.
Концептуально лексическое окружение выглядит так:
lexicalEnvironment = {
environmentRecord: {
<identifier> : <value>,
<identifier> : <value>
}
outer: < Reference to the parent lexical environment>
}
Взглянем на следующий фрагмент кода:
let a = 'Hello World!';
function first() {
let b = 25;
console.log('Inside first function');
}
first();
console.log('Inside global execution context');
Когда JS-движок создаёт глобальный контекст выполнения для выполнения глобального кода, он создаёт и новое лексическое окружение для хранения переменных и функций, объявленных в глобальной области видимости. В результате лексическое окружение глобальной области видимости будет выглядеть так:
globalLexicalEnvironment = {
environmentRecord: {
a : 'Hello World!',
first : < reference to function object >
}
outer: null
}
Обратите внимание на то, что ссылка на внешнее лексическое окружение (outer
) установлена в значение null
, так как у глобальной области видимости нет внешнего лексического окружения.
Когда движок создаёт контекст выполнения для функции first()
, он создаёт и лексическое окружение для хранения переменных, объявленных в этой функции в ходе её выполнения. В результате лексическое окружение функции будет выглядеть так:
functionLexicalEnvironment = {
environmentRecord: {
b : 25,
}
outer: <globalLexicalEnvironment>
}
Ссылка на внешнее лексическое окружение функции установлена в значение <globalLexicalEnvironment>
, так как в исходном коде код функции находится в глобальной области видимости.
Обратите внимание на то, что когда функция завершит работу, её контекст выполнения извлекается из стека вызовов, но её лексическое окружение может быть удалено из памяти, а может и остаться там. Это зависит от того, существуют ли в других лексических окружениях ссылки на данное лексическое окружение в виде ссылок на внешнее лексическое окружение.
Подробный разбор примеров работы с замыканиями
Теперь, когда мы вооружились знаниями о контексте выполнения и о лексическом окружении, вернёмся к замыканиям и более глубоко проанализируем те же фрагменты кода, которые мы уже рассматривали.
▍Пример №1
Взгляните на данный фрагмент кода:
function person() {
let name = 'Peter';
return function displayName() {
console.log(name);
};
}
let peter = person();
peter(); // 'Peter'
Когда выполняется функция person()
, JS-движок создаёт новый контекст выполнения и новое лексическое окружение для этой функции. Завершая работу, функция возвращает функцию displayName()
, в переменную peter
записывается ссылка на эту функцию.
Её лексическое окружение будет выглядеть так:
personLexicalEnvironment = {
environmentRecord: {
name : 'Peter',
displayName: < displayName function reference>
}
outer: <globalLexicalEnvironment>
}
Когда функция person()
завершает работу, её контекст выполнения извлекается из стека. Но её лексическое окружение остаётся в памяти, так как ссылка на него есть в лексическом окружении её внутренней функции displayName()
. В результате переменные, объявленные в этом лексическом окружении, остаются доступными.
Когда вызывается функция peter()
(соответствующая переменная хранит ссылку на функцию displayName()
), JS-движок создаёт для этой функции новый контекст выполнения и новое лексическое окружение. Это лексическое окружение будет выглядеть так:
displayNameLexicalEnvironment = {
environmentRecord: {
}
outer: <personLexicalEnvironment>
}
В функции displayName()
нет переменных, поэтому её запись окружения будет пустой. В процессе выполнения этой функции JS-движок попытается найти переменную name
в лексическом окружении функции.
Так как в лексическом окружении функции displayName()
искомое найти не удаётся, поиск продолжится во внешнем лексическом окружении, то есть, в лексическом окружении функции person()
, которое всё ещё находится в памяти. Там движок находит нужную переменную и выводит её значение в консоль.
▍Пример №2
function getCounter() {
let counter = 0;
return function() {
return counter++;
}
}
let count = getCounter();
console.log(count()); // 0
console.log(count()); // 1
console.log(count()); // 2
Лексическое окружение функции getCounter()
будет выглядеть так:
getCounterLexicalEnvironment = {
environmentRecord: {
counter: 0,
<anonymous function> : < reference to function>
}
outer: <globalLexicalEnvironment>
}
Эта функция возвращает анонимную функцию, которая назначается переменной count
.
Когда выполняется функция count()
, её лексическое окружение выглядит так:
countLexicalEnvironment = {
environmentRecord: {
}
outer: <getCountLexicalEnvironment>
}
При выполнении этой функции система будет искать переменную counter
в её лексическом окружении. В данном случае, опять же, запись окружения функции пуста, поэтому поиск переменной продолжается во внешнем лексическом окружении функции.
Движок находит переменную, выводит её в консоль и инкрементирует переменную counter
, хранящуюся в лексическом окружении функции getCounter()
.
В результате лексическое окружение функции getCounter()
после первого вызова функции count()
будет выглядеть так:
getCounterLexicalEnvironment = {
environmentRecord: {
counter: 1,
<anonymous function> : < reference to function>
}
outer: <globalLexicalEnvironment>
}
При каждом следующем вызове функции count()
JavaScript-движок создаёт новое лексическое окружение для этой функции и инкрементирует переменную counter
, что приводит к изменениям в лексическом окружении функции getCounter()
.
Итоги
В этом материале мы поговорили о том, что такое замыкания, и разобрали глубинные механизмы JavaScript, лежащие в их основе. Замыкания — одна из важнейших фундаментальных концепций JavaScript, её должен понимать каждый JS-разработчик. Понимание замыканий — это одна из ступеней пути к написанию эффективных и качественных приложений.
Уважаемые читатели! Если вы обладаете опытом JS-разработки — просим поделиться с начинающими практическими примерами применения замыканий.
Автор: ru_vds