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

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: пізня відповідь на старий запит не має перезаписати свіжу.

Докладніше в документації: 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(), що мемоізує результат у межах запиту.

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

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