JavaScript: Senior
20 питань · ~22 хв · Версія v3.0
Увійдіть, щоб продовжити
Рушій і мова глибоко: мікрозадачі, пам'ять і WeakRef, Proxy, ітератори, дескриптори, ESM.
- За спробу
- 20
- У пулі
- 100
- Проходжень
- 0
- Середній бал
- -
- Пройшли на 70%+
- -
Питання для підготовки
36 питаньСам 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+.
Спершу почитати
Прочитати - ще не значить знати
20 питань, по одному на екран, ~22 хв. Після завершення - розбір кожної помилки з посиланням на питання.