Это практическое руководство по работе с замыканиями в JavaScript


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

Правильное представление о замыканиях поможет вам писать более эффективный и «чистый» код, чтобы стать отличным JavaScript разработчиком.

В этой статье я попробую объяснить, как устроены замыкания и как они работают в JavaScript.

Начнём без промедлений 🙂


Что такое замыкание?

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

Перед тем как продолжить, давайте разберёмся с лексической областью видимости.

Что такое лексическая область видимости?

Лексическая или статическая область видимости в JavaScript относится к понятиям доступности переменных, функций и объектов в зависимости от их физического расположения в исходном коде. Пример:

let a = 'global';

function outer() {
    let b = 'outer';

function inner() {
      let c = 'inner'
      console.log(c);   // prints 'inner'
      console.log(b);   // prints 'outer'
      console.log(a);   // prints 'global'
    }
    console.log(a);     // prints 'global'
    console.log(b);     // prints 'outer'
    inner();
  }
outer();
console.log(a);         // prints '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(); // prints '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 функции getCounter, даже после завершения getCounter().

Обратите внимание, что значение counter не сбрасывается на 0 при каждом вызове функции count, как это обычно бывает.

Так происходит потому, что при каждом вызове count() для неё создаётся новая область видимости. Но для функции getCounter существует только одна область видимости, потому что переменная counter определена в пространстве getCounter(). Её значение будет увеличиваться при каждом вызове функции count, а не обнуляться.

Как работают замыкания?

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

Чтобы в полной мере понять, как работают замыкания в JavaScript, необходимо знать два наиболее важных понятия: 1) контекст исполнения и 2) лексическое окружение.

Контекст исполнения

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

В текущий момент может быть только один контекст исполнения (потому что JavaScript — однопоточный язык). Этот процесс управляется структурой данных стека, известным как Execution Stack или Call Stack.

Execution Stack — это стек со структурой LIFO (Last in, first out), в котором элементы могут быть добавлены или удалены только с верху стека.

Текущий контекст исполнения всегда находиться в верхней части стека. После выполнения текущей функции, её контекст исполнения «вылетит» из стека, а контроль перейдёт к следующему, под ним в стеке.

Давайте разберём фрагмент кода, чтобы лучше понимать контекст исполнения и стек:

 

После выполнения этого кода, движок JavaScript создаёт глобальный контекст исполнения, чтобы выполнить глобальный код. Когда JS встречает вызов функции first(), он создаёт новый контекст исполнения для этой функции, и «проталкивает» его на верх стека.

Стек исполнения для этого кода выглядит вот так:

 

Когда функция first() завершена, она удаляется из стека. Управление переходит к следующему контексту, в этом случае, к глобальному контексту исполнения. Оставшийся код будет выполнен в глобальном пространстве.

Лексическое окружение

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

Лексическое окружение — это структура данных, которая содержит карту соответствий идентификатор-переменная. В ней идентификатор ссылается на имя переменной/функции, а переменная на сам объект (включая функциональный объект) или на примитивное значение.

В лексическом окружении есть два компонента: 1) запись о внешних условиях и 2) ссылка на внешнюю среду.

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

2. Ссылка на внешнюю среду — означает наличие доступа к внешнему (родительскому) лексическому окружению. Этот компонент очень важен для понимания работы замыканий.

Концептуально лексическое окружение выглядит так:

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');

Когда движок JavaScript создаёт глобальный контекст исполнения, для выполнения глобального кода, он также создаёт новое лексическое окружение в глобальном пространстве. Лексическое окружение для глобального пространства выглядит так:

globalLexicalEnvironment = {
  environmentRecord: {
      a     : 'Hello World!',
      first : < reference to function object >
  }
  outer: null
}

Здесь для лексического окружения установлен null, потому что нет внешнего лексического окружения для глобального пространства.

Когда движок создаёт контекст исполнения для функции first(), он также создаёт лексическое окружение для хранения переменных, определённых в процессе выполнения функции. Лексическое окружение функции выглядит так:

functionLexicalEnvironment = {
  environmentRecord: {
      b    : 25,
  }
  outer: <globalLexicalEnvironment>
}

Для внешнего лексического окружения функции установлено глобальное лексическое окружение, потому что функция окружена глобальным пространством в исходном коде.

Примечание

После выполнения функции, её контекст исполнения удаляется из стека. Но удалится ли её лексическое окружение из памяти, зависит от того, ссылается ли на него другое лексическое окружение, в свойствах их внешнего окружения.

Примеры замыканий. В деталях

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

Пример №1

Разберём этот код:

function person() {
  let name = 'Peter';
  
  return function displayName() {
    console.log(name);
  };
}

let peter = person();
peter(); // prints 'Peter'

После выполнения функции person, движок JavaScript создаёт новый контекст исполнения и лексическое окружение для функции. После её завершения, мы возвращаем функцию displayName и присваиваем её к переменной peter.

Её лексическое окружение выглядит так:

personLexicalEnvironment = {
  environmentRecord: {
    name : 'Peter',
    displayName: < displayName function reference>
  }
  outer: <globalLexicalEnvironment>
}

Когда функция person завершена, её контекст исполнения удаляется из стека. Но её лексическое окружение остаётся в памяти, потому что на него ссылается лексическое окружение внутренней функции displayName. Поэтому её переменные всё ещё доступны в памяти.

Когда функция peter выполнена (она является отсылкой к функции displayName), движок создаёт новый контекст исполнения и лексическое окружение для этой функции.

Её лексическое окружение выглядит так:

displayNameLexicalEnvironment = {
  environmentRecord: {
    
  }
  outer: <personLexicalEnvironment>
}

Так как в функции displayName нет переменных, её запись о внешних условиях будет пустой. В процессе выполнения этой функции, движок JavaScript попытается найти переменную name, в её лексическом окружении.

Так как в лексическом окружении функции displayName нет переменных, JS будет смотреть во внешнем окружении, а именно, в лексическом окружении функции person, которая всё ещё в памяти. Движок JavaScript найдёт переменную и выведет name в консоли.

Пример №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>
}

Когда вызвана функция count, движок JavaScript будет искать переменную counter в лексическом окружении этой функции. И снова, запись окружения пуста, поэтому движок будет смотреть во внешнем лексическом окружении функции.

Движок найдёт переменную, выведет её в консоли и инкрементирует переменную счётчик в лексическом окружении функции getCounter.

После первого вызова функции count, лексическое окружение для функции getCounter будет выглядеть так:

getCounterLexicalEnvironment = {
  environmentRecord: {
    counter: 1,
    <anonymous function> : < reference to function>
  }
  outer: <globalLexicalEnvironment>
}

При каждом вызове функции count, движок JavaScript создаёт для неё новое лексическое окружение, инкрементирует переменную counter и обновляет лексическое окружение функции getCounter, чтобы отразить изменения.

Заключение

Теперь вы знаете, что такое замыкания и как они работают. Замыкания — это базовая концепция JavaScript, которую должен понимать каждый JS разработчик. Эти знания помогут вам быть более эффективным в разработке.

 

Перевод статьи Sukhjinder Arora : Understanding Closures in JavaScript

Предыдущая статьяСоздание правильного чек-листа для инспекции кода
Следующая статья9 лучших примеров макетов сайта и идей для веб-дизайна в 2018