Питання на співбесіді з 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-тестів писати: небагато - для критичних сценаріїв. Вони найповільніші й найдорожчі в підтримці; решту поведінки дешевше перевіряти на нижчих рівнях.
Прапорці:
| Прапорець | Що робить |
|---|---|
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) і блокують головний потік. Регулярні вирази з користувацького введення будувати не можна без екранування, а для складного розбору краще парсер.
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), тож формати на сервері й у браузері збігаються.
Сам 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);
}
}
Важливо: скасування на клієнті не зупиняє вже розпочату роботу на сервері. Якщо запит змінює дані, сервер міг їх уже змінити.
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), які додають ще й обробку помилок і скасування.
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 секунд.
Поки 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 });
Ітератор - об'єкт з методом 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 там, де можна без нього, і ніколи не будувати на ньому логіку, від якої залежить коректність.
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 ловить мутації ще на етапі компіляції без витрат під час виконання.
Без компаратора 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() без колатора, порядок «стрибатиме».
Кожен об'єкт має приховане посилання на інший об'єкт - прототип (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(), що мемоізує результат у межах запиту.
Класичний приклад виправданої мемоізації - рекурсія з перекриттям підзадач (числа Фібоначчі, динамічне програмування): без кешу експоненційна складність, з кешем - лінійна.
Питання з реальних технічних співбесід - 114 питань у 10 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Браузерні API й мережа 12 Сучасний синтаксис 12 Асинхронність 12 Типи й приведення 12 DOM і події 12 Масиви й об'єкти 12 Функції й замикання 12 Тестування 10
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії