Перейти к содержанию
Educora
Продвинутый22 мин15 / 27

Область видимости и замыкания

Разберись, как JavaScript находит переменные: лексическая область видимости, замыкания, IIFE и паттерн «модуль», а также `this`, `bind`, `call` и `apply`.

Проверь себя
В этом уроке ты узнаешь
  • Объяснять лексическую область видимости, цепочку областей, блочную область и поднятие (hoisting)
  • Использовать замыкания, IIFE и паттерн «модуль» для хранения скрытого состояния
  • Предсказывать значение this при вызове и исправлять его через bind, call, apply или стрелочную функцию

На сайте есть кнопка «Нравится», которая должна помнить, сколько раз её нажали. Если держать счётчик в глобальной переменной, любой другой код может случайно его испортить. Как же функции «помнить» свою переменную после того, как она завершилась? Ответ — замыкание (closure), одна из самых мощных идей JavaScript и одна из самых частых тем на собеседованиях.

Лексическая область видимости и цепочка областей

Область видимости — часть кода, где переменная видна. В JavaScript область видимости лексическая: какие переменные видит функция, зависит от того, где она написана, а не от того, где её вызывают. Каждая функция и каждый блок { } с let/const создают новую область. Ища переменную, JavaScript смотрит в текущую область, затем во внешнюю и так до глобальной — это цепочка областей видимости. Поиск идёт только изнутри наружу, никогда наоборот.

JavaScript
const city = 'Baku';

function outer() {
  const district = 'Nasimi';

  function inner() {
    const street = 'Nizami';
    console.log(street, district, city);
  }

  inner();
  console.log(typeof street);
}

outer();

if (true) {
  let blockVar = 'inside';
  var functionVar = 'var ignores blocks';
}
console.log(typeof blockVar, functionVar);
▸ Ожидаемый результат
Nizami Nasimi Baku
undefined
undefined var ignores blocks
inner видит все три области, а outer не видит street. var «сбегает» из блока, а let остаётся внутри него.

Перед выполнением кода движок регистрирует все объявления — это называется поднятием (hoisting). Переменная, объявленная через var, «поднимается» в начало своей области со значением undefined. Объявление function поднимается целиком, поэтому функцию можно вызвать раньше строки, где она написана. let и const тоже поднимаются, но до своей строки находятся во временной мёртвой зоне (TDZ): обращение к ним раньше времени выбрасывает ReferenceError.

JavaScript
console.log(early);
var early = 'var is hoisted';

try {
  console.log(later);
} catch (error) {
  console.log(error.name);
}
let later = 'let waits in the TDZ';

console.log(sayHi());

function sayHi() {
  return 'Function declarations are hoisted';
}
▸ Ожидаемый результат
undefined
ReferenceError
Function declarations are hoisted

Замыкания

Определение
Замыкание (closure)

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

Обычно локальные переменные функции исчезают, когда она заканчивается. Но если внутренняя функция, которая ими пользуется, была возвращена или где-то сохранена, движок сохраняет их жизнь. Каждый вызов внешней функции создаёт новый, отдельный набор переменных — ниже likes и views никак не мешают друг другу.

JavaScript
function createCounter(start = 0) {
  let count = start;
  return {
    increment: () => ++count,
    decrement: () => --count,
    value: () => count,
  };
}

const likes = createCounter();
const views = createCounter(100);

likes.increment();
likes.increment();
views.increment();

console.log(likes.value(), views.value());
console.log(likes.count);
▸ Ожидаемый результат
2 101
undefined
count — не свойство объекта: оно живёт только в замыкании, поэтому снаружи его не изменить.

Ещё одно частое применение замыканий — фабрики функций: ты создаёшь новые функции, которые «помнят» параметр.

JavaScript
function makeMultiplier(factor) {
  return (x) => x * factor;
}

const double = makeMultiplier(2);
const triple = makeMultiplier(3);

console.log(double(5), triple(5));
console.log([1, 2, 3].map(triple));
▸ Ожидаемый результат
10 15
[ 3, 6, 9 ]
JavaScript
for (var i = 1; i <= 3; i++) {
  setTimeout(() => console.log('var', i), 0);
}

for (let j = 1; j <= 3; j++) {
  setTimeout(() => console.log('let', j), 0);
}
▸ Ожидаемый результат
var 4
var 4
var 4
let 1
let 2
let 3

IIFE и паттерн «модуль»

До появления ES-модулей программисты прятали переменные с помощью IIFE — немедленно вызываемого функционального выражения (Immediately Invoked Function Expression): функции, взятой в скобки и тут же вызванной. Её переменные не «протекают» в глобальную область. Если соединить IIFE с замыканием, получится паттерн «модуль»: скрытые данные и открытый объект (API) только с нужными методами. Сегодня на уровне файлов эту задачу решают ES-модули, но паттерн часто встречается в старом коде и полезен внутри одного файла.

JavaScript
const bank = (function () {
  let balance = 0;
  const history = [];

  return {
    deposit(amount) {
      balance += amount;
      history.push(`+${amount}`);
    },
    withdraw(amount) {
      if (amount > balance) return 'Not enough money';
      balance -= amount;
      history.push(`-${amount}`);
      return 'OK';
    },
    report: () => ({ balance, history: [...history] }),
  };
})();

bank.deposit(100);
console.log(bank.withdraw(30), bank.withdraw(500));
console.log(bank.report());
console.log(typeof balance);
▸ Ожидаемый результат
OK Not enough money
{ balance: 70, history: [ '+100', '-30' ] }
undefined
balance и history меняются только через deposit, withdraw и report. report возвращает копию массива, чтобы историю нельзя было испортить снаружи.

this, bind, call и apply

В отличие от обычных переменных, this не лексический: в обычной функции его значение зависит от того, как функцию вызвали, и определяется в момент вызова. У стрелочных функций своего this нет — они берут его из окружающего кода.

ВызовЧему равен this
obj.method()объект слева от точки — obj
fn()undefined в строгом режиме (классы, модули), глобальный объект в старом нестрогом коде
new Fn()только что созданный объект
fn.call(obj, a, b), fn.apply(obj, [a, b])obj — функция вызывается сразу
fn.bind(obj)новая функция, у которой this навсегда obj
стрелочная функцияthis окружающего кода
JavaScript
function introduce(greeting, punctuation) {
  return `${greeting}, I am ${this.name}${punctuation}`;
}

const aysel = { name: 'Aysel' };
const murad = { name: 'Murad' };

console.log(introduce.call(aysel, 'Hi', '!'));
console.log(introduce.apply(murad, ['Hello', '.']));

const ayselIntro = introduce.bind(aysel, 'Hey');
console.log(ayselIntro('!!'));

console.log(Math.max.apply(null, [3, 9, 4]));
▸ Ожидаемый результат
Hi, I am Aysel!
Hello, I am Murad.
Hey, I am Aysel!!
9
call принимает аргументы по одному, apply — массивом. bind не вызывает функцию, а возвращает новую, в которой закреплены this (и при желании первые аргументы).

Когда метод передают как колбэк, он «отрывается» от своего объекта. Методы классов всегда работают в строгом режиме, поэтому this становится undefined, и чтение свойства выбрасывает TypeError. Решения: bind, обёртка-стрелка или стрелочная функция, записанная как поле класса.

JavaScript
class Player {
  score = 0;

  addPoint() {
    this.score++;
  }

  addPointArrow = () => {
    this.score++;
  };
}

const player = new Player();
const detached = player.addPoint;

try {
  detached();
} catch (error) {
  console.log('Lost this:', error.name);
}

[1, 2].forEach(player.addPoint.bind(player));
[1, 2, 3].forEach(player.addPointArrow);
console.log(player.score);
▸ Ожидаемый результат
Lost this: TypeError
5
Задание

Напиши createIdGenerator(prefix): она возвращает функцию, которая при каждом вызове выдаёт следующий идентификатор: user-1, user-2… Счётчик храни в замыкании, и у каждого генератора должен быть свой счётчик.

Задание · JavaScript
function createIdGenerator(prefix) {
  // keep a counter here
  return () => {
    // increase the counter and return `${prefix}-${count}`
  };
}

const userId = createIdGenerator('user');
const orderId = createIdGenerator('order');

console.log(userId());
console.log(userId());
console.log(orderId());
console.log(userId());
▸ Ожидаемый результат
user-1
user-2
order-1
user-3
Задание

Функция finalPrice берёт скидку из this.discount. 1) С помощью call выведи цену товара за 200 манатов для объекта shop. 2) Через bind создай функцию, привязанную к shop, и передай её в map, чтобы пересчитать цены [50, 80].

Задание · JavaScript
const shop = { discount: 10 };

function finalPrice(price) {
  return price - (price * this.discount) / 100;
}

// 1) finalPrice for 200 with call
// 2) bind to shop and use it in map for [50, 80]
▸ Ожидаемый результат
180
[ 45, 72 ]

Главное

  • Лексическая область: функция видит переменные того места, где она написана; поиск идёт наружу по цепочке областей.
  • let/const имеют блочную область и до своей строки находятся в TDZ; var имеет область функции и поднимается как undefined.
  • Замыкание — это функция плюс переменные, которые она помнит; каждый вызов внешней функции создаёт новый набор.
  • IIFE и паттерн «модуль» прячут данные и возвращают только открытый API.
  • this зависит от способа вызова: call/apply задают его один раз, bind закрепляет навсегда, а стрелочная функция берёт его снаружи.

Проверь себя

Вопросов: 10. Каждый правильный ответ приносит XP.

1 / 10
Дано function outer() { let n = 0; return () => ++n; }. Что вернёт b() после const a = outer(); const b = outer(); a(); a();?