Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Замикання в JavaScript». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Замикання (closure)** - це функція, яка зберігає доступ до змінних зі свого місця створення, навіть після того як та область видимості завершила роботу. ```javascript function createCounter() { let count = 0; return () => ++count; } const counter = createCounter(); counter(); // 1 counter(); // 2 ``` **Ключове:** `count` живе далі тому що повернута функція тримає на неї посилання.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Замикання (closure)** - це функція, яка зберігає доступ до змінних зі свого місця створення, навіть після того як та область видимості завершила виконання. ## Теорія ### TL;DR - **Аналогія:** Замикання - як рюкзак. Функція пакує потрібні змінні при створенні і несе їх із собою. - **Механізм:** JavaScript прикріплює до кожної функції внутрішнє посилання `[[Scope]]` на лексичне середовище де вона була оголошена. Ці змінні живуть доки функція існує. - **Кожна функція технічно є замиканням.** Важливо інше: чи ти свідомо використовуєш це для прихованого стану або контексту. - **Правило вибору:** Прихований стан, фабрична функція, callback з контекстом - замикання. Три рівні вкладеності заради однієї змінної - рефактор в клас. ### Швидкий приклад ```javascript 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 є замиканням над своєю [лексичною областю видимості](/questions/scope-in-javascript). Важливо інше: чи ти навмисно використовуєш захоплений scope. Коли пишеш `setTimeout(() => doSomething(userId), 100)` всередині React компонента, цей callback замикається над `userId`. Більшість розробників пишуть замикання постійно, просто не думають про них в таких термінах. ### Коли використовувати - **Прихований стан:** змінні, які не мають бути видимі ззовні - лічильник, конфіг, внутрішній кеш - **Фабричні функції:** `createMultiplier(2)` записує `2` в нову функцію раз і назавжди - **Callbacks та обробники подій:** `fetch`, `addEventListener`, ланцюжки Promise - всі вони захоплюють змінні з оточуючого контексту - **Мемоізація і debounce:** таймер або кеш живуть між викликами всередині замикання, не забруднюючи зовнішній scope Уникай замикань коли простий клас або літерал об'єкта буде читабельніший. Три рівні вкладеності щоб передати одне значення - сигнал до рефакторингу. ### Як JavaScript тримає змінні живими Коли функція створюється, V8 (Chrome і Node.js) записує в неї внутрішню властивість `[[Scope]]`. Вона вказує на лексичне середовище де функцію оголосили, зі всіма змінними батьківських областей видимості. [Збирач сміття](/questions/garbage-collection-javascript) тримає об'єкт живим доки є хоч одне посилання на нього. Замикання і є таким посиланням. Щойно ти втрачаєш останнє посилання на саме замикання, функція і захоплені змінні стають кандидатами на видалення. ### Типові помилки **Помилка 1: `var` у циклі** ```javascript // Неправильно - всі 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: Захоплення великих об'єктів** ```javascript // Неправильно - 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` не захоплюється замиканням** ```javascript // Неправильно - 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` - це не звична змінна, його значення визначається способом виклику функції. [Стрілкові функції](/questions/arrow-functions-javascript) не мають власного `this` і беруть його з оточуючого scope, тому і працюють тут. **Помилка 4: Застаріле замикання (stale closure) в React** ```javascript // Неправильно - 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, тому кожне замикання захоплює різну змінну. ## Приклади ### Базовий: фабрична функція ```javascript 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` незалежні - виклик одного не впливає на інше. ### Середній: мемоізація ```javascript 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` - приватна змінна що живе між викликами повернутої функції. Ніяких глобальних змінних, ніяких класів. Замикання тут виконує реальну роботу: зберігає стан між викликами. ### Просунутий: пастка циклу і три способи її вирішити ```javascript // Проблема: 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 на кожній ітерації, той самий результат без зайвого синтаксису.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.