vyvchy
    Теми розділу

    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).

    Так

    Ні

    Так

    Ні

    Так

    Ні

    Звернення до змінної x

    Є в поточній області?

    Взяти локальну

    Є в зовнішній функції?

    Взяти з обгортки через замикання

    Є в глобальній?

    Взяти глобальну

    ReferenceError x is not defined

    Так

    Ні

    Так

    Ні

    Так

    Ні

    Звернення до змінної x

    Є в поточній області?

    Взяти локальну

    Є в зовнішній функції?

    Взяти з обгортки через замикання

    Є в глобальній?

    Взяти глобальну

    ReferenceError x is not defined

    Ключова властивість — лексична область видимості (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 i — своє звʼязування на ітерацію

    Ітерація 0 → i = 0

    друкує 0

    Ітерація 1 → i = 1

    друкує 1

    Ітерація 2 → i = 2

    друкує 2

    var i — одне звʼязування

    Ітерація 0

    Спільна i

    Ітерація 1

    Ітерація 2

    setTimeout бачить i = 3

    let i — своє звʼязування на ітерацію

    Ітерація 0 → i = 0

    друкує 0

    Ітерація 1 → i = 1

    друкує 1

    Ітерація 2 → i = 2

    друкує 2

    var i — одне звʼязування

    Ітерація 0

    Спільна i

    Ітерація 1

    Ітерація 2

    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, обгортка-ретрай. Часто це місток до задачі на живому кодуванні.

    Джерела