08 · JavaScript/TypeScript для AQA
Функції, замикання та область видимості
Зміст
Функція — це не просто «шматок коду, який щось робить». У JavaScript функція носить із собою пам'ять про те місце, де її створили: які змінні були поруч, який контекст її оточував. Саме ця властивість — замикання (closure) — стоїть за половиною магії та половиною багів у тестовому коді: за фабриками унікальних даних, за фікстурами, за колбеками в map і filter, за хелперами-ретраями. І за класичною пасткою, коли цикл із setTimeout замість 0, 1, 2 друкує 3, 3, 3.
Для AQA це тема з високою вагою на співбесіді не випадково. Автоматизація — це передусім функції, які приймають інші функції та повертають функції. Не розуміючи область видимості (scope), ти не зрозумієш, чому хелпер «бачить» стару змінну, чому обробник у циклі спрацьовує не так, і чому this усередині стрілочної функції не той, на який ти розраховував. Розберемо механізм, а вже потім — де він болить.
Оголошення vs вираз і hoisting
Функцію можна створити двома основними способами, і різниця між ними — не косметична.
// Function declaration (оголошення)
function buildUrl(path: string): string {
return `https://staging.example.com${path}`;
}
// Function expression (функціональний вираз)
const openLogin = function (): void {
// ...
};
Оголошення (function declaration) — це самостійна конструкція, що починається зі слова function. Вираз (function expression) — це функція у правій частині присвоєння, яку кладуть у змінну. Візуально схоже, але поводяться вони по-різному через hoisting (спливання, підняття).
Hoisting — це поведінка рушія, за якої оголошення «піднімаються» вгору своєї області видимості ще до виконання коду. Механізм такий: перш ніж запустити код, рушій сканує область і реєструє всі оголошення. Function declaration піднімається повністю разом із тілом — тому її можна викликати вище за місцем оголошення. А var-змінна піднімається лише як ім'я зі значенням undefined; саме присвоєння лишається на своєму рядку.
greet(); // 'привіт' — declaration піднялася повністю
function greet(): string {
return 'привіт';
}
sayHi(); // TypeError: sayHi is not a function
var sayHi = function (): string {
return 'хай';
};
Другий виклик падає не тому, що sayHi не існує, а тому, що на момент виклику він існує зі значенням undefined — а undefined не можна викликати. Якби замість var стояв let чи const, повідомлення було б іншим: ReferenceError: Cannot access 'sayHi' before initialization. Причина — тимчасова мертва зона (temporal dead zone, TDZ): let і const теж технічно піднімаються, але доступ до них до рядка ініціалізації заборонено. Деталі про var/let/const і блокову область — у розділі Змінні, типи даних і приведення типів.
Практичний висновок для тестового коду: різниця is not a function (var/expression) vs Cannot access before initialization (let/const) одразу підказує, у якому саме порядку виконався код. Це часта причина падінь у хелперах, де функцію використали раніше, ніж оголосили.
Стрілочні функції
Стрілочна функція (arrow function) — компактніший синтаксис виразу функції, що з'явився в ES2015. Крім лаконічності, вона має одну принципову відмінність, важливішу за вигляд.
const double = (n: number): number => n * 2; // неявний return
const toUser = (row: string[]) => ({ id: row[0], name: row[1] }); // повертаємо об'єкт
const log = (): void => { console.log('done'); }; // тіло у фігурних дужках — return явний
Коли тіло — один вираз без фігурних дужок, його результат повертається неявно. Щоб повернути об'єктний літерал одним виразом, його загортають у круглі дужки, інакше фігурні дужки сприймуться як тіло функції.
Головна відмінність стрілки: вона не має власного this (а також власних arguments). Замість цього this береться лексично — з тієї області, де стрілку оголосили. Це рятує від класичної втрати контексту в колбеках і водночас робить стрілку непридатною для методів об'єкта, яким потрібен свій this. Повний розбір this, bind/call/apply і коли стрілка шкодить — у главі this, класи та ООП у тестовому коді.
У тестовому коді стрілки — стандарт для коротких колбеків: предикатів у методах масивів, обробників у Playwright, кроків у CodeceptJS.
const activeUsers = users.filter((u) => u.status === 'active').map((u) => u.email);
await page.locator('.row').evaluateAll((rows) => rows.length);
Параметри: default, rest, spread
Три сучасні можливості параметрів прибирають цілий клас ручної роботи з аргументами.
Параметри за замовчуванням (default parameters) підставляють значення, коли аргумент не передали або передали undefined:
function createUser(name: string, role: string = 'viewer') {
return { name, role };
}
createUser('Ada'); // role = 'viewer'
createUser('Ada', 'admin'); // role = 'admin'
Rest-параметр (...) збирає решту переданих аргументів у справжній масив. Він завжди останній у списку:
function tags(base: string, ...extra: string[]): string[] {
return [base, ...extra];
}
tags('smoke', 'regression', 'ui'); // ['smoke', 'regression', 'ui']
Spread (...) робить зворотне — «розкладає» масив чи об'єкт на окремі елементи. Той самий синтаксис ..., але за контекстом: у списку параметрів це збирання (rest), у виклику чи літералі — розкладання (spread).
const base = { env: 'staging', timeout: 30000 };
const config = { ...base, timeout: 60000 }; // копія base з перевизначеним timeout
Math.max(...[3, 7, 2]); // передаємо елементи масиву як аргументи
Spread для копіювання об'єктів і масивів, поверхневість такої копії (shallow) і пастка {} !== {} детально розібрані в главі Об'єкти, масиви та методи роботи з даними. Тут важливо запам'ятати одне: ... у параметрах — збирання, ... у виклику — розкладання.
Область видимості та ланцюжок
Область видимості (scope) визначає, звідки видно змінну. У JavaScript їх три рівні: глобальна, функціональна (аргументи й тіло функції) та блокова (усе у фігурних дужках для let/const). Коли код звертається до змінної, рушій шукає її знизу вгору: спершу в поточній області, потім у зовнішній, далі — аж до глобальної. Цей маршрут пошуку й називають ланцюжком областей видимості (scope chain).
Ключова властивість — лексична область видимості (lexical scoping): ланцюжок визначається тим, де функцію написали в коді, а не звідки її викликали. Це фундамент, на якому тримається наступна тема.
Замикання: механізм і кейси
Замикання (closure) — це функція разом із посиланнями на змінні з тієї області, де її створили. Простими словами: коли внутрішня функція використовує змінну зовнішньої, вона «замикає» цю змінну в собі — і продовжує мати до неї доступ навіть після того, як зовнішня функція завершилась.
function createEmailFactory(prefix: string) {
let counter = 0; // приватний стан
return () => `${prefix}-${counter++}@example.com`;
}
const nextEmail = createEmailFactory('qa');
nextEmail(); // 'qa-0@example.com'
nextEmail(); // 'qa-1@example.com'
createEmailFactory давно повернула значення й завершилась, але змінна counter не зникла: повернена стрілка тримає її живою й інкрементує при кожному виклику. Це не витік — це замикання. counter недоступний ззовні напряму; єдиний спосіб змінити його — викликати nextEmail. Так замикання дає інкапсуляцію — приватний стан без класів.
Для AQA такий генератор — робочий інструмент ізоляції тестових даних: кожен тест отримує гарантовано унікальний email, і паралельні прогони не б'ються за одні й ті самі дані. Де ще замикання зустрічаються в тестовому коді щодня:
- Фікстури й фабрики: функція готує стан (клієнта API, залогінений контекст) і повертає замкнені на нього хелпери.
- Конфігурація:
baseUrl, таймаути, токен «зашиваються» в замкнення хелпера, і його не треба щоразу передавати параметром. - Колбеки в методах масивів: предикат у
filterбачить змінні навколишнього тесту саме завдяки замиканню. - Лічильники й накопичувачі: підрахунок спроб, збір логів між викликами.
Функції вищого порядку
Функція вищого порядку (higher-order function) — це функція, яка приймає іншу функцію як аргумент, повертає функцію, або те й те одразу. Це не екзотика: map, filter, reduce, find, some, every, sort, setTimeout, page.route — усе це функції вищого порядку. Робота з масивами тестових даних через них — окрема тема глави Об'єкти, масиви та методи роботи з даними.
Функція, що повертає функцію, — це майже завжди замикання плюс вищий порядок разом. Класичний приклад із тестового коду — обгортка-ретрай:
function withRetry<T>(fn: () => Promise<T>, attempts = 3): () => Promise<T> {
return async () => {
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (e) {
lastError = e;
}
}
throw lastError;
};
}
const fetchStable = withRetry(() => api.get('/orders'), 5);
withRetry приймає функцію й повертає нову, яка замикає в собі й fn, і attempts. Це дає перевикористовуваний хелпер без дублювання циклу в кожному тесті. Такий самий принцип — за декораторами, за кастомними асертами, за обгортками кроків у CodeceptJS. Синтаксис узагальнення <T> — з TypeScript; детальніше про генерики див. главу TypeScript: генерики, утилітарні типи та звуження.
Пастка var у циклі з setTimeout
Це найпопулярніше питання-пастка на замикання, і воно не академічне — той самий баг виникає, коли в циклі навішують обробники чи планують відкладені дії.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Друкує: 3, 3, 3
Очікуєш 0, 1, 2, а отримуєш 3, 3, 3. Чому? var має одну змінну i на всю функцію, а не окрему на ітерацію. Усі три стрілки замикаються на ту саму i. Коли за 100 мс setTimeout нарешті виконає колбеки, цикл давно завершився, і i вже дорівнює 3 — усі три бачать це фінальне значення. Замикання зберігає посилання на змінну, а не її знімок у момент створення.
Найпростіше лікування — let замість var. let має блокову область, тож на кожній ітерації циклу створюється нове звʼязування i, і кожна стрілка замикає своє значення:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Друкує: 0, 1, 2
До появи let цю проблему розв'язували негайно викликаною функцією (IIFE), що створювала окрему область на кожну ітерацію. Сьогодні це історія, але на співбесіді можуть попросити показати обидва способи. Чому setTimeout узагалі виконується не миттєво — це подвійна тема про event loop, розібрана в главах Виконання JavaScript та event loop і Асинхронність: Promises, async/await і паралельність.
Типові помилки
- Виглядає як «функція не існує», а насправді var-hoisting.
TypeError: x is not a functionпри виклику функціонального виразу вище за оголошення означає, щоvar xуже піднявся якundefined. Function declaration так не падає — вона піднімається з тілом. - Виглядає як баг у циклі, а насправді одне звʼязування var.
3, 3, 3замість0, 1, 2— не глюк рушія, а спільна зміннаvarдля всіх замикань. Лік —let. - Виглядає як «зайва пам'ять/витік», а насправді робоче замикання. Змінна, що «не зникає» після завершення функції, — не помилка, якщо на неї свідомо тримає посилання повернена функція. Витік починається там, де таких посилань накопичуються тисячі й ніхто їх не звільняє.
- Виглядає як загублений контекст, а насправді не той тип функції. Стрілка не має власного
this; звичайна функція — має. Вибір типу функції для методу vs колбека — не стиль, а семантика. - Виглядає як копія об'єкта, а насправді те саме посилання.
const b = aдля об'єкта не копіює його;{ ...a }копіює лише верхній рівень. Це межа теми — деталі у главі про об'єкти й масиви.
Підсумок
- Function declaration піднімається повністю (можна викликати вище); функціональний вираз через
varпіднімається якundefined, а черезlet/const— потрапляє в TDZ. - Стрілочна функція не має власного
thisіarguments— бере їх лексично; це робить її ідеальною для колбеків і небезпечною для методів. ...у списку параметрів збирає (rest),...у виклику чи літералі розкладає (spread); default-параметр спрацьовує наundefined.- Замикання — це функція плюс живі посилання на змінні області, де її створили; воно дає приватний стан, фабрики й фікстури, і зберігає посилання на змінну, а не її знімок.
varу циклі — одна спільна змінна для всіх замикань (звідси3, 3, 3);letстворює нове звʼязування на кожну ітерацію.
Що питають на співбесіді
- «Що таке замикання? Наведи приклад.» Інтерв'юер хоче почути не визначення з підручника, а що функція тримає доступ до змінних зовнішньої області після її завершення — і робочий приклад (лічильник, фабрика унікальних даних). Плюс бали за згадку інкапсуляції приватного стану.
- «Що надрукує цей цикл із
varіsetTimeout? Як полагодити?» Класика на розуміння, що замикання ловить змінну, а не значення. Сильна відповідь:3, 3, 3, бо однаiна всі колбеки; лік —let(нове звʼязування на ітерацію) або IIFE. - «Різниця між function declaration і function expression?» Дивляться, чи знаєш про hoisting: declaration викликається вище за оголошення, expression — ні. Бонус — TDZ для
let/constі різні тексти помилок. - «Чому стрілочна функція зручна в колбеках?» Очікують згадку лексичного
this— що стрілка не втрачає контекст. Перевіряють, чи не плутаєш її зі звичайною функцією. - «Що таке функція вищого порядку?» Проста перевірка словника: приймає або повертає функцію; приклади —
map/filter, обгортка-ретрай. Часто це місток до задачі на живому кодуванні.
Джерела
- MDN — Closures
- MDN — Functions guide
- MDN — Arrow function expressions
- MDN — Rest parameters
- MDN — Hoisting (glossary)
Що таке функція вищого порядку (higher-order function)?
Це функція, яка або приймає іншу функцію аргументом, або повертає функцію, або робить і те, і те. Нічого екзотичного тут немає: map, filter, reduce, find, sort, setTimeout, page.route у Playwright — усе це представники цього класу. Практичний сенс для автоматизації простий: саме завдяки такому підходу масив тестових даних фільтрують, перетворюють і згортають одним рядком замість ручного циклу. На співбесіді це часто розминка перед задачею на живе кодування, де попросять або передати колбек, або самому написати функцію, що повертає функцію.
У чому різниця між оголошенням функції та функціональним виразом, і до чого тут hoisting?
Оголошення (function declaration) починається зі слова function і є самостійною конструкцією, а функціональний вираз (function expression) — це функція, яку кладуть у праву частину присвоєння. Різниця не косметична: рушій перед виконанням сканує область і реєструє оголошення, причому declaration піднімається цілком, разом із тілом, тому її дозволено викликати вище за рядок, де вона написана. Вираз через var так не працює: угору спливає лише ім'я змінної зі значенням undefined, а саме присвоєння функції лишається на місці. Тому виклик виразу до його ініціалізації падає, а виклик оголошення — ні. Це саме те, що перевіряє інтерв'юер словом hoisting: чи розумієш ти, що declaration і expression поводяться по-різному ще до першого рядка виконання.
Чим стрілочна функція відрізняється від звичайної й чому вона зручна в колбеках?
Головна відмінність не в короткому синтаксисі, а в тому, що стрілка не заводить власний this (як і власний arguments) — вона бере його лексично з тієї області, де її написали. Саме тому в колбеках стрілка не втрачає контекст: усередині обробника подій чи предиката масиву this лишається тим самим, що й зовні, і не треба ані bind, ані зайвої змінної self. Зворотний бік цієї ж властивості — стрілка не годиться на роль методу об'єкта, якому потрібен свій this, бо там вона підхопить зовнішній контекст замість самого об'єкта. У тестовому коді це стандарт для коротких предикатів у filter/map і для інлайн-обробників, але не для методів page-object'а, що спираються на this.
Навіщо потрібні параметри default, rest і spread, і як не сплутати rest зі spread?
Default-параметр підставляє значення, коли аргумент не передали або передали undefined: role = 'viewer' спрацює на порожньому виклику, але не на явному аргументі. Rest збирає решту переданих аргументів у справжній масив і завжди стоїть останнім у списку параметрів. Spread робить протилежне — розкладає масив чи об'єкт на окремі елементи у виклику або літералі. Пастка в тому, що синтаксис у rest і spread однаковий — три крапки, ..., — і розрізняє їх лише контекст: у списку параметрів це збирання (rest), у виклику чи літералі — розкладання (spread). Практичний нюанс: default реагує саме на undefined, а null вважається переданим значенням і default не активує.
Що таке область видимості (scope) і ланцюжок областей видимості (scope chain)?
Область видимості визначає, звідки видно ту чи іншу змінну; у JavaScript їх три рівні — глобальна, функціональна (параметри й тіло функції) і блокова (те, що у фігурних дужках, для let/const). Коли код звертається до імені, рушій шукає його не абияк, а знизу вгору: спершу в поточній області, потім у зовнішній, і так аж до глобальної. Цей маршрут пошуку й називають ланцюжком областей видимості. Якщо ім'я не знайшлося ніде до глобальної включно, отримуємо ReferenceError. Для тестового коду це пояснює, чому хелпер несподівано «бачить» зовнішню змінну: він просто піднявся ланцюжком і взяв її з обгортки.
Що означає «лексична» область видимості (lexical scoping)?
Лексична означає, що ланцюжок областей визначається місцем, де функцію написали в коді, а не тим, звідки її потім викликали. Тобто внутрішня функція завжди «бачить» змінні тієї обгортки, у тілі якої вона фізично розташована, незалежно від того, з якого місця програми її запустили. Це протилежність гіпотетичному динамічному підходу, де оточення бралося б із точки виклику. Розуміти це критично, бо саме на лексичній прив'язці тримаються і стрілочний this, і замикання: обидва беруть оточення з місця оголошення. Без цієї деталі неможливо пояснити, чому повернена функція пам'ятає змінні давно завершеної обгортки.
Що таке замикання (closure)? Наведи приклад.
Замикання — це функція разом із живими посиланнями на змінні тієї області, де її створили. Механізм такий: коли внутрішня функція користується змінною зовнішньої, вона «замикає» цю змінну в собі й зберігає до неї доступ навіть після того, як зовнішня функція завершила роботу. Класичний приклад — фабрика лічильника: зовнішня функція оголошує let counter = 0 і повертає стрілку, що інкрементує та віддає counter; після повернення обгортка завершилась, а змінна жива й росте з кожним викликом. Це не витік і не магія, а штатна властивість мови. Сильна відповідь одразу додає, що так дістають інкапсуляцію: приватний стан без класів, недоступний ззовні напряму.
Замикання зберігає знімок значення змінної чи посилання на неї?
Посилання на змінну, а не її копію в момент створення. Це принципово: якщо після створення замикання зовнішня змінна зміниться, функція побачить уже нове значення, а не те, яке було на момент її оголошення. Саме на цьому спотикається класична пастка циклу — усі колбеки, замкнені на одну спільну змінну, читають її фінальний стан, а не проміжні. Тому коли треба «заморозити» конкретне значення на ітерацію, створюють окреме зв'язування (через let або окрему функцію-обгортку), і тоді кожне замикання тримає власну змінну. Плутанина «значення проти посилання» тут — джерело половини багів на цю тему.
Як за допомогою замикання зробити приватний стан і інкапсуляцію?
Ідея в тому, щоб оголосити змінну всередині функції та назовні віддати лише ті хелпери, які з нею працюють, — сама змінна залишається недосяжною ззовні. Наприклад, фабрика повертає пару функцій increment і getValue, замкнених на локальний count; змінити лічильник можна тільки через них, прямого доступу до count немає. Це дає ту саму інкапсуляцію, що й приватні поля класу, але без класу — стан захований у замиканні. Для AQA це робочий патерн ізоляції: генератор унікальних email'ів тримає внутрішній лічильник, і жоден тест не може випадково його скинути чи прочитати повз API фабрики. Приватність тут забезпечена самою областю видимості, а не домовленістю «не чіпати».
Що надрукує цикл із var і setTimeout, і як це полагодити?
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
Надрукує 3, 3, 3, а не 0, 1, 2, і це не глюк рушія. Причина в тому, що var заводить одну-єдину змінну i на всю функцію, а не окрему на кожну ітерацію, тож усі три колбеки замикаються на ту саму i. Коли за 100 мс таймери нарешті спрацюють, цикл давно завершився й i вже дорівнює 3 — усі три й друкують трійку, бо замикання тримає посилання на змінну, а не її знімок. Найпростіший лік — замінити var на let: let має блокову область, тому на кожній ітерації створюється нове зв'язування i, і кожен колбек замикає власне значення, даючи очікувані 0, 1, 2. До появи let ту саму ізоляцію робили негайно викликаною функцією (IIFE), що заводила окрему область на ітерацію.
Що таке тимчасова мертва зона (temporal dead zone, TDZ)?
Це проміжок від початку блоку до рядка ініціалізації змінної let чи const, у якому змінна вже технічно існує, але звертатися до неї заборонено. Такі змінні теж піднімаються рушієм, проте, на відміну від var, не отримують значення undefined наперед — до їхньої ініціалізації будь-який доступ кидає ReferenceError: Cannot access '...' before initialization. Сенс TDZ у тому, щоб ловити помилки раннього використання замість того, щоб мовчки віддавати undefined, як це робить var. На практиці різні тексти помилок стають підказкою: var дасть undefined (а виклик — is not a function), тоді як let/const чесно скажуть, що змінна ще не ініціалізована.
Функцію викликали вище за рядок оголошення. Коли впаде is not a function, а коли Cannot access before initialization?
Це залежить від того, як оголошено функцію. Якщо це вираз через var, ім'я вже піднялося зі значенням undefined, тому виклик дасть TypeError: ... is not a function — бо undefined викликати не можна, хоча змінна й «існує». Якщо ж це вираз через let чи const, змінна перебуває в TDZ, і доступ до неї до ініціалізації кидає ReferenceError: Cannot access '...' before initialization. А ось справжнє оголошення (function declaration) взагалі не впаде — воно піднялося з тілом і викличеться нормально. Для дебагу тестів ця різниця в тексті помилки — миттєва підказка, у якому саме порядку виконався код і чим саме оголосили функцію.
Чому стрілочну функцію не варто використовувати як метод об'єкта?
Тому що метод зазвичай розраховує, що this вказує на сам об'єкт, а стрілка власного this не має й підхопить зовнішній лексичний контекст — найчастіше це буде не об'єкт, а модуль чи глобал. У результаті всередині такого «методу» звернення до полів через this зламається або мовчки поверне не те. Звичайна функція в ролі методу отримує this за правилом виклику — об'єкт перед крапкою, — і саме тому для методів беруть її, а не стрілку. Різниця тут не про стиль, а про семантику: тип функції визначає, звідки візьметься this. Практичне правило: стрілка — для колбеків, які мають зберегти зовнішній контекст; звичайна функція — для методів, яким потрібен власний.
Як написати обгортку-ретрай і чому це водночас замикання і функція вищого порядку?
Обгортка приймає функцію та кількість спроб, а повертає нову функцію, яка в циклі викликає передану, ковтаючи помилки до останньої спроби:
function withRetry<T>(fn: () => Promise<T>, attempts = 3): () => Promise<T> {
return async () => {
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (e) {
lastError = e;
}
}
throw lastError;
};
}
Це функція вищого порядку, бо і приймає функцію (fn), і повертає функцію. І це замикання, бо повернена функція «пам'ятає» fn та attempts після того, як withRetry завершилась. Разом вони дають перевикористовуваний хелпер проти flaky-запитів без копіювання циклу ретраю в кожен тест. Той самий принцип стоїть за декораторами, кастомними асертами й обгортками кроків.
Змінна не зникає після завершення функції. Це витік пам'яті чи ні?
Саме по собі — ні: якщо на змінну свідомо тримає посилання повернена функція, її «незникнення» є нормальною роботою замикання, а не помилкою. Рушій не звільняє те, що досі досяжне, і фабрика лічильників чи конфіг, зашитий у хелпер, тримають свій стан цілком легітимно. Витік починається в іншому місці — коли таких замкнених посилань накопичуються тисячі й ніхто їх не відпускає: наприклад, обробники, що замикають важкі об'єкти й не знімаються, поступово з'їдають пам'ять. Тобто діагноз залежить від наміру: живе посилання під контролем — інкапсуляція, забуте посилання без потреби — витік. Плутати «замикання тримає змінну» з «пам'ять тече» — типова помилка на співбесіді.
Навіщо до появи let використовували негайно викликану функцію (IIFE)?
IIFE (immediately invoked function expression) — це функція, яку оголошують і одразу викликають, щоб створити окрему область видимості на місці. У циклах її застосовували саме для лікування пастки спільної var: обгортаючи тіло ітерації в IIFE й передаючи туди поточне i аргументом, отримували на кожну ітерацію власну змінну, замкнену на конкретне значення, а не на спільне фінальне. Так 3, 3, 3 перетворювалося на очікуване 0, 1, 2 ще до того, як з'явився блоковий let. Сьогодні це переважно історія — let розв'язує ту саму проблему чистіше, — але на співбесіді можуть попросити показати обидва способи, щоб перевірити розуміння механізму областей, а не завчену відповідь «просто візьми let».
Три кейси, де область видимості й замикання вирішують, зелений тест чи полювання за примарою: фабрика унікальних даних на замиканні проти конфліктів у CI, класична пастка спільної var у циклі навішування з таблицею рішень, і обгортка вищого порядку проти flaky-запитів. Скрізь — що дивитися і чому.
Кейс 1. Фабрика унікальних тестових даних на замиканні
Симптом упізнаваний: тест реєстрації зелений поодинці, а в CI на повторному прогоні червоніє з конфліктом на зайнятий email. Причина — фіксовані дані: попередній прогін лишив по собі юзера, і другий проситься на той самий email. Замикання розв'язує це елегантно — фабрика тримає приватний лічильник, недоступний ззовні, і кожен виклик віддає гарантовано новий email.
function createEmailFactory(prefix: string) {
let counter = 0; // приватний стан, живе між викликами
return () => `${prefix}-${Date.now()}-${counter++}@example.com`;
}
const nextEmail = createEmailFactory('qa');
nextEmail(); // 'qa-1737100000000-0@example.com'
nextEmail(); // 'qa-1737100000000-1@example.com'
Функція createEmailFactory давно повернула значення й завершилась, але змінна counter не зникла — повернена стрілка тримає її живою й інкрементує при кожному зверненні. Прямого доступу до counter ззовні немає: єдиний спосіб змінити його — покликати nextEmail.
Що дивитися і чому:
counterпереживає завершення обгортки — це замикання, не витік. Рушій не звільняє змінну, поки на неї є досяжне посилання; тут воно свідоме й контрольоване. Занепокоєння про пам'ять доречне лише коли таких фабрик плодяться тисячі й ніхто їх не відпускає.- Приватність дає сама область видимості, а не домовленість. Жоден тест не може випадково скинути лічильник чи прочитати його повз API фабрики — стан захований у замиканні, як у приватному полі класу, тільки без класу.
- Ізоляція даних лікує клас флаку. Унікальний email на кожен виклик прибирає конфлікти паралельних воркерів за одні й ті самі дані. Це той самий патерн, що й фікстури: функція готує стан і повертає замкнені на нього хелпери.
Кейс 2. Пастка спільної var у циклі навішування
Живий баг: хочемо в циклі навісити на набір рядків таблиці обробники, що логують індекс, або відкласти перевірки через setTimeout. Пишемо через var — і всі колбеки друкують однакове фінальне число замість 0, 1, 2.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Друкує: 3, 3, 3
Це не глюк рушія. var заводить одну змінну i на всю функцію, тож усі три стрілки замикаються на неї. Поки за 100 мс таймери спрацюють, цикл завершився й i вже дорівнює 3 — усі бачать це значення, бо замикання тримає посилання на змінну, а не її знімок на момент ітерації.
Три способи й що вони насправді роблять:
| Спосіб | Що відбувається | Вивід |
|---|---|---|
var i | одна спільна змінна на всі колбеки | 3, 3, 3 |
let i | нове зв'язування i на кожну ітерацію | 0, 1, 2 |
var + IIFE | окрема область на ітерацію, значення передано аргументом | 0, 1, 2 |
Сучасний лік — просто let:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Друкує: 0, 1, 2
Що дивитися і чому:
letмає блокову область, тому щоітерації створює новеi. Кожен колбек замикає власне значення, а не спільне — звідси очікувані0, 1, 2без жодних обгорток.- IIFE — це історія, але її просять показати. До появи
letокрему область на ітерацію заводили негайно викликаною функцією, куди передавали поточнеiаргументом. Розуміння механізму важливіше за завчене «візьми let». - Той самий баг ширший за
console.log. Він виникає всюди, де в циклі навішують обробники чи планують відкладені дії, замикаючи змінну циклу — типове джерело «чомусь спрацьовує не на тому елементі».
Кейс 3. Обгортка вищого порядку проти flaky-запитів
Мережевий ендпоінт іноді моргає, і кожен тест обростає власним циклом ретраю — копіпаст, який важко підтримувати. Функція вищого порядку прибирає дублювання: приймає запит, повертає його «стабільну» версію, що сама повторює спробу до успіху або останньої невдачі.
function withRetry<T>(fn: () => Promise<T>, attempts = 3): () => Promise<T> {
return async () => {
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (e) {
lastError = e; // ковтаємо до останньої спроби
}
}
throw lastError; // усі спроби вичерпано — кидаємо останню помилку
};
}
const fetchOrders = withRetry(() => api.get('/orders'), 5);
const res = await fetchOrders();
withRetry і приймає функцію, і повертає функцію — тому це функція вищого порядку. А повернена стрілка «пам'ятає» і fn, і attempts після того, як withRetry завершилась, — тому це замикання. Синтаксис <T> тут із TypeScript: він зберігає тип результату запиту наскрізь, щоб res не став any.
Що дивитися і чому:
- Замикання зашиває
fnіattemptsу хелпер. Не треба щоразу передавати їх параметром — вони зафіксовані в момент створення обгортки, як конфіг усередині фікстури. - Ретрай пишеться один раз. Той самий принцип стоїть за декораторами, кастомними асертами й обгортками кроків — усі вони приймають чи повертають функцію.
- Обережно з тим, що ретраїмо. Повторювати варто саме нестабільні мережеві виклики; загортати у ретрай асерт бізнес-логіки небезпечно — так можна замаскувати справжній баг продукту під «просто моргнуло».
Оголошення функцій і hoisting
- Знаю різницю між оголошенням (function declaration) і функціональним виразом (function expression): перше — самостійна конструкція зі словом
function, друге — функція у правій частині присвоєння. - Можу пояснити, чому declaration викликається вище за рядок оголошення, а вираз через
var— ні: declaration піднімається з тілом,varспливає лише як ім'я зі значеннямundefined. - Розумію тимчасову мертву зону (TDZ) для
let/constі розрізняю тексти помилок:is not a function(викликvar-виразу до ініціалізації) vsReferenceError: Cannot access before initialization(TDZ) — кожен підказує про порядок виконання.
Стрілочні функції
- Знаю головну відмінність стрілки: вона не має власного
thisтаarguments, а бере їх лексично з місця оголошення. - Можу пояснити, чому стрілка ідеальна для колбеків (не втрачає зовнішній контекст) і водночас непридатна як метод об'єкта, якому потрібен свій
this. - Пам'ятаю про неявний return в однорядковій стрілці та про те, що об'єктний літерал доводиться загортати в круглі дужки, інакше фігурні дужки сприймуться як тіло.
Параметри: default, rest, spread
- Знаю, що default-параметр спрацьовує на пропущеному аргументі або явному
undefined, але не наnull. - Розумію, що rest збирає решту аргументів у справжній масив і завжди стоїть останнім у списку параметрів.
- Можу пояснити, що spread розкладає масив чи об'єкт на елементи, і що rest vs spread розрізняє тільки контекст: у списку параметрів
...збирає, у виклику чи літералі — розкладає.
Область видимості та ланцюжок
- Знаю три рівні області: глобальна, функціональна та блокова (для
let/const) — і чим блокова відрізняється від функціональної. - Можу описати ланцюжок областей видимості (scope chain): пошук імені знизу вгору до глобальної, а якщо не знайшлось —
ReferenceError. - Розумію, що область лексична: ланцюжок визначається місцем написання функції в коді, а не точкою її виклику.
Замикання
- Можу дати визначення замикання (closure): функція плюс живі посилання на змінні області, де її створили, доступні навіть після завершення обгортки.
- Знаю, що замикання тримає посилання на змінну, а не її знімок у момент створення — звідси й пастка спільної змінної в циклі.
- Можу пояснити, як замикання дає приватний стан та інкапсуляцію без класів: змінна захована в області, ззовні доступна лише через повернені хелпери.
- Розрізняю робоче замикання й витік пам'яті: живе посилання під контролем — норма, тисячі забутих посилань без потреби — витік.
Функції вищого порядку
- Знаю визначення функції вищого порядку: приймає функцію, повертає функцію або те й те; приклади —
map,filter,reduce,setTimeout,page.route. - Можу пояснити, чому функція, що повертає функцію (обгортка-ретрай, декоратор), — це майже завжди замикання плюс вищий порядок разом.
Пастка var/let у циклі
- Можу передбачити вивід циклу з
varіsetTimeout(3, 3, 3) і пояснити чому: одна спільнаvar iна всі колбеки, які бачать її фінальне значення. - Знаю два способи лікування:
let(нове зв'язування на кожну ітерацію) і, історично, IIFE, що заводила окрему область на ітерацію.
Що таке замикання (closure)?
Питання
Оголошення функції (function declaration) vs функціональний вираз (function expression)