Коротка відповідь. 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 = об'єкти, які не звільняються довше, ніж ти очікуєш.
Чотири питання-пастки з відповідями
- «Що виведе цикл із var і setTimeout?» –
3, 3, 3; одна змінна на всі колбеки, виконання після циклу. - «Чим ReferenceError відрізняється від TypeError у прикладах вище?» – ReferenceError: звернення до неіснуючої/TDZ-змінної; TypeError: змінна існує, але з нею роблять неможливе (викликають undefined).
- «Чи піднімаються let і const?» – так, але з TDZ: доступ до ініціалізації заборонений.
- «Як зробити лічильник, який не можна зламати ззовні?» – функція-фабрика, що повертає методи над локальною змінною (див. приклад із
once); змінна недоступна інакше як через ці методи.
Що робити далі
Перевір себе чесно: закрий статтю і напиши з нуля memoize та цикл, що виводить 0, 1, 2 трьома різними способами. Якщо вийшло – тема твоя; якщо ні, повернись до відповідного розділу. Ці ж механіки лежать в основі половини задач із добірки live coding, а місце теми в загальній картині співбесіди – у великому розборі питань з JavaScript.