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

Питання на співбесіді з 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.

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

По суті нічим: 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-модулях.

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

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

Порядок такий:

  1. виконується весь синхронний код до кінця;
  2. виконуються всі мікрозадачі (колбеки Promise, queueMicrotask);
  3. браузер може перемалювати сторінку;
  4. береться наступна макрозадача - зокрема колбек таймера.

Тому колбек Promise спрацьовує раніше за таймер з нульовою затримкою.

Затримка - мінімальна, а не точна. setTimeout(fn, 100) гарантує лише, що колбек запуститься не раніше ніж за 100 мс. Якщо головний потік зайнятий важким обчисленням 2 секунди, таймер спрацює через 2 секунди.

Інші обмеження:

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

Навіщо setTimeout(fn, 0) на практиці: відкласти роботу до моменту, коли поточна подія оброблена й DOM оновлено, або розбити довгу роботу на шматки, даючи браузеру перемальовувати сторінку між ними.

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

Колбек (функція зворотного виклику) - функція, яку передають іншій функції, щоб та викликала її пізніше:

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(...).

Докладніше в документації: Array.prototype.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 змінений стан зазвичай будують поверхневими копіями лише тих гілок, що змінилися, - це й дешевше, і дозволяє швидко порівнювати посилання.

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

Метод залежить від того, що шукаємо і що потрібно отримати.

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)

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

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.

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

Замикання - це функція разом зі змінними того місця, де її створено. Внутрішня функція «пам'ятає» змінні зовнішньої навіть після того, як зовнішня вже завершилася.

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 - не використовувати.

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

У 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 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 37 Middle 41 Senior 36

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