javascript
March 25

Замыкания и области видимости в 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.com

Node.js

const 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. Такой скоуп определяет, где переменные доступны внутри фигурных скобок. Посмотрим на примерах

Блок if

const x = 10;

if (true) {
  const x = 20;
  console.log(x); // 20
}

console.log(x); // 10

Цикл for

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 станет гораздо понятнее!