Питання на співбесіді з JavaScript
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
114 питань
Promise - об'єкт-обіцянка результату, який з'явиться пізніше: відповідь сервера, прочитаний файл, таймер. Замість того щоб передавати колбек, функція одразу повертає Promise, а результат на нього «підписують».
Три стани:
- pending - очікування, результату ще немає;
- fulfilled - виконано успішно, є значення;
- rejected - відхилено, є причина (зазвичай об'єкт
Error).
Перехід з pending відбувається один раз і назавжди: виконаний Promise не стане відхиленим.
fetch('/api/user')
.then((response) => response.json())
.then((user) => console.log(user.name))
.catch((error) => console.error('Не вдалося завантажити', error))
.finally(() => hideSpinner());
Що варто знати:
then()повертає новий Promise, тому виклики можна з'єднувати в ланцюжок. Значення, повернене зthen, стає результатом наступного кроку.- Один
catch()наприкінці ловить помилку з будь-якого попереднього кроку. - Promise без обробника помилки, який відхилився, дає
unhandledrejection- у Node.js це за замовчуванням завершує процес. fetchвідхиляє Promise лише при мережевій помилці. Відповідь 404 чи 500 - це виконаний Promise зresponse.ok === false.
По суті нічим: async/await - зручніший запис тих самих Promise. Асинхронний код читається зверху вниз, як синхронний.
// then()
function loadUser(id) {
return fetch(`/api/users/${id}`)
.then((r) => r.json())
.then((user) => user.name);
}
// async/await
async function loadUser(id) {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
return user.name;
}
Правила:
async-функція завжди повертає Promise.return 5у ній - цеPromise, що виконається з 5.awaitчекає Promise і повертає його значення. Якщо Promise відхилено,awaitкидає виняток.- Помилки ловлять звичайним
try/catch.
try {
const user = await loadUser(1);
} catch (error) {
showError(error.message);
}
Пастка - зайва послідовність:
const user = await loadUser(); // чекаємо
const posts = await loadPosts(); // і лише тоді починаємо друге
Якщо запити незалежні, їх запускають разом: const [user, posts] = await Promise.all([loadUser(), loadPosts()]);.
await на верхньому рівні (поза функцією) працює в ES-модулях.
setTimeout(fn, 0) означає не «виконай зараз», а «постав у чергу, щойно буде можливість». JavaScript у браузері виконується в одному потоці: поки працює поточний код, ніщо інше не запуститься.
console.log('1');
setTimeout(() => console.log('3'), 0);
Promise.resolve().then(() => console.log('2б'));
console.log('2');
// 1, 2, 2б, 3
Порядок такий:
- виконується весь синхронний код до кінця;
- виконуються всі мікрозадачі (колбеки Promise,
queueMicrotask); - браузер може перемалювати сторінку;
- береться наступна макрозадача - зокрема колбек таймера.
Тому колбек Promise спрацьовує раніше за таймер з нульовою затримкою.
Затримка - мінімальна, а не точна. setTimeout(fn, 100) гарантує лише, що колбек запуститься не раніше ніж за 100 мс. Якщо головний потік зайнятий важким обчисленням 2 секунди, таймер спрацює через 2 секунди.
Інші обмеження:
- вкладені таймери після п'яти рівнів отримують мінімальну затримку 4 мс;
- у фонових вкладках браузер пригальмовує таймери до разу на секунду і рідше - не можна покладатися на них для точного відліку часу;
- для анімацій є
requestAnimationFrame, синхронізований з перемальовуванням екрана.
Навіщо setTimeout(fn, 0) на практиці: відкласти роботу до моменту, коли поточна подія оброблена й DOM оновлено, або розбити довгу роботу на шматки, даючи браузеру перемальовувати сторінку між ними.
Колбек (функція зворотного виклику) - функція, яку передають іншій функції, щоб та викликала її пізніше:
button.addEventListener('click', () => console.log('клік'));
[1, 2, 3].map((n) => n * 2); // теж колбек, але синхронний
До появи Promise асинхронний код писали лише колбеками: «зроби запит і, коли буде відповідь, виклич цю функцію».
Пекло колбеків з'являється, коли кроки залежать один від одного:
getUser(id, (error, user) => {
if (error) return showError(error);
getOrders(user.id, (error, orders) => {
if (error) return showError(error);
getInvoice(orders[0].id, (error, invoice) => {
if (error) return showError(error);
render(invoice);
});
});
});
Проблеми:
- код росте вправо і погано читається;
- помилку треба перевіряти на кожному рівні окремо - легко пропустити;
- немає простого способу запустити кілька операцій паралельно й дочекатися всіх;
- функція може викликати колбек двічі чи жодного разу - і нічого вас від цього не захистить.
Те саме з Promise і async/await:
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
render(await getInvoice(orders[0].id));
} catch (error) {
showError(error);
}
Код читається зверху вниз, одна обробка помилок на всі кроки, а Promise гарантовано виконується чи відхиляється рівно один раз.
Колбеки нікуди не зникли: обробники подій, методи масивів, setTimeout - це колбеки, і для них вони доречні. Від колбеків відмовилися саме для одноразових асинхронних результатів.
Усі обходять масив і викликають функцію для кожного елемента:
map- новий масив тієї ж довжини з перетвореними елементами.filter- новий масив лише з тими елементами, для яких функція повернула істинне значення.reduce- згортає масив в одне значення: суму, об'єкт, інший масив.forEach- нічого не повертає (undefined), лише виконує дію для кожного елемента.
const orders = [{ total: 100, paid: true }, { total: 250, paid: false }, { total: 40, paid: true }];
orders.map((o) => o.total); // [100, 250, 40]
orders.filter((o) => o.paid); // два оплачені
orders.reduce((sum, o) => sum + o.total, 0); // 390
orders.forEach((o) => console.log(o.total)); // лише побічна дія
Типові помилки:
mapзаради побічної дії, без використання результату. Для цього єforEachчиfor...of.reduceбез початкового значення на порожньому масиві кидаєTypeError.- Перервати
forEachнеможливо (breakне працює). Якщо треба зупинитися, -for...of,some()чиfind().
Усі чотири не змінюють початковий масив (хіба що сам колбек зробить це навмисно), тож їх зручно з'єднувати в ланцюжок: orders.filter(...).map(...).
Об'єкти й масиви в змінних зберігаються за посиланням. const b = a - це не копія, а друге ім'я для того самого об'єкта.
Поверхнева копія копіює лише перший рівень. Вкладені об'єкти лишаються спільними:
const user = { name: 'Оля', address: { city: 'Київ' } };
const copy = { ...user }; // або Object.assign({}, user)
copy.name = 'Іра'; // user.name не змінився
copy.address.city = 'Львів'; // user.address.city теж став 'Львів'!
Глибока копія копіює всі рівні:
const deep = structuredClone(user);
deep.address.city = 'Одеса'; // user не зачеплено
structuredClone (вбудований у браузери й Node.js 17+) коректно копіює Date, Map, Set, вкладені масиви й навіть циклічні посилання. Не копіює функції, DOM-вузли й прототипи класів: екземпляр класу стане звичайним об'єктом.
Старий прийом JSON.parse(JSON.stringify(obj)) має купу обмежень: Date перетворюється на рядок, undefined, функції й Map зникають, циклічні посилання дають помилку.
На практиці глибока копія потрібна рідше, ніж здається: у React, Vue чи Redux змінений стан зазвичай будують поверхневими копіями лише тих гілок, що змінилися, - це й дешевше, і дозволяє швидко порівнювати посилання.
Метод залежить від того, що шукаємо і що потрібно отримати.
includes(value) - чи є значення в масиві, true/false. Для примітивів (рядки, числа):
const roles = ['admin', 'editor'];
roles.includes('admin'); // true
indexOf(value) - позиція першого входження або -1. Колись це був єдиний спосіб перевірки (indexOf(x) !== -1), тепер для перевірки є includes. Відмінність у краєвому випадку: includes знаходить NaN, indexOf - ні.
some(predicate) - чи є хоча б один елемент, що задовольняє умову. Для об'єктів, де шукаємо за властивістю:
const users = [{ id: 1, active: false }, { id: 2, active: true }];
users.some((user) => user.active); // true
find(predicate) - сам елемент (перший відповідний) або undefined; findIndex - його позиція або -1. Є й варіанти з кінця: findLast, findLastIndex.
const user = users.find((user) => user.id === 2);
every(predicate) - протилежність some: чи всі елементи задовольняють умову.
Типова помилка - includes з об'єктами:
users.includes({ id: 1, active: false }); // false
Об'єкти порівнюються за посиланням, а не за вмістом. Новий об'єкт з тими самими полями - інший об'єкт. Для об'єктів використовують some/find з умовою.
Продуктивність. Усі ці методи перебирають масив, тобто працюють за O(n). Якщо перевірок багато (наприклад, у циклі по іншому масиву), краще один раз побудувати Set чи Map:
const allowed = new Set(roles);
items.filter((item) => allowed.has(item.role)); // has - O(1)
Object.keys, Object.values, Object.entries повертають масиви власних перелічуваних властивостей об'єкта:
const prices = { coffee: 50, tea: 35 };
Object.keys(prices); // ['coffee', 'tea']
Object.values(prices); // [50, 35]
Object.entries(prices); // [['coffee', 50], ['tea', 35]]
for (const [name, price] of Object.entries(prices)) {
console.log(`${name}: ${price} грн`);
}
Оскільки це масиви, до них можна застосувати map, filter, reduce. А назад у об'єкт - через Object.fromEntries:
const withVat = Object.fromEntries(
Object.entries(prices).map(([name, price]) => [name, price * 1.2]),
);
for...in перебирає ключі, але разом з успадкованими перелічуваними властивостями з ланцюжка прототипів:
for (const key in prices) {
if (Object.hasOwn(prices, key)) { /* ... */ }
}
Для звичайних об'єктів успадкованих перелічуваних властивостей зазвичай немає, але якщо якась бібліотека додала метод в Object.prototype, for...in його побачить. Тому в сучасному коді частіше пишуть Object.entries + for...of.
Не плутати for...in і for...of:
for...in- ключі (для масиву - індекси як рядки'0','1');for...of- значення ітерованих об'єктів (масиви, рядки,Map,Set). Звичайний об'єкт не ітерований,for...ofпо ньому кинеTypeError.
Порядок ключів визначений: спершу цілочисельні ключі за зростанням, потім рядкові в порядку додавання.
Object.keys({ b: 1, 2: 1, a: 1, 1: 1 }); // ['1', '2', 'b', 'a']
Це пастка для об'єктів, де ключами є id: { 10: ..., 2: ... } перебереться як 2, 10, а не в порядку додавання. Якщо порядок важливий, краще Map чи масив.
Символьні ключі жоден з цих способів не повертає - для них є Object.getOwnPropertySymbols чи Reflect.ownKeys.
Замикання - це функція разом зі змінними того місця, де її створено. Внутрішня функція «пам'ятає» змінні зовнішньої навіть після того, як зовнішня вже завершилася.
function makeCounter() {
let count = 0;
return () => ++count;
}
const next = makeCounter();
next(); // 1
next(); // 2
// count недоступний ззовні, але живе, поки живе функція next
Де замикання трапляються щодня:
- Обробники подій і колбеки - вони бачать змінні з коду, що їх створив.
- Приватний стан - як
countвище: змінити його можна лише через функцію. - Фабрики функцій -
const add5 = makeAdder(5). - Хуки React - кожен рендер створює нові функції, що замикають значення стану саме цього рендеру.
Класична пастка з var у циклі:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i)); // 3, 3, 3
}
var має одну змінну на всю функцію, і до моменту спрацювання таймерів вона вже дорівнює 3. З let у кожної ітерації своя змінна, і виведеться 0, 1, 2.
Нюанс для пам'яті: поки замикання живе (наприклад, обробник не знято), живуть і всі змінні, які воно використовує, разом з великими об'єктами в них.
var |
let |
const |
|
|---|---|---|---|
| Область видимості | функція | блок {} |
блок {} |
| Повторне присвоєння | так | так | ні |
| Повторне оголошення | так | ні | ні |
| Доступ до оголошення | undefined |
помилка (TDZ) | помилка (TDZ) |
if (true) {
var a = 1;
let b = 2;
}
a; // 1 - var «протікає» з блоку
b; // ReferenceError
Підняття (hoisting): оголошення всіх трьох обробляються до виконання коду, але по-різному. var одразу отримує undefined, тож звернення до нього до рядка оголошення не дає помилки - лише тихий undefined. let і const до оголошення перебувають у «тимчасовій мертвій зоні» (TDZ): звернення кидає ReferenceError. Друге поведінка краща: помилка видна одразу.
const не робить значення незмінним - лише забороняє переприсвоїти змінну:
const user = { name: 'Оля' };
user.name = 'Іра'; // можна: змінюємо вміст об'єкта
user = {}; // TypeError
Для незмінності вмісту - Object.freeze() (поверхнево).
Сучасне правило: const за замовчуванням, let - коли значення справді змінюється, var - не використовувати.
У JavaScript функцію можна створити кількома способами, і вони відрізняються не лише синтаксисом.
// оголошення функції (function declaration)
function add(a, b) {
return a + b;
}
// функціональний вираз (function expression)
const multiply = function (a, b) {
return a * b;
};
// стрілкова функція (arrow function)
const square = (n) => n * n;
1. Підняття (hoisting). Оголошення функції доступне до рядка, де воно написане, - рушій «піднімає» його на початок області видимості:
greet(); // працює
function greet() { console.log('Привіт'); }
sayHi(); // TypeError: sayHi is not a function (з var)
var sayHi = function () {};
wave(); // ReferenceError (з const/let)
const wave = () => {};
Вирази й стрілкові функції - це значення, присвоєні змінним, тож діють правила змінних: з const/let до ініціалізації звертатися не можна.
2. this. Звичайні функції отримують this залежно від способу виклику. Стрілкові функції власного this не мають - беруть його з місця, де їх створено. Тому стрілки зручні в колбеках усередині методів класу, але не підходять як методи об'єкта, яким потрібен this цього об'єкта.
3. Інші відмінності стрілкових функцій:
- немає об'єкта
arguments(використовують rest-параметри...args); - не можна викликати з
new- вони не конструктори; - короткий запис з неявним поверненням:
(n) => n * 2. Щоб повернути об'єкт, його беруть у дужки:() => ({ id: 1 }).
Що обирати:
- оголошення функції - для «іменованих» функцій верхнього рівня модуля: їх зручно читати й шукати, і порядок у файлі не важливий;
- стрілкові функції - для колбеків (
map,filter, обробники подій) і коротких допоміжних функцій; - методи класів і об'єктів - звичайний синтаксис методу
save() { ... }.
Іменований функціональний вираз (const f = function factorial(n) { ... }) має ім'я, видиме лише всередині, - для рекурсії й читабельних стеків помилок.
Параметри за замовчуванням задають значення, якщо аргумент не передано:
function formatPrice(amount, currency = 'грн', digits = 2) {
return `${amount.toFixed(digits)} ${currency}`;
}
formatPrice(100); // '100.00 грн'
formatPrice(100, '$'); // '100.00 $'
Значення за замовчуванням спрацьовує лише для undefined, але не для null, 0 чи порожнього рядка:
formatPrice(100, undefined); // '100.00 грн'
formatPrice(100, null); // '100.00 null'
Значення обчислюється при кожному виклику і може посилатися на попередні параметри:
function createUser(name, slug = name.toLowerCase(), createdAt = new Date()) { /* ... */ }
Кожен виклик отримає нову дату чи новий масив (items = []) - на відміну від Python, де значення за замовчуванням створюється один раз.
Rest-параметри збирають «решту» аргументів у справжній масив:
function sum(...numbers) {
return numbers.reduce((total, n) => total + n, 0);
}
sum(1, 2, 3); // 6
function log(level, ...messages) {
console[level](...messages);
}
Rest-параметр може бути лише один і лише останнім.
Рядом - старий об'єкт arguments, псевдомасив усіх переданих аргументів. Він не має методів масиву, недоступний у стрілкових функціях і погано читається. У сучасному коді його замінюють rest-параметри.
Іменовані параметри через деструктуризацію - зручний прийом для функцій з багатьма необов'язковими налаштуваннями:
function fetchUsers({ page = 1, perPage = 20, sort = 'name' } = {}) { /* ... */ }
fetchUsers({ sort: 'created_at' });
fetchUsers(); // працює завдяки "= {}"
Порядок аргументів більше не важливий, а виклик сам себе документує. Без = {} наприкінці виклик без аргументів кине помилку: деструктуризувати undefined неможливо.
function.length рахує лише параметри до першого зі значенням за замовчуванням і без rest - деякі бібліотеки (каррування, DI) на це покладаються.
===(строга рівність) -true, лише якщо однакові і тип, і значення. Без перетворень.==(нестрога) спершу приводить операнди до спільного типу за досить заплутаними правилами.
1 === '1'; // false
1 == '1'; // true
0 == ''; // true
0 == '0'; // true
'' == '0'; // false - рівність не транзитивна
null == undefined; // true
null == 0; // false
[] == false; // true
NaN === NaN; // false
Правило: завжди === і !==. Більшість лінтерів (правило eqeqeq) так і вимагає.
Єдиний поширений виняток: value == null - коротка перевірка «null або undefined» одночасно. Але й тут читабельніше value === null || value === undefined або оператор ??.
Ще нюанси:
NaNне дорівнює нічому, навіть собі. Перевірка -Number.isNaN(x).- Об'єкти й масиви порівнюються за посиланням:
[1] === [1]-false, бо це два різні масиви. Object.is(a, b)- як===, алеObject.is(NaN, NaN)даєtrue, аObject.is(0, -0)-false.
undefined- «значення немає, бо його не задали»: неініціалізована змінна, відсутня властивість, параметр без аргументу, результат функції безreturn.null- «значення свідомо немає»: його присвоюють явно.
let a; // undefined
const user = {};
user.phone; // undefined
const selected = null; // свідомо «нічого не вибрано»
typeof undefined; // 'undefined'
typeof null; // 'object' - історична помилка мови
|| проти ??:
a || bповертаєb, якщоaбудь-яке хибне значення:0,'',false,NaN,null,undefined.a ?? bповертаєb, лише якщоa-nullабоundefined.
const page = 0;
page || 1; // 1 - помилка: нуль - валідне значення
page ?? 1; // 0
const title = '';
title || 'Без назви'; // 'Без назви'
title ?? 'Без назви'; // ''
Правило: для значень за замовчуванням - ??, якщо 0, '' чи false мають бути збережені.
Поруч живе ?. (optional chaining): user?.address?.city повертає undefined, якщо будь-яка ланка null чи undefined, замість TypeError. І присвоєння ??=: options.timeout ??= 5000.
У JavaScript сім примітивних типів і один об'єктний:
| Тип | Приклад |
|---|---|
string |
'Київ' |
number |
42, 3.14, NaN, Infinity |
bigint |
9007199254740993n |
boolean |
true, false |
undefined |
змінна без значення |
null |
«навмисно порожньо» |
symbol |
Symbol('id') |
object |
об'єкти, масиви, функції, дати, Map... |
Примітиви незмінні й передаються за значенням:
let a = 'текст';
let b = a;
b += '!';
a; // 'текст' - зміна b не торкнулася a
const s = 'привіт';
s.toUpperCase(); // повертає НОВИЙ рядок, s не змінився
Об'єкти передаються за посиланням: змінна зберігає не сам об'єкт, а посилання на нього.
const user = { name: 'Оля' };
const same = user;
same.name = 'Іра';
user.name; // 'Іра' - це той самий об'єкт
function rename(obj) { obj.name = 'Петро'; }
rename(user); // змінює оригінал
Порівняння теж відрізняється: примітиви порівнюються за значенням, об'єкти - за посиланням:
'a' === 'a'; // true
{} === {}; // false - два різні об'єкти
[1, 2] === [1, 2]; // false
Як у примітива є методи? 'текст'.length працює, бо рушій на мить «загортає» примітив у тимчасовий об'єкт-обгортку (String), викликає метод і викидає обгортку. Створювати обгортки вручну (new String('a')) не варто: new String('a') === 'a' - false, а new Boolean(false) у умові - істинний.
Чому це важливо на практиці:
- зміна об'єкта в одній частині коду видна всюди, де є посилання на нього, - джерело багатьох помилок. Звідси звичка створювати копії (
{ ...user },toSorted()) замість змін на місці; - реактивність Vue і стан React покладаються на це: React порівнює стан за посиланням і не помітить зміни всередині того самого об'єкта;
typeofповертає'object'дляnullі масивів - історична особливість, тому для перевірки масиву єArray.isArray.
Питання з реальних технічних співбесід - 114 питань у 10 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Браузерні API й мережа 12 Сучасний синтаксис 12 Асинхронність 12 Типи й приведення 12 DOM і події 12 Масиви й об'єкти 12 Функції й замикання 12 Тестування 10
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії