Skip to main content

Замикання в JavaScript

Замикання (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 є замиканням над своєю лексичною областю видимості. Важливо інше: чи ти навмисно використовуєш захоплений 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 у циклі

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 - це не звична змінна, його значення визначається способом виклику функції. Стрілкові функції не мають власного 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 на кожній ітерації, той самий результат без зайвого синтаксису.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?