Senior: питання на співбесіді з теми «Функції й замикання»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Кожен об'єкт має приховане посилання на інший об'єкт - прототип (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: пізня відповідь на старий запит не має перезаписати свіжу.
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(), що мемоізує результат у межах запиту.
Класичний приклад виправданої мемоізації - рекурсія з перекриттям підзадач (числа Фібоначчі, динамічне програмування): без кешу експоненційна складність, з кешем - лінійна.