Замикання в JavaScript
Замикання (closure) - це функція, яка зберігає доступ до змінних зі свого місця створення, навіть після того як та область видимості завершила виконання.
Теорія
TL;DR
- Аналогія: Замикання - як рюкзак. Функція пакує потрібні змінні при створенні і несе їх із собою.
- Механізм: JavaScript прикріплює до кожної функції внутрішнє посилання
[[Scope]]на лексичне середовище де вона була оголошена. Ці змінні живуть доки функція існує. - Кожна функція технічно є замиканням. Важливо інше: чи ти свідомо використовуєш це для прихованого стану або контексту.
- Правило вибору: Прихований стан, фабрична функція, callback з контекстом - замикання. Три рівні вкладеності заради однієї змінної - рефактор в клас.
Швидкий приклад
function createCounter() {
let count = 0; // Ця змінна буде "захоплена"
return function increment() {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
// count тут недоступна - вона прихованаcreateCounter виконалась і повернула increment. Зовнішня функція завершила роботу. Але count живе далі, бо increment тримає на неї посилання. Кожен виклик counter() читає і змінює ту саму змінну.
Головна відмінність
Технічно кожна функція в JavaScript є замиканням над своєю лексичною областю видимості. Важливо інше: чи ти навмисно використовуєш захоплений scope. Коли пишеш setTimeout(() => doSomething(userId), 100) всередині React компонента, цей callback замикається над userId. Більшість розробників пишуть замикання постійно, просто не думають про них в таких термінах.
Коли використовувати
- Прихований стан: змінні, які не мають бути видимі ззовні - лічильник, конфіг, внутрішній кеш
- Фабричні функції:
createMultiplier(2)записує2в нову функцію раз і назавжди - Callbacks та обробники подій:
fetch,addEventListener, ланцюжки Promise - всі вони захоплюють змінні з оточуючого контексту - Мемоізація і debounce: таймер або кеш живуть між викликами всередині замикання, не забруднюючи зовнішній scope
Уникай замикань коли простий клас або літерал об'єкта буде читабельніший. Три рівні вкладеності щоб передати одне значення - сигнал до рефакторингу.
Як JavaScript тримає змінні живими
Коли функція створюється, V8 (Chrome і Node.js) записує в неї внутрішню властивість [[Scope]]. Вона вказує на лексичне середовище де функцію оголосили, зі всіма змінними батьківських областей видимості.
Збирач сміття тримає об'єкт живим доки є хоч одне посилання на нього. Замикання і є таким посиланням. Щойно ти втрачаєш останнє посилання на саме замикання, функція і захоплені змінні стають кандидатами на видалення.
Типові помилки
Помилка 1: var у циклі
// Неправильно - всі callbacks бачать одну й ту саму i
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // Виводить 3, 3, 3
}, 1000);
}
// Правильно - let створює окремий binding на кожній ітерації
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // Виводить 0, 1, 2
}, 1000);
}
// Також правильно - IIFE захоплює значення до того як цикл його змінить (до ES6)
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(function() {
console.log(j); // Виводить 0, 1, 2
}, 1000);
})(i);
}З var всі три callbacks замикаються на одній i. Цикл завершується до того як будь-який callback спрацює, тому всі виводять 3. З let кожна ітерація отримує свій binding - три окремі змінні, три окремі замикання.
Помилка 2: Захоплення великих об'єктів
// Неправильно - largeData залишається в пам'яті доки слухач існує
function setupListener() {
const largeData = new Array(1000000).fill('data');
document.getElementById('btn').addEventListener('click', function() {
console.log(largeData.length); // Захоплює весь масив
});
}
// Правильно - захоплюй тільки те що потрібно
function setupListener() {
const largeData = new Array(1000000).fill('data');
const length = largeData.length;
document.getElementById('btn').addEventListener('click', function() {
console.log(length); // Захоплює число, не масив
});
// largeData тепер може бути зібрана збирачем сміття
}Помилка 3: this не захоплюється замиканням
// Неправильно - this всередині callback це не obj
const obj = {
count: 0,
increment: function() {
setTimeout(function() {
this.count++; // this - це window або undefined у strict mode
}, 1000);
}
};
// Правильно - стрілкова функція успадковує this з оточуючого scope
const obj = {
count: 0,
increment: function() {
setTimeout(() => {
this.count++; // this - це obj
console.log(this.count); // 1
}, 1000);
}
};Замикання захоплює змінні. this - це не звична змінна, його значення визначається способом виклику функції. Стрілкові функції не мають власного this і беруть його з оточуючого scope, тому і працюють тут.
Помилка 4: Застаріле замикання (stale closure) в React
// Неправильно - count завжди 0 всередині інтервалу
function Counter() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // count захоплено при mount, завжди 0
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
// Правильно - використовуй функцію-апдейтер
function Counter() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // prev завжди актуальний
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}Bug зі stale closure в React hooks - одна з найчастіших тем на middle і senior співбесідах. Callback інтервалу захоплює count на момент першого рендеру і більше не оновлює його. Функція-апдейтер вирішує це: React сам передає актуальний стан як аргумент.
Де зустрічається в продакшені
- React hooks:
useState,useCallback,useRefповністю побудовані на замиканнях для збереження стану між рендерами - Redux middleware: огортає
dispatchв замикання щоб перехоплювати actions до редьюсера - Express middleware: обробники запитів замикаються на екземплярі
appі підключеннях до бази - Debounce і throttle: ID таймера живе між викликами всередині замикання
- Модульний патерн (до ES6): IIFE-замикання створювали приватні змінні до появи
import/export - Dependency injection:
createUserService(db)повертає функції що замикаються на з'єднанні з базою
Питання на співбесіді
Q: Чому захоплена змінна не видаляється збирачем сміття коли зовнішня функція завершила роботу?
A: Змінна досі має активне посилання - від внутрішньої функції. Garbage collector видаляє тільки об'єкти без посилань. Щойно ти втратиш посилання на саме замикання, змінна теж стане кандидатом на видалення.
Q: Замикання може змінювати захоплену змінну чи тільки читати її?
A: Може змінювати. Замикання тримає посилання на сам binding, не на копію значення. Два замикання на одній змінній бачать зміни одне одного.
Q: Яка різниця між замиканням і передачею аргументів?
A: Замикання захоплює змінні при створенні і зберігає їх між викликами. Аргументи передаються при кожному виклику і не зберігаються. Замикання підходить для прихованого стану. Аргументи - для залежностей що мають бути явно видимі на місці виклику.
Q: Чи можуть замикання спричиняти витоки пам'яті? Як це попередити?
A: Так. Якщо замикання захоплює великий об'єкт і функція ніколи не збирається сміттям (наприклад, слухач подій якого ніколи не видаляють), об'єкт залишається в пам'яті. Вирішення: видаляти слухачі після використання, захоплювати лише примітиви або маленькі значення, використовувати WeakMap для кешування через замикання.
Q: (Рівень senior) Чому setTimeout у циклі з var поводиться неочікувано?
A: Замикання захоплює змінну за посиланням, не за значенням. Коли таймаут спрацьовує, цикл вже завершений і i містить кінцеве значення. З let кожна ітерація створює окремий binding у новому блочному scope, тому кожне замикання захоплює різну змінну.
Приклади
Базовий: фабрична функція
function createMultiplier(multiplier) {
// multiplier захоплюється в замиканні
return function(number) {
return number * multiplier;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
console.log(double(10)); // 20Кожен виклик createMultiplier створює окреме замикання зі своїм multiplier. double і triple незалежні - виклик одного не впливає на інше.
Середній: мемоізація
function memoize(fn) {
const cache = {}; // Приватний кеш, спільний для всіх викликів
return function(...args) {
const key = JSON.stringify(args);
if (key in cache) {
return cache[key]; // Кеш-хіт - обчислень немає
}
const result = fn(...args);
cache[key] = result;
return result;
};
}
const heavyCalc = memoize((n) => {
let sum = 0;
for (let i = 0; i < n; i++) sum += i;
return sum;
});
console.log(heavyCalc(1000000)); // обчислюється один раз
console.log(heavyCalc(1000000)); // повертається з кешуcache - приватна змінна що живе між викликами повернутої функції. Ніяких глобальних змінних, ніяких класів. Замикання тут виконує реальну роботу: зберігає стан між викликами.
Просунутий: пастка циклу і три способи її вирішити
// Проблема: var створює один спільний binding для всіх обробників
const buttons = document.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
console.log(i); // Завжди виводить buttons.length, а не 0, 1, 2
});
}
// Спосіб 1: let - найчитабельніший, сучасний варіант
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
console.log(i); // Кожен callback має свою i
});
}
// Спосіб 2: IIFE - захоплює значення до того як цикл його змінить (до ES6)
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', (function(index) {
return function() {
console.log(index); // index - локальна копія i на цій ітерації
};
})(i));
}IIFE працює тому що виклик функції створює новий scope. На кожній ітерації i передається як аргумент і прив'язується до index локально. Внутрішня функція замикається на index, а не на i. З let рушій робить це автоматично: новий binding на кожній ітерації, той самий результат без зайвого синтаксису.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.