Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Функції й замикання

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

12 питань

Замикання - це функція разом зі змінними того місця, де її створено. Внутрішня функція «пам'ятає» змінні зовнішньої навіть після того, як зовнішня вже завершилася.

function makeCounter() {
  let count = 0;
  return () => ++count;
}

const next = makeCounter();
next(); // 1
next(); // 2
// count недоступний ззовні, але живе, поки живе функція next

Де замикання трапляються щодня:

  • Обробники подій і колбеки - вони бачать змінні з коду, що їх створив.
  • Приватний стан - як count вище: змінити його можна лише через функцію.
  • Фабрики функцій - const add5 = makeAdder(5).
  • Хуки React - кожен рендер створює нові функції, що замикають значення стану саме цього рендеру.

Класична пастка з var у циклі:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i)); // 3, 3, 3
}

var має одну змінну на всю функцію, і до моменту спрацювання таймерів вона вже дорівнює 3. З let у кожної ітерації своя змінна, і виведеться 0, 1, 2.

Нюанс для пам'яті: поки замикання живе (наприклад, обробник не знято), живуть і всі змінні, які воно використовує, разом з великими об'єктами в них.

Докладніше в документації: Замикання

var let const
Область видимості функція блок {} блок {}
Повторне присвоєння так так ні
Повторне оголошення так ні ні
Доступ до оголошення undefined помилка (TDZ) помилка (TDZ)
if (true) {
  var a = 1;
  let b = 2;
}
a; // 1 - var «протікає» з блоку
b; // ReferenceError

Підняття (hoisting): оголошення всіх трьох обробляються до виконання коду, але по-різному. var одразу отримує undefined, тож звернення до нього до рядка оголошення не дає помилки - лише тихий undefined. let і const до оголошення перебувають у «тимчасовій мертвій зоні» (TDZ): звернення кидає ReferenceError. Друге поведінка краща: помилка видна одразу.

const не робить значення незмінним - лише забороняє переприсвоїти змінну:

const user = { name: 'Оля' };
user.name = 'Іра';   // можна: змінюємо вміст об'єкта
user = {};           // TypeError

Для незмінності вмісту - Object.freeze() (поверхнево).

Сучасне правило: const за замовчуванням, let - коли значення справді змінюється, var - не використовувати.

Докладніше в документації: let

У JavaScript функцію можна створити кількома способами, і вони відрізняються не лише синтаксисом.

// оголошення функції (function declaration)
function add(a, b) {
  return a + b;
}

// функціональний вираз (function expression)
const multiply = function (a, b) {
  return a * b;
};

// стрілкова функція (arrow function)
const square = (n) => n * n;

1. Підняття (hoisting). Оголошення функції доступне до рядка, де воно написане, - рушій «піднімає» його на початок області видимості:

greet();                  // працює
function greet() { console.log('Привіт'); }

sayHi();                  // TypeError: sayHi is not a function (з var)
var sayHi = function () {};

wave();                   // ReferenceError (з const/let)
const wave = () => {};

Вирази й стрілкові функції - це значення, присвоєні змінним, тож діють правила змінних: з const/let до ініціалізації звертатися не можна.

2. this. Звичайні функції отримують this залежно від способу виклику. Стрілкові функції власного this не мають - беруть його з місця, де їх створено. Тому стрілки зручні в колбеках усередині методів класу, але не підходять як методи об'єкта, яким потрібен this цього об'єкта.

3. Інші відмінності стрілкових функцій:

  • немає об'єкта arguments (використовують rest-параметри ...args);
  • не можна викликати з new - вони не конструктори;
  • короткий запис з неявним поверненням: (n) => n * 2. Щоб повернути об'єкт, його беруть у дужки: () => ({ id: 1 }).

Що обирати:

  • оголошення функції - для «іменованих» функцій верхнього рівня модуля: їх зручно читати й шукати, і порядок у файлі не важливий;
  • стрілкові функції - для колбеків (map, filter, обробники подій) і коротких допоміжних функцій;
  • методи класів і об'єктів - звичайний синтаксис методу save() { ... }.

Іменований функціональний вираз (const f = function factorial(n) { ... }) має ім'я, видиме лише всередині, - для рекурсії й читабельних стеків помилок.

Докладніше в документації: Функції

Параметри за замовчуванням задають значення, якщо аргумент не передано:

function formatPrice(amount, currency = 'грн', digits = 2) {
  return `${amount.toFixed(digits)} ${currency}`;
}

formatPrice(100);          // '100.00 грн'
formatPrice(100, '$');     // '100.00 $'

Значення за замовчуванням спрацьовує лише для undefined, але не для null, 0 чи порожнього рядка:

formatPrice(100, undefined);  // '100.00 грн'
formatPrice(100, null);       // '100.00 null'

Значення обчислюється при кожному виклику і може посилатися на попередні параметри:

function createUser(name, slug = name.toLowerCase(), createdAt = new Date()) { /* ... */ }

Кожен виклик отримає нову дату чи новий масив (items = []) - на відміну від Python, де значення за замовчуванням створюється один раз.

Rest-параметри збирають «решту» аргументів у справжній масив:

function sum(...numbers) {
  return numbers.reduce((total, n) => total + n, 0);
}
sum(1, 2, 3);   // 6

function log(level, ...messages) {
  console[level](...messages);
}

Rest-параметр може бути лише один і лише останнім.

Рядом - старий об'єкт arguments, псевдомасив усіх переданих аргументів. Він не має методів масиву, недоступний у стрілкових функціях і погано читається. У сучасному коді його замінюють rest-параметри.

Іменовані параметри через деструктуризацію - зручний прийом для функцій з багатьма необов'язковими налаштуваннями:

function fetchUsers({ page = 1, perPage = 20, sort = 'name' } = {}) { /* ... */ }

fetchUsers({ sort: 'created_at' });
fetchUsers();   // працює завдяки "= {}"

Порядок аргументів більше не важливий, а виклик сам себе документує. Без = {} наприкінці виклик без аргументів кине помилку: деструктуризувати undefined неможливо.

function.length рахує лише параметри до першого зі значенням за замовчуванням і без rest - деякі бібліотеки (каррування, DI) на це покладаються.

Докладніше в документації: Параметри за замовчуванням

У звичайній функції this визначається в момент виклику, а не там, де функцію написано:

  • obj.method() - this це obj;
  • просто fn() - undefined у строгому режимі (у модулях і класах він завжди строгий), глобальний об'єкт - у нестрогому;
  • new Fn() - новий об'єкт;
  • fn.call(ctx), fn.apply(ctx), fn.bind(ctx) - явно заданий ctx;
  • в обробнику addEventListener - елемент, на якому висить обробник.

Типова пастка - метод, переданий як колбек:

class Timer {
  seconds = 0;
  tick() { this.seconds++; }
}

const timer = new Timer();
setInterval(timer.tick, 1000); // this === undefined -> TypeError

Метод «відірвався» від об'єкта, і викликається вже як просто функція.

Стрілкова функція не має власного this - вона бере this з місця, де її створено (лексично), і змінити його не можна навіть через call чи bind:

setInterval(() => timer.tick(), 1000);   // працює
// або поле-стрілка в класі:
class Timer2 { seconds = 0; tick = () => { this.seconds++; }; }

Ще відмінності стрілкових функцій: немає власного arguments, їх не можна викликати через new, вони не мають prototype.

Коли НЕ стрілка: методи об'єкта, яким потрібен this цього об'єкта ({ name: 'x', greet: () => this.name } не працює), і обробники, де потрібен this як елемент.

Докладніше в документації: this

Усі три дозволяють явно задати this для звичайної функції:

  • fn.call(ctx, a, b) - викликає функцію одразу, аргументи передаються через кому.
  • fn.apply(ctx, [a, b]) - те саме, але аргументи - масивом.
  • fn.bind(ctx, a) - не викликає, а повертає нову функцію з назавжди прив'язаним this (і, за бажанням, частиною аргументів).
function greet(greeting, punctuation) {
  return `${greeting}, ${this.name}${punctuation}`;
}

const user = { name: 'Оля' };

greet.call(user, 'Привіт', '!');          // 'Привіт, Оля!'
greet.apply(user, ['Вітаю', '.']);         // 'Вітаю, Оля.'

const hello = greet.bind(user, 'Привіт');
hello('?');                                // 'Привіт, Оля?'

Де це трапляється:

  • bind - щоб передати метод як колбек, не загубивши this: button.addEventListener('click', this.onClick.bind(this)). Сьогодні частіше пишуть стрілкову функцію.
  • Часткове застосування: const double = multiply.bind(null, 2).
  • apply історично використовували, щоб передати масив як аргументи: Math.max.apply(null, numbers). Тепер є spread: Math.max(...numbers).
  • Позичання методів: Array.prototype.slice.call(arguments) - тепер замість цього Array.from().

Пастки: функцію, прив'язану через bind, повторний bind уже не перевизначить. А на стрілкові функції call, apply і bind щодо this не впливають узагалі.

Докладніше в документації: Function.prototype.bind

IIFE (immediately invoked function expression) - функція, яку оголошують і одразу викликають:

(function () {
  const secret = 'видно лише тут';
  // ...
})();

(() => {
  // те саме стрілковою функцією
})();

Дужки навколо функції потрібні, щоб рушій сприйняв її як вираз, а не як оголошення функції (яке не можна одразу викликати).

Навіщо їх використовували:

  • ізоляція області видимості. До ES-модулів усі скрипти на сторінці ділили одну глобальну область. Змінні з var на верхньому рівні ставали властивостями window і конфліктували між бібліотеками. IIFE створювала приватну область;
  • шаблон «модуль» - приватний стан плюс публічний інтерфейс:
const counter = (function () {
  let count = 0;
  return {
    increment: () => ++count,
    current: () => count,
  };
})();
  • копія значення в циклі з var - до появи let кожна ітерація ділила одну змінну.

Що змінилося:

  • ES-модулі мають власну область видимості: змінні верхнього рівня модуля не глобальні. Vite, вбудовані <script type="module"> - IIFE для ізоляції не потрібні;
  • let і const мають блокову область видимості - досить фігурних дужок;
  • замикання для приватного стану тепер часто замінюють приватні поля класів (#count).

Де IIFE досі трапляються:

  • асинхронний код на верхньому рівні звичайного скрипта (не модуля), де немає top-level await:
(async () => {
  const config = await loadConfig();
  start(config);
})();
  • результат збирання. Збирачі пакують код для старих оточень і бібліотеки у формат IIFE (наприклад, format: 'iife' у Rollup/Vite) - так вони підключаються одним <script> без конфліктів;
  • ініціалізація константи складною логікою в одному виразі (хоча зазвичай читабельніша окрема функція).

Пастка: рядок перед IIFE без крапки з комою. const a = b і на наступному рядку (function () {})() рушій прочитає як виклик b(...). Звідси звичка ставити ; перед IIFE у коді без крапок з комою.

Докладніше в документації: IIFE

Змінні let, const (і класи) теж «піднімаються» на початок своєї області видимості, але не ініціалізуються. Від початку блоку до рядка оголошення змінна існує, але звертання до неї кидає ReferenceError. Цей проміжок називають тимчасовою мертвою зоною (temporal dead zone).

{
  console.log(price);   // ReferenceError: Cannot access 'price' before initialization
  let price = 100;
}

З var поведінка інша - змінна ініціалізується як undefined, і помилка тихо проходить:

console.log(total);   // undefined
var total = 100;

Чому це «тимчасова», а не «просторова» зона. Важливий момент виконання, а не позиція в тексті:

function showPrice() {
  console.log(price);   // функцію оголошено вище за let...
}
let price = 100;
showPrice();            // ...але викликано після ініціалізації - працює

Неочевидні випадки:

  • typeof не рятує. Для неоголошеної змінної typeof x повертає 'undefined', але для змінної в TDZ - кидає ReferenceError;
  • затінення:
const limit = 10;
function check() {
  console.log(limit);   // ReferenceError: внутрішня limit у TDZ
  const limit = 20;
}

Внутрішнє оголошення «закриває» зовнішнє для всього блоку, а не лише від свого рядка;

  • параметри за замовчуванням: function f(a = b, b = 1) {} - при виклику f() параметр b ще в TDZ;
  • класи: new User() до рядка class User {} теж кидає помилку, на відміну від функцій.

Навіщо це зроблено так:

  • помилка доступу до неініціалізованої змінної видна одразу, а не перетворюється на загадкове undefined;
  • const повинна мати рівно одне значення - побачити її «до» присвоєння було б порушенням цього правила.

Практичний висновок: оголошувати змінні до використання, на початку блоку чи безпосередньо перед ним. Помилки TDZ найчастіше трапляються при циклічних імпортах модулів: модуль звертається до експортованого const іншого модуля, який ще не встиг виконатися.

Докладніше в документації: let: тимчасова мертва зона

Кожен об'єкт має приховане посилання на інший об'єкт - прототип (Object.getPrototypeOf(obj)). Якщо властивості немає в самому об'єкті, рушій шукає її в прототипі, потім у прототипі прототипу - і так до null. Це ланцюжок прототипів.

const animal = { speak() { return `${this.name} подає голос`; } };
const dog = Object.create(animal);
dog.name = 'Рекс';
dog.speak(); // знайдено в прототипі; this - dog

Класи - синтаксис над тим самим механізмом:

class Animal {
  constructor(name) { this.name = name; }
  speak() { return `${this.name} подає голос`; }
}

class Dog extends Animal {
  speak() { return `${super.speak()}: гав`; }
}

Під капотом Animal - функція-конструктор, методи лежать в Animal.prototype, а extends будує ланцюжок Dog.prototype → Animal.prototype → Object.prototype. Методи не копіюються в кожен екземпляр - усі екземпляри ділять одні й ті самі методи через прототип.

Що відрізняє класи від «ручних» конструкторів:

  • клас не можна викликати без new;
  • тіло класу завжди в строгому режимі;
  • приватні поля #secret - справді приватні, на рівні мови, а не за домовленістю;
  • поля класу (count = 0) створюються на кожному екземплярі, а не в прототипі.

Пастки:

  • Змінюваний об'єкт у прототипі спільний для всіх екземплярів: масив, доданий у прототип, - один на всіх.
  • Розширювати вбудовані прототипи (Array.prototype.myMethod = ...) погано: конфлікти з бібліотеками й майбутніми версіями мови.

Докладніше в документації: Успадкування і ланцюжок прототипів

Обидва обмежують, як часто спрацьовує функція, яку викликають дуже часто: введення тексту, scroll, resize, рух миші.

  • Debounce - виконати один раз, коли виклики припинилися на заданий час. Пошук під час набору: запит іде, коли користувач перестав друкувати на 300 мс.
  • Throttle - виконувати не частіше ніж раз на заданий проміжок, поки виклики тривають. Позиція скролу для прогрес-бару: раз на 100 мс, а не 60 разів на секунду.

Обидва - класичний приклад замикань: таймер і час останнього виклику живуть у зовнішній функції.

function debounce(fn, wait) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}

function throttle(fn, wait) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= wait) {
      last = now;
      fn.apply(this, args);
    }
  };
}

input.addEventListener('input', debounce((e) => search(e.target.value), 300));

Що питають на старших рівнях:

  • Leading / trailing: чи виконувати функцію на початку серії, наприкінці чи обидва рази. Throttle вище втрачає останній виклик серії - для прогрес-бару це помилка, бо фінальна позиція не потрапить.
  • Скасування: debounce у компоненті треба вміти скасувати при його знищенні, інакше колбек спрацює вже після того.
  • requestAnimationFrame - природний «throttle» для всього, що малює: не частіше за кадр.

Для debounce пошукових запитів додають ще AbortController: пізня відповідь на старий запит не має перезаписати свіжу.

Докладніше в документації: Debounce

JavaScript звільняє пам'ять автоматично: збирач сміття видаляє об'єкти, до яких неможливо дістатися від «коренів» (глобальних об'єктів, стеку викликів, активних обробників). Витік - це об'єкт, який уже не потрібен, але на який досі хтось посилається.

Замикання тримає змінні своєї області видимості, поки живе сама функція:

function attachHandler(element) {
  const hugeReport = buildReport();       // 50 МБ даних

  element.addEventListener('click', () => {
    console.log(hugeReport.title);        // замикання тримає hugeReport
  });
}

Поки обробник зареєстрований, hugeReport у пам'яті. Якщо обробнику потрібен лише заголовок - краще захопити лише його: const title = hugeReport.title.

Типові джерела витоків у фронтенді:

  • обробники на глобальних об'єктах (window, document), додані компонентом і не зняті при його знищенні:
// при кожному монтуванні компонента - новий обробник, старі живуть вічно
window.addEventListener('resize', this.onResize);
  • таймери: setInterval, що посилається на дані компонента, працює й після того, як компонент зник зі сторінки;
  • від'єднані вузли DOM: елемент видалено зі сторінки, але посилання на нього лишилося в масиві, кеші чи замиканні - разом з ним живе все його піддерево;
  • кеші без обмеження: Map, куди дані лише додають;
  • спостерігачі й підписки: IntersectionObserver, WebSocket-обробники, підписки на сховище стану.

Як прибирати:

const controller = new AbortController();

window.addEventListener('resize', onResize, { signal: controller.signal });
document.addEventListener('keydown', onKey, { signal: controller.signal });

// при знищенні компонента - одним викликом знімаються всі обробники
controller.abort();

У фреймворках для цього є хуки знищення: onUnmounted у Vue, функція очищення в useEffect React, destroy() в Alpine-компонентах.

Для кешів - WeakMap (запис зникає разом з об'єктом-ключем) чи обмеження розміру з витісненням.

Як знайти витік: у DevTools вкладка Memory - зробити знімок купи, повторити дію кілька разів (відкрити й закрити модальне вікно), зробити ще знімок і порівняти. Об'єкти, кількість яких росте з кожним повтором, і поле Detached для від'єднаних вузлів DOM вказують на винуватця. Панель Retainers показує ланцюжок посилань, що тримає об'єкт.

Особливо важливо для SPA і wire:navigate: сторінка не перезавантажується, тож витоки накопичуються годинами роботи.

Докладніше в документації: Керування пам'яттю

Мемоізація - кешування результату функції за її аргументами: повторний виклик з тими самими аргументами повертає збережений результат замість повторного обчислення.

function memoize(fn) {
  const cache = new Map();
  return (arg) => {
    if (!cache.has(arg)) {
      cache.set(arg, fn(arg));
    }
    return cache.get(arg);
  };
}

const slowSquare = (n) => { /* важке обчислення */ return n * n; };
const fastSquare = memoize(slowSquare);

Кеш живе в замиканні - прямо до нього ніхто не дістанеться.

Умови, за яких мемоізація коректна:

  • функція чиста: результат залежить лише від аргументів і не має побічних ефектів. Мемоізувати getCurrentUser() чи функцію з Date.now() всередині - отримати застарілі дані;
  • аргументи можна порівняти як ключ. Для примітивів - просто. Для об'єктів Map порівнює за посиланням: два однакові за вмістом об'єкти - два різні ключі. Для кількох аргументів ключ доводиться будувати (JSON.stringify(args)), а це має свою ціну.

Коли мемоізація шкодить:

  • дешеві функції. Перевірка кешу, побудова ключа й зберігання можуть коштувати більше, ніж саме обчислення;
  • рідкісні повтори. Якщо аргументи майже завжди нові, кеш лише росте;
  • пам'ять. Кеш без обмеження - витік. Для довгоживучих сторінок потрібне обмеження розміру (LRU) або WeakMap, якщо ключ - об'єкт, і запис має зникати разом з ним;
  • змінні аргументи. Якщо об'єкт-аргумент змінили «на місці», посилання те саме - кеш поверне результат для старого вмісту.

Мемоізація у фреймворках:

  • Vue computed - мемоізоване значення, що перераховується лише при зміні реактивних залежностей;
  • React useMemo, useCallback, memo - зберігають значення чи функцію між рендерами. Найчастіша помилка - обгортати ними все підряд: порівняння залежностей теж коштує, а код стає складнішим. Їх застосовують при виміряній проблемі продуктивності чи для стабільних посилань, від яких залежать інші ефекти;
  • у Laravel аналог - once(), що мемоізує результат у межах запиту.

Класичний приклад виправданої мемоізації - рекурсія з перекриттям підзадач (числа Фібоначчі, динамічне програмування): без кешу експоненційна складність, з кешем - лінійна.

Докладніше в документації: Мемоізація