Коротка відповідь. Scope визначає, звідки видно змінну; замикання – це функція, яка запам'ятала свій лексичний scope і носить його з собою; hoisting – механізм, через який оголошення «спливають» на початок області видимості ще до виконання. Три теми звучать академічно, але на співбесіді перевіряються трьома-чотирма конкретними пастками. Розберімо саме їх.

Scope за дві хвилини

У сучасному JavaScript три види області видимості: глобальна, функціональна та блокова. let і const мають блоковий scope, var – лише функціональний:

function demo() {
  if (true) {
    var a = 1;
    let b = 2;
  }
  console.log(a); // 1 – var «не бачить» блоку
  console.log(b); // ReferenceError – let живе лише в блоці
}

Scope визначається місцем написання коду (лексично), а не місцем виклику. Це речення варто сказати на співбесіді дослівно – воно відрізняє розуміння від переказу.

Пастка 1: замикання в циклі

Найчастіше питання по темі за всі часи:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 3, 3, 3

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 0);
}
// 0, 1, 2

Чому так: із var існує одна змінна i на всі ітерації; колбеки виконаються після завершення циклу (див. розбір event loop) і всі побачать фінальне значення 3. Із let кожна ітерація створює нову прив'язку змінної, і кожне замикання захоплює свою.

Сильна відповідь не закінчується на «треба let»: назви й старі обхідні шляхи (IIFE, третій аргумент setTimeout) – це показує, що ти розумієш механізм, а не запам'ятав виправлення.

Пастка 2: hoisting функцій проти змінних

sayHi();        // 'Привіт' – function declaration піднімається цілком
console.log(x); // undefined – var піднімається БЕЗ значення
// sayBye();    // TypeError: sayBye is not a function

function sayHi() {
  console.log('Привіт');
}
var x = 10;
var sayBye = function () {
  console.log('Бувай');
};

Правило: function declaration піднімається разом із тілом; var – лише оголошення (значення буде undefined до рядка присвоєння); function expression поводиться як звичайна змінна. Тому виклик sayBye() до присвоєння – це виклик undefined, звідси TypeError, а не ReferenceError. Різниця між цими двома помилками – окреме улюблене уточнення інтерв'юерів.

Пастка 3: temporal dead zone

let/const теж «hoisted», але звертатися до них до рядка оголошення не можна – це і є TDZ:

{
  // TDZ для value починається тут
  // console.log(value); // ReferenceError: Cannot access before initialization
  let value = 42;       // TDZ закінчується
  console.log(value);   // 42
}

Формулювання для співбесіди: «змінна існує з початку блоку, але недоступна до ініціалізації». Це пояснює, чому помилка каже саме cannot access before initialization, а не is not defined. Довідка з деталями – MDN, розділ JavaScript.

Замикання на практиці: once і memoize

Щоб тема перестала бути абстракцією, напиши дві утиліти, які реально живуть у продакшн-коді:

function once<T extends (...args: unknown[]) => unknown>(fn: T): T {
  let called = false;
  let result: unknown;
  return function (this: unknown, ...args: unknown[]) {
    if (!called) {
      called = true;
      result = fn.apply(this, args);
    }
    return result;
  } as T;
}

function memoize(fn: (n: number) => number): (n: number) => number {
  const cache = new Map<number, number>();
  return (n) => {
    if (!cache.has(n)) cache.set(n, fn(n));
    return cache.get(n)!;
  };
}

Обидві працюють тільки завдяки замиканню: called, result і cache живуть між викликами, але недоступні ззовні. Питання «де замикання корисні в реальному коді» – саме про це: приватний стан, кешування, фабрики обробників подій. Побічний ефект, про який варто згадати: замикання тримає посилання на своє оточення, тому великі об'єкти в захопленому scope не звільняться збирачем сміття, поки живе функція.

Замикання і пам'ять: про що питають на Middle+

Замикання тримає посилання на все своє лексичне оточення. Звідси реальний клас проблем:

function attachHandler(element) {
  const bigData = new Array(1_000_000).fill('…'); // великий об'єкт
  element.addEventListener('click', () => {
    console.log('клік'); // bigData тут не потрібен…
  });
  // …але поки живе обробник, оточення (разом із bigData)
  // не може бути звільнене збирачем сміття
}

Виправлення очевидні, щойно бачиш причину: не тримати великі структури в scope обробника, або занулити посилання після використання, або знімати обробник (removeEventListener), коли елемент більше не потрібен. На співбесіді це питання звучить як «чи можуть замикання спричиняти витоки пам'яті» – правильна відповідь: самі по собі ні, але довгоживуча функція + великий захоплений scope = об'єкти, які не звільняються довше, ніж ти очікуєш.

Чотири питання-пастки з відповідями

  1. «Що виведе цикл із var і setTimeout?» – 3, 3, 3; одна змінна на всі колбеки, виконання після циклу.
  2. «Чим ReferenceError відрізняється від TypeError у прикладах вище?» – ReferenceError: звернення до неіснуючої/TDZ-змінної; TypeError: змінна існує, але з нею роблять неможливе (викликають undefined).
  3. «Чи піднімаються let і const?» – так, але з TDZ: доступ до ініціалізації заборонений.
  4. «Як зробити лічильник, який не можна зламати ззовні?» – функція-фабрика, що повертає методи над локальною змінною (див. приклад із once); змінна недоступна інакше як через ці методи.

Що робити далі

Перевір себе чесно: закрий статтю і напиши з нуля memoize та цикл, що виводить 0, 1, 2 трьома різними способами. Якщо вийшло – тема твоя; якщо ні, повернись до відповідного розділу. Ці ж механіки лежать в основі половини задач із добірки live coding, а місце теми в загальній картині співбесіди – у великому розборі питань з JavaScript.