JavaScript: питання на співбесіді рівня Middle
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
41 питань
JavaScript виконує код в одному потоці. Асинхронні дії (таймери, мережа, події) виконує середовище - браузер чи Node.js, а колбеки, що мають спрацювати після них, стають у черги. Event loop бере з черг наступне завдання, коли стек викликів порожній.
Дві черги з різним пріоритетом:
- Макрозадачі (tasks):
setTimeout,setInterval, події користувача, повідомлення з мережі. - Мікрозадачі (microtasks): колбеки Promise (
then, продовження післяawait),queueMicrotask,MutationObserver.
Порядок: виконати одну макрозадачу → виконати всі мікрозадачі, що накопичилися (включно з тими, що додалися під час виконання) → (у браузері) відмалювати сторінку → наступна макрозадача.
console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
// 1, 4, 3, 2
setTimeout(fn, 0) не означає «одразу»: це макрозадача, і вона чекає, поки виконаються всі мікрозадачі.
Практичні наслідки:
- Довгий синхронний код (великий цикл, важкий
JSON.parse) блокує все: кліки, анімацію, рендер. Важку роботу ділять на частини або виносять у Web Worker. - Нескінченний ланцюжок мікрозадач теж заморожує сторінку: рендер чекає, поки черга мікрозадач спорожніє.
Усі чотири приймають масив Promise і запускаються паралельно, але по-різному вирішують, коли й чим завершитися:
Promise.all- чекає всі. Результат - масив значень у тому ж порядку. Перша ж помилка відхиляє весь результат (інші Promise продовжують виконуватися, але їхні результати губляться).Promise.allSettled- чекає всі, ніколи не відхиляється. Результат - масив об'єктів{status: 'fulfilled', value}або{status: 'rejected', reason}.Promise.race- результат першого, що завершився, хай успіхом, хай помилкою.Promise.any- перший успішний. Відхиляється зAggregateError, лише якщо відхилено всі.
// Усе потрібне для сторінки - інакше показати помилку
const [user, settings] = await Promise.all([getUser(), getSettings()]);
// Розіслати сповіщення й порахувати, скільки не дійшло
const results = await Promise.allSettled(recipients.map(send));
const failed = results.filter((r) => r.status === 'rejected').length;
// Тайм-аут
const data = await Promise.race([fetchData(), sleep(5000).then(() => { throw new Error('timeout'); })]);
// Перше доступне дзеркало
const file = await Promise.any(mirrors.map((url) => fetch(url)));
Нюанс race для тайм-ауту: «програлий» запит не скасовується, він продовжує виконуватися. Для справжнього скасування потрібен AbortController (у fetch - AbortSignal.timeout(5000)).
Старі API (події, таймери, бібліотеки з колбеками) зручно «загорнути» в Promise, щоб використовувати з await. Це називають промісифікацією.
Класичний спосіб - конструктор new Promise:
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function loadImage(src) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Не вдалося завантажити ${src}`));
image.src = src;
});
}
await sleep(500);
const image = await loadImage('/logo.png');
Правила:
resolveіrejectдіють лише перший раз - повторні виклики ігноруються;- виняток, кинутий синхронно всередині функції-виконавця, автоматично відхиляє Promise;
- не загортайте в
new Promiseте, що вже повертає Promise, - це зайвий шар і місце для втрачених помилок.
Promise.withResolvers() (ES2024) повертає Promise разом з його resolve і reject назовні:
const { promise, resolve, reject } = Promise.withResolvers();
Це зручно, коли виконання відбувається не в одному колбеку, а десь пізніше - наприклад, чекаємо на відповідь у WebSocket за ідентифікатором запиту:
const pending = new Map();
function request(socket, payload) {
const id = crypto.randomUUID();
const { promise, resolve, reject } = Promise.withResolvers();
pending.set(id, { resolve, reject });
socket.send(JSON.stringify({ id, ...payload }));
return promise;
}
socket.onmessage = (event) => {
const { id, result, error } = JSON.parse(event.data);
const handlers = pending.get(id);
pending.delete(id);
error ? handlers?.reject(new Error(error)) : handlers?.resolve(result);
};
Раніше для цього писали той самий шаблон вручну, зберігаючи resolve у зовнішні змінні з конструктора.
У Node.js для функцій зі стилем (error, result) є готовий util.promisify, а більшість модулів мають Promise-версії (node:fs/promises).
for await...of перебирає послідовність значень, що надходять асинхронно: кожен крок циклу чекає на наступне значення.
Асинхронний генератор - найпростіший спосіб таку послідовність створити. Наприклад, обхід API з пагінацією:
async function* fetchAllPages(url) {
let next = url;
while (next) {
const response = await fetch(next);
const page = await response.json();
yield* page.data; // віддати елементи сторінки по одному
next = page.links.next; // колекція API Resource у Laravel кладе сюди URL наступної сторінки
}
}
for await (const user of fetchAllPages('/api/users')) {
console.log(user.name);
if (user.id === 42) break; // решта сторінок навіть не буде завантажена
}
Переваги перед «завантажити все в масив»:
- лінивість - наступна сторінка запитується лише тоді, коли попередню оброблено;
- пам'ять - не треба тримати весь набір одночасно;
breakчиreturnз циклу зупиняє генератор, і зайві запити не робляться.
Де ще зустрічається:
- потоки даних:
ReadableStreamу Node.js, Chrome і Firefox можна перебирати черезfor await(Safari поки ні - там читають черезgetReader()):
const response = await fetch('/export.csv');
const decoder = new TextDecoder();
for await (const chunk of response.body) {
process(decoder.decode(chunk, { stream: true }));
}
- читання файлів построково в Node.js (
readline), події, черги повідомлень.
for await над масивом Promise теж працює, але виконує їх у порядку масиву, а не в порядку завершення, і помилки обробляє незручно: for await (const r of [p1, p2, p3]). Якщо Promise вже запущені, краще Promise.all чи Promise.allSettled.
Array.fromAsync() (ES2024) збирає асинхронну послідовність у масив: const users = await Array.fromAsync(fetchAllPages('/api/users'));.
Обидва зберігають пари «ключ - значення», але Map створений саме як словник, а об'єкт - як запис з полями.
Відмінності:
- Ключі. У об'єкта ключі - лише рядки й символи (число
1стане рядком'1'). УMapключем може бути будь-що: об'єкт, функція,NaN. - Розмір.
map.size- одразу; для об'єкта -Object.keys(obj).length. - Порядок.
Mapзберігає порядок вставки. У об'єкта порядок теж визначений, але з винятком: ключі, схожі на цілі числа, йдуть першими за зростанням. - Немає успадкованих ключів. Звичайний об'єкт має прототип, і ключі на кшталт
constructorчи__proto__можуть дати сюрпризи.Mapпорожній по-справжньому. - Швидкодія на частих додаваннях і видаленнях у
Mapзазвичай краща. - JSON.
JSON.stringify(map)дає{}- його треба перетворювати:Object.fromEntries(map).
const visits = new Map();
const button = document.querySelector('#buy');
visits.set(button, 1); // ключ - DOM-елемент
visits.set(button, visits.get(button) + 1);
visits.has(button); // true
for (const [element, count] of visits) { /* ... */ }
Коли що: об'єкт - для структур з відомими полями ({ id, name, email }), JSON і конфігурацій. Map - для словників з динамічними ключами, частих змін і ключів-не-рядків. Для набору унікальних значень - Set.
Змінюють масив на місці (мутують): push, pop, shift, unshift, splice, sort, reverse, fill, copyWithin.
Повертають новий, не чіпаючи початковий: map, filter, slice, concat, flat, flatMap, а також нові (ES2023) toSorted, toReversed, toSpliced і with.
const scores = [30, 10, 20];
const sorted = scores.sort((a, b) => a - b);
// scores теж став [10, 20, 30]: sort змінює масив і повертає його ж
const safe = [30, 10, 20];
const sortedCopy = safe.toSorted((a, b) => a - b); // safe лишився [30, 10, 20]
const replaced = safe.with(0, 99); // [99, 10, 20], safe не змінився
Чому це важливо:
- У React, Vue, Redux мутація стану на місці ламає виявлення змін: посилання на масив те саме, і компонент може не оновитися. Тому
toSorted()чи[...items].sort()замістьitems.sort(). - Масив з аргументу функції належить коду, який його передав. Відсортувати його на місці - змінити чужі дані непомітно.
Ще пастка sort: без функції порівняння елементи порівнюються як рядки, тож [10, 9, 1].sort() дає [1, 10, 9]. Для чисел потрібен (a, b) => a - b, для українського тексту - (a, b) => a.localeCompare(b, 'uk').
Групування масиву за ознакою довго писали вручну через reduce. З ES2024 для цього є вбудовані функції.
Object.groupBy(items, callback) - результат у звичайному об'єкті, ключі - те, що повернув колбек:
const orders = [
{ id: 1, status: 'paid', total: 100 },
{ id: 2, status: 'new', total: 50 },
{ id: 3, status: 'paid', total: 70 },
];
const byStatus = Object.groupBy(orders, (order) => order.status);
// { paid: [{ id: 1, ... }, { id: 3, ... }], new: [{ id: 2, ... }] }
const bySize = Object.groupBy(orders, ({ total }) => (total >= 70 ? 'large' : 'small'));
Map.groupBy(items, callback) - результат у Map. Потрібен, коли ключ - не рядок: об'єкт, дата, число, яке не має перетворюватися на рядок:
const byCustomer = Map.groupBy(orders, (order) => customersById.get(order.customerId));
byCustomer.get(someCustomer); // замовлення конкретного об'єкта-клієнта
Особливості:
- це статичні функції, а не методи масиву:
Object.groupBy(arr, fn), а неarr.groupBy(fn). Метод масиву планували, але він конфліктував зі старими бібліотеками, що розширювалиArray.prototype; - об'єкт від
Object.groupByмає прототипnull: у нього немаєhasOwnProperty,toStringтощо.result.hasOwnProperty('paid')кине помилку - перевіряйте черезObject.hasOwn(result, 'paid')чи'paid' in result; - групи відсутніх значень просто не створюються - порожніх масивів для «ненайдених» статусів не буде;
- елементи в групах ідуть у порядку вихідного масиву.
Як це робили раніше (і досі доводиться в старих оточеннях):
const byStatus = orders.reduce((groups, order) => {
(groups[order.status] ??= []).push(order);
return groups;
}, {});
Порівняння з Laravel: це аналог collect($orders)->groupBy('status'). Агрегати по групах далі рахують звичайними методами: Object.entries(byStatus).map(([status, list]) => [status, list.length]).
Підтримка: усі сучасні браузери з 2024 року і Node.js 21+. Для старших середовищ - поліфіл (core-js) чи reduce.
Set - колекція унікальних значень. Повторне додавання того самого значення нічого не змінює.
const tags = new Set(['php', 'laravel', 'php']);
tags.size; // 2
tags.add('vue');
tags.has('laravel'); // true, за O(1)
tags.delete('php');
[...tags]; // ['laravel', 'vue'] - порядок додавання зберігається
Найчастіший прийом - прибрати дублікати:
const unique = [...new Set(ids)];
Унікальність визначається як === (з винятком: NaN дорівнює NaN). Тому об'єкти порівнюються за посиланням - два різні об'єкти { id: 1 } обидва потраплять у множину. Для унікальності об'єктів за полем використовують Map з ключем-полем.
Методи множин (ES2025) - нарешті вбудовані операції теорії множин. Кожен повертає новий Set:
const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);
a.union(b); // {1, 2, 3, 4}
a.intersection(b); // {2, 3}
a.difference(b); // {1} - є в a, немає в b
a.symmetricDifference(b); // {1, 4} - є лише в одній з множин
new Set([2]).isSubsetOf(a); // true
a.isSupersetOf(new Set([1])); // true
a.isDisjointFrom(new Set([9])); // true - спільних елементів немає
Практичні застосування:
- синхронізація зв'язків: які теги додати й які прибрати при збереженні форми:
const toAttach = selected.difference(current);
const toDetach = current.difference(selected);
Це те, що в Laravel робить sync() для зв'язків «багато-до-багатьох».
- права:
required.isSubsetOf(userPermissions)- чи має користувач усі потрібні права; - швидкі перевірки належності у фільтрах замість
array.includesу циклі.
Аргументом нових методів може бути не лише Set, а будь-який об'єкт із size, has() і keys() - наприклад, Map (порівнюються ключі). Масив напряму не підходить: a.union([4]) кине помилку, потрібно a.union(new Set([4])).
Підтримка: усі сучасні браузери з 2024 року, Node.js 22+.
У звичайній функції this визначається в момент виклику, а не там, де функцію написано:
obj.method()-thisцеobj;- просто
fn()-undefinedу строгому режимі (у модулях і класах він завжди строгий), глобальний об'єкт - у нестрогому; new Fn()- новий об'єкт;fn.call(ctx),fn.apply(ctx),fn.bind(ctx)- явно заданийctx;- в обробнику
addEventListener- елемент, на якому висить обробник.
Типова пастка - метод, переданий як колбек:
class Timer {
seconds = 0;
tick() { this.seconds++; }
}
const timer = new Timer();
setInterval(timer.tick, 1000); // this === undefined -> TypeError
Метод «відірвався» від об'єкта, і викликається вже як просто функція.
Стрілкова функція не має власного this - вона бере this з місця, де її створено (лексично), і змінити його не можна навіть через call чи bind:
setInterval(() => timer.tick(), 1000); // працює
// або поле-стрілка в класі:
class Timer2 { seconds = 0; tick = () => { this.seconds++; }; }
Ще відмінності стрілкових функцій: немає власного arguments, їх не можна викликати через new, вони не мають prototype.
Коли НЕ стрілка: методи об'єкта, яким потрібен this цього об'єкта ({ name: 'x', greet: () => this.name } не працює), і обробники, де потрібен this як елемент.
Усі три дозволяють явно задати this для звичайної функції:
fn.call(ctx, a, b)- викликає функцію одразу, аргументи передаються через кому.fn.apply(ctx, [a, b])- те саме, але аргументи - масивом.fn.bind(ctx, a)- не викликає, а повертає нову функцію з назавжди прив'язанимthis(і, за бажанням, частиною аргументів).
function greet(greeting, punctuation) {
return `${greeting}, ${this.name}${punctuation}`;
}
const user = { name: 'Оля' };
greet.call(user, 'Привіт', '!'); // 'Привіт, Оля!'
greet.apply(user, ['Вітаю', '.']); // 'Вітаю, Оля.'
const hello = greet.bind(user, 'Привіт');
hello('?'); // 'Привіт, Оля?'
Де це трапляється:
bind- щоб передати метод як колбек, не загубившиthis:button.addEventListener('click', this.onClick.bind(this)). Сьогодні частіше пишуть стрілкову функцію.- Часткове застосування:
const double = multiply.bind(null, 2). applyісторично використовували, щоб передати масив як аргументи:Math.max.apply(null, numbers). Тепер є spread:Math.max(...numbers).- Позичання методів:
Array.prototype.slice.call(arguments)- тепер замість цьогоArray.from().
Пастки: функцію, прив'язану через bind, повторний bind уже не перевизначить. А на стрілкові функції call, apply і bind щодо this не впливають узагалі.
IIFE (immediately invoked function expression) - функція, яку оголошують і одразу викликають:
(function () {
const secret = 'видно лише тут';
// ...
})();
(() => {
// те саме стрілковою функцією
})();
Дужки навколо функції потрібні, щоб рушій сприйняв її як вираз, а не як оголошення функції (яке не можна одразу викликати).
Навіщо їх використовували:
- ізоляція області видимості. До ES-модулів усі скрипти на сторінці ділили одну глобальну область. Змінні з
varна верхньому рівні ставали властивостямиwindowі конфліктували між бібліотеками. IIFE створювала приватну область; - шаблон «модуль» - приватний стан плюс публічний інтерфейс:
const counter = (function () {
let count = 0;
return {
increment: () => ++count,
current: () => count,
};
})();
- копія значення в циклі з
var- до появиletкожна ітерація ділила одну змінну.
Що змінилося:
- ES-модулі мають власну область видимості: змінні верхнього рівня модуля не глобальні. Vite, вбудовані
<script type="module">- IIFE для ізоляції не потрібні; letіconstмають блокову область видимості - досить фігурних дужок;- замикання для приватного стану тепер часто замінюють приватні поля класів (
#count).
Де IIFE досі трапляються:
- асинхронний код на верхньому рівні звичайного скрипта (не модуля), де немає top-level
await:
(async () => {
const config = await loadConfig();
start(config);
})();
- результат збирання. Збирачі пакують код для старих оточень і бібліотеки у формат IIFE (наприклад,
format: 'iife'у Rollup/Vite) - так вони підключаються одним<script>без конфліктів; - ініціалізація константи складною логікою в одному виразі (хоча зазвичай читабельніша окрема функція).
Пастка: рядок перед IIFE без крапки з комою. const a = b і на наступному рядку (function () {})() рушій прочитає як виклик b(...). Звідси звичка ставити ; перед IIFE у коді без крапок з комою.
Змінні let, const (і класи) теж «піднімаються» на початок своєї області видимості, але не ініціалізуються. Від початку блоку до рядка оголошення змінна існує, але звертання до неї кидає ReferenceError. Цей проміжок називають тимчасовою мертвою зоною (temporal dead zone).
{
console.log(price); // ReferenceError: Cannot access 'price' before initialization
let price = 100;
}
З var поведінка інша - змінна ініціалізується як undefined, і помилка тихо проходить:
console.log(total); // undefined
var total = 100;
Чому це «тимчасова», а не «просторова» зона. Важливий момент виконання, а не позиція в тексті:
function showPrice() {
console.log(price); // функцію оголошено вище за let...
}
let price = 100;
showPrice(); // ...але викликано після ініціалізації - працює
Неочевидні випадки:
typeofне рятує. Для неоголошеної змінноїtypeof xповертає'undefined', але для змінної в TDZ - кидаєReferenceError;- затінення:
const limit = 10;
function check() {
console.log(limit); // ReferenceError: внутрішня limit у TDZ
const limit = 20;
}
Внутрішнє оголошення «закриває» зовнішнє для всього блоку, а не лише від свого рядка;
- параметри за замовчуванням:
function f(a = b, b = 1) {}- при викликуf()параметрbще в TDZ; - класи:
new User()до рядкаclass User {}теж кидає помилку, на відміну від функцій.
Навіщо це зроблено так:
- помилка доступу до неініціалізованої змінної видна одразу, а не перетворюється на загадкове
undefined; constповинна мати рівно одне значення - побачити її «до» присвоєння було б порушенням цього правила.
Практичний висновок: оголошувати змінні до використання, на початку блоку чи безпосередньо перед ним. Помилки TDZ найчастіше трапляються при циклічних імпортах модулів: модуль звертається до експортованого const іншого модуля, який ще не встиг виконатися.
Усі числа в JavaScript (крім BigInt) - 64-бітні числа з плаваючою комою за стандартом IEEE 754. Більшість десяткових дробів, як 0.1, у двійковій системі - нескінченні дроби, тож зберігаються з крихітною похибкою.
0.1 + 0.2; // 0.30000000000000004
0.1 + 0.2 === 0.3; // false
1.005 * 100; // 100.49999999999999
Це не баг JavaScript - так само поводяться float у PHP, Python, Java.
Як порівнювати дроби: з допуском:
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON; // true
Як рахувати гроші:
- У найменших одиницях - копійках, як цілі числа:
1999замість19.99. Цілі числа доNumber.MAX_SAFE_INTEGER(≈ 9 квадрильйонів) представлені точно. - Округлення - явне й в одному місці:
Math.round(price * quantity). - Для складних розрахунків (відсотки, курси, розподіл) - бібліотеки десяткової арифметики (
decimal.js,big.js,dinero.js). - Показ користувачу - через
Intl.NumberFormat:
new Intl.NumberFormat('uk-UA', { style: 'currency', currency: 'UAH' }).format(1999 / 100);
// '19,99 ₴'
toFixed() - для показу, не для розрахунків: він повертає рядок і сам округлює з тією ж похибкою ((1.005).toFixed(2) дає '1.00').
У логічному контексті (if, &&, ||, !, тернарний оператор) кожне значення перетворюється на true чи false. Хибних значень небагато:
false, 0, -0, 0n, '' (порожній рядок), null, undefined, NaN.
Усе інше - істинне, і саме тут сюрпризи:
Boolean('0'); // true - непорожній рядок
Boolean('false'); // true
Boolean([]); // true - порожній масив
Boolean({}); // true - порожній об'єкт
Де це підводить:
- Нуль як валідне значення:
function setVolume(level) {
if (!level) level = 50; // гучність 0 перетвориться на 50
}
Правильно: level ??= 50 чи явна перевірка level === undefined.
- Перевірка порожнього масиву:
if (items)завжди істинне. Потрібноif (items.length > 0). - Рендер у React:
{count && <Badge />}приcount === 0виведе на сторінку0. Правильно:{count > 0 && <Badge />}. - Рядки з форми: значення з
<input>завжди рядок, і'0'- істинний.
!!value чи Boolean(value) явно перетворюють значення на логічне - корисно, коли функція має повертати саме true/false.
NaN (Not a Number) - спеціальне значення типу number, яке означає результат невизначеної чи неможливої числової операції:
typeof NaN; // 'number'
Number('abc'); // NaN
0 / 0; // NaN
Math.sqrt(-1); // NaN
undefined + 1; // NaN
NaN не дорівнює нічому, навіть собі. Так визначено стандартом IEEE 754 для чисел з рухомою комою:
NaN === NaN; // false
NaN == NaN; // false
const x = NaN;
x !== x; // true - єдине значення з такою властивістю
Тому if (value === NaN) ніколи не спрацює.
Як перевіряти правильно:
Number.isNaN(value); // true лише для справжнього NaN
Object.is(value, NaN); // теж true
Не плутати з глобальною isNaN(). Вона спершу перетворює аргумент на число, тож відповідає на інше питання - «чи стане це NaN після перетворення»:
isNaN('abc'); // true - 'abc' перетворився на NaN
Number.isNaN('abc'); // false - це рядок, а не NaN
isNaN(undefined); // true
isNaN(''); // false - '' перетворюється на 0
Для перевірки «чи є це коректним числом» найзручніше Number.isFinite(value): false для NaN, Infinity і будь-яких нечислових типів.
NaN «заражає» обчислення: будь-яка арифметика з NaN дає NaN. Одне некоректне значення в масиві перетворить суму на NaN:
[10, 20, Number('')].reduce((a, b) => a + b); // 30 ('' дає 0)
[10, 20, Number('x')].reduce((a, b) => a + b); // NaN
Звідси правило: перевіряти дані на вході (при парсингу форми чи відповіді API), а не шукати, де з'явився NaN, у кінці розрахунку.
Різні методи по-різному бачать NaN:
[NaN].includes(NaN)→true(алгоритм SameValueZero);[NaN].indexOf(NaN)→-1(строга рівність);new Set([NaN, NaN]).size→1;JSON.stringify(NaN)→'null'-NaNтихо перетворюється наnullпри відправці на сервер.
Питання рівня Middle з реальних технічних співбесід - 41 питання у 10 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 78 відкритих вакансій рівня Middle. Переглянути вакансії