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

Питання на співбесіді з JavaScript

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

114 питань

Playwright керує справжніми браузерами (Chromium, Firefox, WebKit) і перевіряє застосунок так, як ним користується людина.

import { test, expect } from '@playwright/test';

test('користувач оформлює замовлення', async ({ page }) => {
  await page.goto('/products/42');
  await page.getByRole('button', { name: 'Додати в кошик' }).click();
  await page.getByRole('link', { name: 'Кошик' }).click();

  await expect(page.getByRole('heading', { name: 'Кошик' })).toBeVisible();
  await expect(page.getByTestId('cart-total')).toHaveText('1 999,00 ₴');
});

Локатори описують, як знайти елемент, а не сам елемент: пошук повторюється щоразу, коли до локатора звертаються. Пріоритет той самий, що в Testing Library: getByRole, getByLabel, getByText, і лише потім getByTestId. CSS-селектори на кшталт .btn-primary > span ламаються від зміни верстки.

Автоочікування - головна причина стабільності. Перед click() Playwright сам чекає, доки елемент буде прикріплений до DOM, видимий, стабільний (без анімації), увімкнений і не перекритий іншим елементом. Перевірки expect(locator).toHaveText() повторюються, доки не пройдуть чи не мине тайм-аут.

Звідки беруться нестабільні (flaky) тести:

Причина Як виправити
page.waitForTimeout(2000) чекати на стан: await expect(locator).toBeVisible()
перевірка значення один раз: expect(await el.textContent()).toBe(...) перевірка, що повторюється: await expect(el).toHaveText(...)
тести залежать один від одного чи від спільних даних кожен тест створює свої дані, ізольований стан входу
реальні сторонні сервіси (платежі, карти) підміна через page.route()
анімації й час вимкнути анімації, фіксувати час через page.clock

Повтор входу в кожному тесті повільний - стан автентифікації зберігають один раз (storageState) і перевикористовують.

Діагностика: trace: 'on-first-retry' у конфігурації записує трасу (знімки DOM, мережу, консоль для кожного кроку) при повторі. npx playwright show-trace показує, що саме сталося в CI.

retries у CI допомагає пережити рідкісні збої, але тест, що проходить лише з другої спроби, - сигнал, а не норма: такі тести варто відстежувати й виправляти.

Скільки E2E-тестів писати: небагато - для критичних сценаріїв. Вони найповільніші й найдорожчі в підтримці; решту поведінки дешевше перевіряти на нижчих рівнях.

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

Прапорці:

Прапорець Що робить
g шукати всі збіги, а не перший
i без урахування регістру
m ^ і $ - початок і кінець кожного рядка тексту
s . збігається й з переносом рядка
u режим Unicode: коректні символи поза BMP, \p{...}
v розширений Unicode-режим: операції над множинами в класах, властивості рядків
y «липкий»: збіг лише точно з позиції lastIndex
d індекси груп у match.indices

Іменовані групи:

const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const { year, month } = '2026-10-04'.match(re).groups;

'2026-10-04'.replace(re, '$<day>.$<month>.$<year>');   // "04.10.2026"

matchAll - усі збіги з групами й позиціями:

for (const m of 'a1 b22 c333'.matchAll(/(?<letter>[a-z])(?<digits>\d+)/g)) {
  console.log(m.groups.letter, m.groups.digits, m.index);
}

Вимагає прапорець g, інакше кидає TypeError.

Lookbehind і lookahead - умова на сусідній текст, яка не входить у збіг:

'ціна: 100 грн'.replace(/(?<=ціна: )\d+/, '200');   // "ціна: 200 грн"
/\d+(?= грн)/.exec('100 грн')[0];                   // "100"
/(?<!\$)\b\d+/;                                     // число, перед яким немає $

Заміна функцією:

'привіт світ'.replace(/\p{L}+/gu, (word) => word[0].toUpperCase() + word.slice(1));
// "Привіт Світ"

Unicode і кирилиця: \w - лише [A-Za-z0-9_], українські літери в нього не входять. Для літер будь-якої мови - \p{L} з прапорцем u чи v; \p{Script=Cyrillic} - лише кирилиця.

Пастка з g і test(): регулярний вираз з прапорцем g зберігає lastIndex між викликами:

const re = /a/g;
re.test('aa');   // true
re.test('aa');   // true
re.test('aa');   // false - пошук почався з кінця рядка

Для перевірки «чи є збіг» прапорець g не потрібен.

Безпека: вирази з вкладеними квантифікаторами ((a+)+$) на зловмисному введенні виконуються експоненційно довго (ReDoS) і блокують головний потік. Регулярні вирази з користувацького введення будувати не можна без екранування, а для складного розбору краще парсер.

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

Intl - вбудований у браузер і Node.js API інтернаціоналізації. Він знає правила форматування для сотень мов, тож не потрібно писати власні функції чи підключати важкі бібліотеки.

Числа й гроші:

new Intl.NumberFormat('uk-UA', { style: 'currency', currency: 'UAH' }).format(1234567.891);
// "1 234 567,89 ₴"

new Intl.NumberFormat('uk-UA', { notation: 'compact' }).format(1500000);
// "1,5 млн"

new Intl.NumberFormat('uk-UA', { style: 'percent', maximumFractionDigits: 1 }).format(0.1234);

Українські правила - кома в дробах і нерозривний пробіл між розрядами - застосовуються автоматично.

Дати:

new Intl.DateTimeFormat('uk-UA', { dateStyle: 'long', timeZone: 'Europe/Kyiv' })
  .format(new Date('2026-10-04T10:00:00Z'));
// "4 жовтня 2026 р."

timeZone важливий: без нього дата форматується в поясі браузера користувача.

Відносний час:

const rtf = new Intl.RelativeTimeFormat('uk', { numeric: 'auto' });
rtf.format(-1, 'day');      // "учора"
rtf.format(3, 'hour');      // "через 3 години"
rtf.format(-5, 'minute');   // "5 хвилин тому"

Множина - найскладніше для української: три форми плюс дроби.

const rules = new Intl.PluralRules('uk');
rules.select(1);    // "one"   -> 1 коментар, 21 коментар
rules.select(2);    // "few"   -> 2 коментарі, 22 коментарі
rules.select(5);    // "many"  -> 5 коментарів, 11 коментарів
rules.select(1.5);  // "other" -> 1,5 коментаря

const forms = { one: 'коментар', few: 'коментарі', many: 'коментарів', other: 'коментаря' };
const label = (n) => `${n} ${forms[rules.select(n)]}`;

Ручне правило «якщо 1 - однина, інакше множина» дає «21 коментарів» і «2 коментарів».

Сортування рядків:

['їжак', 'ґанок', 'яблуко', 'єнот'].sort();
// ["яблуко", "єнот", "їжак", "ґанок"] - за кодами символів

['їжак', 'ґанок', 'яблуко', 'єнот'].sort(new Intl.Collator('uk').compare);
// ["ґанок", "єнот", "їжак", "яблуко"] - за абеткою

Продуктивність: створення форматера відносно дороге - для списків створюйте його один раз і використовуйте format() повторно.

Узгодженість з бекендом: PHP має ті самі правила ICU через розширення intl (NumberFormatter, Number::currency() у Laravel), тож формати на сервері й у браузері збігаються.

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

Сам Promise скасувати не можна - лише проігнорувати його результат. Справжнє скасування роблять через AbortController: операція отримує signal і сама стежить, чи його не скасували.

const controller = new AbortController();

fetch('/api/search?q=laravel', { signal: controller.signal })
  .then((r) => r.json())
  .then(render)
  .catch((error) => {
    if (error.name === 'AbortError') return; // скасовано свідомо - не помилка
    showError(error);
  });

controller.abort(); // запит перервано, з'єднання закрито

Типові сценарії:

  • Пошук під час набору: кожна нова літера скасовує попередній запит, щоб старіша відповідь не перезаписала новішу (класична гонка).
  • Компонент зник (React-ефект, Vue onUnmounted) - скасувати його запити й підписки.
  • Тайм-аут: fetch(url, { signal: AbortSignal.timeout(5000) }).
  • Кілька причин скасування одразу: AbortSignal.any([userSignal, AbortSignal.timeout(5000)]).

Сигнал працює не лише з fetch: addEventListener(type, fn, { signal }) знімає обробник при скасуванні, а у власних функціях можна перевіряти signal.aborted чи слухати подію abort:

async function processAll(items, signal) {
  for (const item of items) {
    signal.throwIfAborted();
    await process(item);
  }
}

Важливо: скасування на клієнті не зупиняє вже розпочату роботу на сервері. Якщо запит змінює дані, сервер міг їх уже змінити.

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

forEach викликає колбек і ігнорує те, що він повертає. async-колбек повертає Promise, який ніхто не чекає: цикл завершується одразу, помилки губляться.

items.forEach(async (item) => { await save(item); });
console.log('готово'); // виведеться ДО того, як щось збережеться

Правильні варіанти залежно від задачі:

// Послідовно, по одному
for (const item of items) {
  await save(item);
}

// Усі паралельно
await Promise.all(items.map((item) => save(item)));

Проблема «всіх паралельно»: тисяча елементів - тисяча одночасних запитів. API почне відповідати 429, браузер обмежить кількість з'єднань, база отримає піку навантаження.

Обмежена паралельність - не більше N одночасно:

async function mapLimit(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;

  async function worker() {
    while (next < items.length) {
      const index = next++;
      results[index] = await fn(items[index], index);
    }
  }

  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return results;
}

const saved = await mapLimit(items, 5, save);

Тут next++ безпечний, бо JavaScript однопотоковий: між читанням і збільшенням лічильника інший «воркер» виконатися не може.

На практиці часто беруть готові бібліотеки (p-limit, p-map), які додають ще й обробку помилок і скасування.

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

fetch за замовчуванням не має тайм-ауту: запит до сервера, що завис, може «висіти» хвилинами. А тимчасові збої мережі чи відповідь 503 часто зникають після повтору.

Тайм-аут через AbortSignal.timeout():

try {
  const response = await fetch('/api/report', { signal: AbortSignal.timeout(5000) });
} catch (error) {
  if (error.name === 'TimeoutError') {
    showError('Сервер не відповів за 5 секунд');
  }
}

При тайм-ауті Promise відхиляється з TimeoutError, а при ручному скасуванні через AbortController - з AbortError. Це дає змогу розрізнити причини.

Поєднати тайм-аут і ручне скасування (користувач пішов зі сторінки) - AbortSignal.any():

const controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]);

Повтор з експоненційною затримкою і випадковим розкидом:

async function fetchWithRetry(url, options = {}, { retries = 3, baseDelay = 300 } = {}) {
  for (let attempt = 0; ; attempt++) {
    try {
      const response = await fetch(url, { ...options, signal: AbortSignal.timeout(5000) });
      if (response.status < 500 && response.status !== 429) {
        return response;                 // успіх або помилка клієнта - не повторюємо
      }
      if (attempt >= retries) return response;
    } catch (error) {
      if (attempt >= retries) throw error;
    }
    const delay = baseDelay * 2 ** attempt * (0.5 + Math.random());
    await new Promise((resolve) => setTimeout(resolve, delay));
  }
}

Що важливо:

  • повторювати лише те, що має сенс повторювати: мережеві помилки, тайм-аути, 502/503/504, 429. Помилки 4xx (валідація, 403, 404) повтор не виправить;
  • ідемпотентність: GET повторювати безпечно. POST на створення замовлення - ні: перша спроба могла дійти до сервера, а загубилася лише відповідь. Для таких запитів потрібен ключ ідемпотентності, який сервер перевіряє;
  • розкид затримки (jitter) потрібен, щоб тисячі клієнтів після збою не повторювали запити синхронно й не «добили» сервер, що відновлюється;
  • Retry-After у відповіді 429/503 варто поважати замість власної затримки;
  • загальний бюджет часу важливіший за кількість спроб: користувач не чекатиме хвилину на три повтори з тайм-аутом по 20 секунд.

Докладніше в документації: AbortSignal.timeout()

Поки JavaScript виконується, браузер не може обробити клік, натискання клавіші чи перемалювати сторінку. Довга задача (long task) - це шматок роботи головного потоку понад 50 мс. Користувач бачить «завислий» інтерфейс, а метрика INP (Interaction to Next Paint) погіршується.

// обробка 50 000 рядків одним циклом - інтерфейс завмирає на секунду
rows.forEach((row) => renderRow(row));

Async-функція сама по собі не допомагає. await всередині циклу над синхронною роботою не віддає керування браузеру надовго: мікрозадачі виконуються одна за одною до порожньої черги, і браузер між ними не малює.

Рішення 1 - віддавати керування між шматками:

async function processInChunks(items, handle) {
  let deadline = performance.now() + 40;
  for (const item of items) {
    handle(item);
    if (performance.now() > deadline) {
      await yieldToMain();
      deadline = performance.now() + 40;
    }
  }
}

function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();              // продовження отримує пріоритет
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

scheduler.yield() віддає керування браузеру, але ставить продовження попереду інших задач, тож робота не «загубиться» в черзі. Він підтримується не всюди, тому потрібен запасний варіант через setTimeout.

Рішення 2 - винести обчислення з головного потоку у Web Worker. Підходить для чистих обчислень (парсинг великого файлу, стиснення, криптографія), але воркер не має доступу до DOM.

Рішення 3 - не робити зайвої роботи:

  • віртуалізація довгих списків - рендерити лише видимі 30 рядків замість 50 000;
  • requestIdleCallback для некритичної роботи (аналітика, попереднє завантаження) - коли браузеру нічим зайнятися;
  • debounce для обробників, що спрацьовують на кожне натискання клавіші.

Як знайти довгі задачі: вкладка Performance у DevTools (позначені червоним), або програмно:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) console.warn('Long task', entry.duration);
}).observe({ type: 'longtask', buffered: true });

Докладніше в документації: Scheduler.yield()

Ітератор - об'єкт з методом next(), який щоразу повертає { value, done }. Ітерований об'єкт має метод [Symbol.iterator](), що повертає ітератор.

Саме на цьому протоколі працюють for...of, spread ([...x]), деструктуризація, Array.from, Promise.all, конструктори Map і Set. Масиви, рядки, Map, Set, NodeList - ітеровані з коробки; звичайні об'єкти - ні.

Власний ітерований об'єкт найпростіше зробити генератором:

class Range {
  constructor(from, to) {
    this.from = from;
    this.to = to;
  }

  *[Symbol.iterator]() {
    for (let i = this.from; i <= this.to; i++) {
      yield i;
    }
  }
}

[...new Range(1, 5)];          // [1, 2, 3, 4, 5]
for (const n of new Range(1, 3)) console.log(n);

Навіщо це на практиці:

  • Ліниві послідовності: значення обчислюються лише тоді, коли їх просять. Можна описати навіть нескінченну послідовність і взяти з неї 10 елементів.
  • Однаковий інтерфейс для власних колекцій - їх можна передавати скрізь, де очікується ітероване.
  • Асинхронні ітератори (Symbol.asyncIterator, for await...of) - для потоків даних: сторінки API, рядки великого файлу, потік відповіді fetch.

Нові «ітераторні хелпери» (Iterator.prototype.map, filter, take, drop) дозволяють працювати з ітераторами ліниво, не перетворюючи їх на масив. Вони вже є в сучасних браузерах і Node.js 22+.

Докладніше в документації: Ітератори й генератори

Збирач сміття звільняє об'єкт, коли на нього немає посилань. Звичайний Map тримає свої ключі: поки об'єкт лежить у Map як ключ, його не звільнять, навіть якщо більше ніде він не потрібен. Звідси витоки пам'яті в кешах і реєстрах.

WeakMap тримає ключі слабко: запис не заважає збирачу сміття. Коли на об'єкт-ключ не лишилося інших посилань, запис зникає разом з ним.

const metadata = new WeakMap();

function track(element) {
  metadata.set(element, { clicks: 0, addedAt: Date.now() });
}
// DOM-елемент видалили зі сторінки й забули - запис у metadata зникне сам

Обмеження WeakMap: ключі - лише об'єкти (і незареєстровані символи), немає size, обходу й clear(). Інакше результат залежав би від того, коли саме пройшов збирач сміття.

Де застосовують:

  • приватні дані, прив'язані до чужих об'єктів, яких не можна змінювати;
  • кеш результатів для об'єктів (мемоізація за аргументом-об'єктом);
  • метадані DOM-вузлів у бібліотеках.

WeakSet - те саме для множини: «чи бачили ми цей об'єкт».

WeakRef - слабке посилання на окремий об'єкт: ref.deref() повертає об'єкт або undefined, якщо його вже зібрано. Разом з FinalizationRegistry дозволяє дізнатися про збирання. Але момент збирання непередбачуваний, тож MDN прямо радить уникати WeakRef там, де можна без нього, і ніколи не будувати на ньому логіку, від якої залежить коректність.

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

const захищає лише змінну від переприсвоєння, але не вміст об'єкта. Для захисту самого об'єкта є три рівні:

Метод Додати властивість Видалити Змінити значення
Object.preventExtensions ні так так
Object.seal ні ні так
Object.freeze ні ні ні
const config = Object.freeze({ apiUrl: '/api', retries: 3 });
config.retries = 10;     // тихо ігнорується
delete config.apiUrl;    // тихо ігнорується
config.retries;          // 3

Тихо - лише в нестрогому режимі. У строгому режимі (а ES-модулі та класи завжди строгі) спроба змінити заморожений об'єкт кидає TypeError. Це добре: помилка видна одразу.

Заморожування поверхневе:

const settings = Object.freeze({ theme: 'dark', limits: { upload: 10 } });
settings.limits.upload = 1000;   // працює!

Вкладені об'єкти лишаються змінюваними. Для глибокого заморожування потрібна рекурсія:

function deepFreeze(object) {
  for (const value of Object.values(object)) {
    if (value && typeof value === 'object' && !Object.isFrozen(value)) {
      deepFreeze(value);
    }
  }
  return Object.freeze(object);
}

Інші нюанси:

  • масиви теж можна заморозити: push, sort, присвоєння елемента кидають помилку в строгому режимі;
  • Map, Set, Date заморожування не захищає: їхні дані зберігаються у внутрішніх слотах, а не у властивостях. Object.freeze(new Map()).set('a', 1) спрацює;
  • розморозити неможливо - лише створити змінювану копію ({ ...frozen }, structuredClone);
  • перевірки: Object.isFrozen, Object.isSealed, Object.isExtensible.

Де це корисно:

  • константи й конфігурація, що не повинні змінюватися випадково;
  • «перелічення» без TypeScript: const Status = Object.freeze({ Paid: 'paid', New: 'new' });
  • спільний стан, який мають змінювати лише через визначені функції - заморожування ловить випадкові мутації в розробці.

Що не варто робити: заморожувати все підряд «для іммутабельності». Реальну незмінність у застосунках зазвичай забезпечують підходом - нові об'єкти замість зміни старих (toSorted, spread), - а TypeScript з readonly ловить мутації ще на етапі компіляції без витрат під час виконання.

Докладніше в документації: Object.freeze()

Без компаратора sort() перетворює елементи на рядки і порівнює їх за кодами символів UTF-16:

[10, 9, 1].sort();   // [1, 10, 9] - '10' < '9', бо '1' < '9'

Для чисел потрібна функція порівняння, що повертає від'ємне, нуль чи додатне число:

[10, 9, 1].sort((a, b) => a - b);        // [1, 9, 10]
users.sort((a, b) => b.age - a.age);     // за спаданням віку

Українські рядки. Порівняння за кодами UTF-16 ламає абетку: літери є, і, ї, ґ мають коди більші за я, бо в Unicode стоять поза основним блоком:

['яблуко', 'їжак', 'ґанок', 'гора', 'єнот'].sort();
// ['гора', 'яблуко', 'єнот', 'їжак', 'ґанок'] - неправильно

Правильно - Intl.Collator з українською локаллю:

const collator = new Intl.Collator('uk');
words.sort(collator.compare);
// ['гора', 'ґанок', 'єнот', 'їжак', 'яблуко']

users.sort((a, b) => collator.compare(a.name, b.name));

Корисні опції колатора:

  • numeric: true - «природне» сортування чисел у рядках: файл2 перед файл10;
  • sensitivity: 'base' - ігнорувати регістр і діакритику при порівнянні (зручно для пошуку збігів);
  • caseFirst: 'upper' - великі літери першими.

a.localeCompare(b, 'uk') дає той самий результат, але створює колатор при кожному виклику. Для сортування великих масивів один Intl.Collator значно швидший.

Інші факти про sort:

  • сортування змінює масив на місці і повертає той самий масив. Для копії - toSorted() (ES2023);
  • сортування стабільне (гарантовано з ES2019): елементи з однаковим ключем зберігають взаємний порядок. Тому сортування за кількома ключами можна робити послідовно, але простіше одним компаратором:
items.sort((a, b) => a.category.localeCompare(b.category, 'uk') || b.price - a.price);
  • компаратор має бути узгодженим: повертати true/false ((a, b) => a > b) - помилка. Результат залежить від рушія і може бути неправильним;
  • undefined завжди йдуть у кінець, компаратор для них не викликається.

Сортування на сервері й на клієнті має збігатися. Якщо список посторінково сортує база (з колацією uk), а клієнт досортовує сторінку через sort() без колатора, порядок «стрибатиме».

Докладніше в документації: Array.prototype.sort()

Кожен об'єкт має приховане посилання на інший об'єкт - прототип (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(), що мемоізує результат у межах запиту.

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

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

Питання з реальних технічних співбесід - 114 питань у 10 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 37 Middle 41 Senior 36

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії