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

Питання на співбесіді: Помилки й налагодження

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

10 питань

try - блок коду, у якому може статися помилка. catch - що робити, якщо вона сталася. finally - що виконати в будь-якому разі.

function loadSettings() {
  try {
    const raw = localStorage.getItem('settings');
    return JSON.parse(raw) ?? {};
  } catch (error) {
    console.warn('Пошкоджені налаштування, використовуємо типові', error);
    return {};
  } finally {
    hideLoader();   // виконається і при успіху, і при помилці
  }
}

Як це працює:

  • коли в try кидається виняток, решта блоку пропускається і керування переходить у catch;
  • у catch доступний об'єкт помилки: error.name, error.message, error.stack;
  • якщо помилки не було, catch не виконується;
  • finally виконується завжди - навіть якщо в try чи catch був return чи нова помилка.

Змінна в catch необов'язкова:

try {
  JSON.parse(input);
} catch {
  showError('Некоректний JSON');
}

Кинути власну помилку - throw:

if (!email.includes('@')) {
  throw new Error('Некоректна електронна адреса');
}

Кидати варто об'єкти Error (або нащадки), а не рядки: throw 'помилка' не має стеку викликів, і налагоджувати таке набагато важче.

Типові помилки новачків:

  • ловити все й мовчати: catch (e) {} - помилка зникла, а програма працює з неправильними даними. Як мінімум - записати в лог;
  • занадто великий try: обгорнути всю функцію, а потім не розуміти, який рядок кинув виняток. Краще обгортати лише те, що справді може впасти і що ви знаєте, як обробити;
  • очікувати, що try зловить помилку в колбеку setTimeout чи обробнику події - не зловить, бо колбек виконується пізніше, коли try уже завершено;
  • асинхронний код без await: try { fetchData() } catch не зловить відхилений Promise. Потрібно try { await fetchData() } catch.

finally для прибирання: зняти індикатор завантаження, розблокувати кнопку, закрити з'єднання - те, що має статися незалежно від результату.

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

Усі вбудовані помилки - нащадки Error. За типом зазвичай одразу зрозуміло, що пішло не так.

TypeError - операція над значенням не того типу. Найчастіша помилка у фронтенді:

const user = null;
user.name;              // TypeError: Cannot read properties of null (reading 'name')
undefined();            // TypeError: undefined is not a function
const PI = 3.14; PI = 3; // TypeError: Assignment to constant variable

Зазвичай означає, що дані прийшли не такі, як очікувалося: API повернув null, елемент не знайдено на сторінці, змінну не ініціалізовано.

ReferenceError - звернення до змінної, якої не існує або яка ще не ініціалізована:

console.log(totla);     // ReferenceError: totla is not defined (друкарська помилка)
console.log(x); let x = 1;   // ReferenceError: Cannot access 'x' before initialization

SyntaxError - код чи дані неможливо розібрати:

JSON.parse('{ name: "Оля" }');   // SyntaxError: ключі в JSON мають бути в лапках

Синтаксична помилка в самому файлі скрипта не дає йому виконатися взагалі - жоден рядок файлу не спрацює.

RangeError - значення поза допустимими межами:

new Array(-1);              // RangeError: Invalid array length
(1.5).toFixed(200);         // RangeError: toFixed() digits argument must be between 0 and 100
function f() { f(); } f();  // RangeError: Maximum call stack size exceeded

URIError - некоректний URI: decodeURIComponent('%').

AggregateError - кілька помилок разом, наприклад від Promise.any, коли відхилилися всі Promise (error.errors - масив причин).

Як розрізняти в коді:

try {
  data = JSON.parse(text);
} catch (error) {
  if (error instanceof SyntaxError) {
    showError('Сервер повернув некоректні дані');
  } else {
    throw error;   // невідому помилку - далі, а не ковтати
  }
}

Читати повідомлення уважно: Cannot read properties of undefined (reading 'map') каже не про map, а про те, що об'єкт перед .map - undefined. Шукати треба, звідки взялося це undefined.

Стек викликів (error.stack) показує, через які функції пройшов виконання до помилки, - з номерами рядків. У DevTools по ньому можна клікнути й перейти до коду.

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

console.log - найпростіший спосіб, але для складних помилок значно ефективніше зупинити виконання й подивитися на стан програми зсередини.

Оператор debugger у коді зупиняє виконання в цьому місці, якщо відкрито DevTools:

function calculateTotal(items) {
  debugger;   // виконання зупиниться тут
  return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}

Без відкритих DevTools оператор нічого не робить. Але в репозиторій його не комітять - лінтери на це скаржаться.

Точки зупинки в DevTools (вкладка Sources) - те саме без зміни коду: клік по номеру рядка. Види:

  • умовна - зупиняється лише якщо вираз істинний (item.price < 0). Незамінно в циклі на тисячу ітерацій;
  • logpoint - нічого не зупиняє, а друкує вираз у консоль. Як console.log, але без зміни коду й перезбирання;
  • на подію - зупинитися на будь-якому click, submit, таймері (панель Event Listener Breakpoints);
  • на зміну DOM - зупинитися, коли елемент змінюється чи видаляється (контекстне меню елемента у вкладці Elements). Відповідає на питання «хто змінює цей елемент?»;
  • на запит - коли URL запиту містить рядок (XHR/fetch Breakpoints);
  • на винятки - «Pause on exceptions», включно з перехопленими.

Після зупинки:

  • Scope - значення всіх змінних у поточній функції й замиканнях;
  • Call Stack - як ми сюди потрапили; клік на попередню функцію показує її стан;
  • Watch - вирази, що перераховуються на кожному кроці;
  • консоль працює в контексті зупиненої функції - можна викликати функції й змінювати змінні;
  • кроки: Step over (наступний рядок), Step into (увійти у функцію), Step out (вийти з неї), Resume.

Корисні дрібниці:

  • $0 у консолі - поточний виділений у Elements елемент;
  • «Blackbox script» / «Add to ignore list» - не заходити в код бібліотек (Vue, Alpine) при покроковому виконанні;
  • вкладка Network - запити, відповіді, статуси; «Copy as fetch» для повторення запиту в консолі;
  • у Livewire-проєктах корисно дивитися запити livewire/update у Network - там видно, що компонент відправив і що отримав.

Мініфікований код налагоджувати неможливо - для цього потрібні source maps, які у Vite в режимі розробки є завжди.

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

Власні класи помилок дають змогу розрізняти помилки за типом, а не за текстом повідомлення, і передавати додаткові дані.

class HttpError extends Error {
  constructor(response, options) {
    super(`HTTP ${response.status} для ${response.url}`, options);
    this.name = 'HttpError';
    this.status = response.status;
  }
}

class ValidationError extends Error {
  constructor(errors) {
    super('Дані не пройшли перевірку');
    this.name = 'ValidationError';
    this.errors = errors;   // { email: ['Поле обов'язкове'] }
  }
}

this.name варто задавати явно: саме воно відображається в консолі й стеку (ValidationError: Дані не пройшли перевірку). Без нього всі помилки виглядатимуть як Error.

Обробка за типом:

async function request(url, options) {
  const response = await fetch(url, options);
  if (response.status === 422) {
    throw new ValidationError((await response.json()).errors);
  }
  if (!response.ok) {
    throw new HttpError(response);
  }
  return response.json();
}

try {
  await request('/api/orders', { method: 'POST', body });
} catch (error) {
  if (error instanceof ValidationError) {
    showFieldErrors(error.errors);   // формат помилок валідації Laravel
  } else if (error instanceof HttpError && error.status === 403) {
    showError('Недостатньо прав');
  } else {
    throw error;
  }
}

cause (ES2022) - посилання на початкову помилку, що спричинила поточну. Передається другим аргументом конструктора:

try {
  config = JSON.parse(await readConfig());
} catch (error) {
  throw new Error('Не вдалося завантажити конфігурацію', { cause: error });
}

Навіщо: на вищому рівні потрібне зрозуміле повідомлення («не вдалося завантажити конфігурацію»), але без втрати технічної причини («Unexpected token } in JSON at position 42»). Раніше доводилося або губити початкову помилку, або склеювати повідомлення рядками. Тепер ланцюжок причин зберігається повністю, і DevTools та Sentry показують його.

Це аналог previous у винятках PHP (new RuntimeException('...', previous: $e)).

Що варто знати:

  • instanceof працює з класами, що наслідують Error, - у сучасних середовищах без хитрощів (раніше Babel ламав це при компіляції в ES5);
  • не варто створювати клас на кожну дрібницю - лише там, де код по-різному реагує на помилки;
  • помилки, що перетинають межі (з воркера, з postMessage, з серверу), втрачають клас - при серіалізації залишаються лише name і message. Тип доведеться відновлювати за name чи кодом.

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

Якщо Promise відхилено, а обробника помилки (catch, другого аргументу then, try навколо await) немає, помилка стає необробленою.

У браузері сторінка продовжує працювати, але:

  • у консолі з'являється Uncaught (in promise) Error: ...;
  • на window спрацьовує подія unhandledrejection.

У Node.js з версії 15 необроблене відхилення завершує процес з помилкою. Сервер на Node, де забули catch, падає від першого ж збою мережі.

Глобальні обробники в браузері:

// необроблені винятки в синхронному коді й колбеках
window.addEventListener('error', (event) => {
  report({
    message: event.message,
    source: event.filename,
    line: event.lineno,
    stack: event.error?.stack,
  });
});

// необроблені відхилення Promise
window.addEventListener('unhandledrejection', (event) => {
  report({ message: String(event.reason), stack: event.reason?.stack });
  // event.preventDefault() - прибрати повідомлення з консолі
});

event.reason - те, з чим відхилено Promise (зазвичай об'єкт Error, але може бути будь-що).

Подія rejectionhandled спрацьовує, якщо обробник додали пізніше, ніж браузер зафіксував відхилення як необроблене. Буває при «лінивому» підключенні catch.

Що ці обробники дають і чого ні:

  • це останній рубіж для моніторингу, а не спосіб обробляти помилки. Зловити тут помилку й «продовжити» неможливо: функцію, що впала, уже не повернути;
  • саме так працюють Sentry, Flare та інші системи: підписуються на ці події й надсилають звіти на сервер;
  • помилки зі скриптів іншого домену (CDN) приходять як Script error. без деталей - браузер приховує їх з міркувань безпеки. Деталі з'являться, якщо скрипт підключено з crossorigin="anonymous" і сервер віддає заголовок CORS.

Типові джерела необроблених відхилень:

  • fetch(...).then(...) без catch наприкінці ланцюжка;
  • виклик async-функції без await («запустив і забув»): saveDraft(); в обробнику події;
  • Promise.all, у якому відхилився один Promise, - решта відхилень після першого вже ігноруються, але перше має бути оброблене.

Правило: кожен ланцюжок Promise має закінчуватися обробкою помилки, а «запущені й забуті» async-виклики - явним .catch(report). Лінтер (@typescript-eslint/no-floating-promises) знаходить такі місця автоматично.

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

Об'єкт console має значно більше, ніж log, і правильний метод часто економить час налагодження.

Рівні повідомлень - фільтруються в DevTools:

console.debug('деталі');     // приховано за замовчуванням (рівень Verbose)
console.info('інформація');
console.warn('попередження');  // жовте, зі стеком
console.error('помилка');      // червоне, зі стеком

console.table - масив об'єктів таблицею:

console.table(users, ['id', 'name', 'email']);   // лише вибрані колонки

console.group / groupCollapsed / groupEnd - вкладені групи повідомлень:

console.groupCollapsed(`Замовлення ${order.id}`);
console.log('товари', order.items);
console.log('доставка', order.shipping);
console.groupEnd();

console.time / timeLog / timeEnd - вимір часу:

console.time('рендер');
renderList(items);
console.timeEnd('рендер');   // рендер: 48.2 ms

console.count - скільки разів виконався рядок (корисно для пошуку зайвих рендерів):

console.count('render ProductCard');

console.trace - стек викликів у цьому місці: відповідає на питання «хто викликав цю функцію?».

console.assert(condition, ...) - пише помилку, лише якщо умова хибна:

console.assert(total >= 0, 'Від\'ємна сума', { total, items });

console.dir(element) - DOM-елемент як об'єкт з властивостями, а не як HTML.

Підстановки й стилі:

console.log('%cУвага', 'color: red; font-weight: bold', 'звичайний текст');

Пастка - лінивий показ об'єктів. console.log(obj) показує об'єкт за посиланням: коли ви розгорнете його в консолі пізніше, побачите поточний стан, а не стан на момент виклику. Якщо об'єкт змінюється, логуйте знімок: console.log(structuredClone(obj)) чи JSON.stringify(obj).

Утиліти, доступні лише в консолі DevTools: $0 (виділений елемент), $$('selector') (масив елементів), copy(obj) (скопіювати в буфер), monitorEvents(window, 'resize').

На продакшені console.* у коді - шум і інколи витік даних. Збирач може прибрати їх (у Vite - опція esbuild.drop: ['console', 'debugger']), а для справжніх помилок потрібна система моніторингу, а не консоль користувача.

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

try...catch ловить лише помилки, що виникають під час виконання блоку try - поки цей код на стеку викликів.

try {
  setTimeout(() => {
    null.x;   // помилка тут
  }, 0);
} catch (error) {
  console.log('спіймано');   // НЕ виконається
}

Чому: setTimeout лише реєструє колбек і одразу повертає керування. Блок try завершується без помилок. Колбек виконується пізніше, з власного стеку, коли цикл подій дійде до таймера. Зовнішнього try на цьому стеку вже немає. Помилка стає необробленою й іде в глобальний обробник window.onerror.

Те саме для:

  • обробників подій (addEventListener);
  • колбеків старих асинхронних API;
  • then-колбеків Promise, якщо не підключено catch.

Як правильно:

1. try всередині колбека:

setTimeout(() => {
  try {
    riskyWork();
  } catch (error) {
    report(error);
  }
}, 0);

2. Перетворити на Promise і використовувати await:

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

try {
  await sleep(100);
  riskyWork();         // тепер у тій самій async-функції - try спрацює
} catch (error) {
  report(error);
}

await «зшиває» асинхронні кроки в одну логічну послідовність: після очікування виконання продовжується всередині того самого try.

3. Але await має бути:

try {
  saveDraft();             // async-функція без await
} catch (error) {          // не спіймає відхилення
}

try {
  await saveDraft();       // спіймає
} catch (error) {
}

4. return з try в async-функції: return fetchData() повертає Promise без очікування - відхилення пройде повз catch цієї функції. return await fetchData() в try - спіймає.

Обробники подій - помилку краще ловити в самому обробнику або загорнути обробники обгорткою:

const safe = (handler) => async (event) => {
  try {
    await handler(event);
  } catch (error) {
    report(error);
    showToast('Щось пішло не так');
  }
};

button.addEventListener('click', safe(onSave));

Загальне правило: помилку можна спіймати лише там, де її очікують. В асинхронному коді очікування - це await чи catch на відповідному Promise.

Докладніше в документації: Керування потоком і обробка помилок

Помилки фронтенду відбуваються в браузерах користувачів, а не на вашому сервері: якщо їх не збирати, про них дізнаються зі скарг (або не дізнаються взагалі).

Джерела помилок:

  • window подія error - необроблені винятки, а також помилки завантаження ресурсів (зображення, скрипти) при підписці з capture: true;
  • unhandledrejection - необроблені відхилення Promise;
  • явні виклики report(error) у catch, де помилку обробили, але про неї треба знати;
  • обробники помилок фреймворків: app.config.errorHandler у Vue, error boundaries у React - вони ловлять помилки рендеру, які інакше лише покажуть порожній екран.

Готові рішення - Sentry, Bugsnag, Flare (від авторів Ignition, з інтеграцією з Laravel): SDK підписується на всі ці події, групує однакові помилки й показує частоту.

Що робить звіт корисним:

  • читабельний стек - з source maps, завантаженими в систему моніторингу при деплої (самі карти на сервер не публікують);
  • версія релізу - щоб розуміти, в якому деплої з'явилася помилка і чи виправив її новий;
  • «хлібні крихти» (breadcrumbs) - що відбувалося перед помилкою: кліки, переходи, мережеві запити, повідомлення консолі. Найчастіше саме вони пояснюють, як відтворити проблему;
  • контекст: сторінка, браузер, ОС, користувач (ідентифікатор, а не персональні дані);
  • зв'язок з бекендом - ідентифікатор запиту (trace id), щоб знайти відповідний запис у логах Laravel.

Шум, який треба фільтрувати:

  • помилки розширень браузера й вбудованих скриптів (стек вказує на chrome-extension://);
  • Script error. без деталей - скрипти з інших доменів без crossorigin;
  • ResizeObserver loop completed with undelivered notifications - зазвичай нешкідливе попередження;
  • помилки мережі при закритті вкладки, AbortError від свідомо скасованих запитів;
  • помилки завантаження частин після деплою (Failed to fetch dynamically imported module): старі вкладки просять файли, яких уже немає. Це треба обробляти (запропонувати оновити сторінку), а не лише рахувати.

Обсяг і ціна:

  • вибірка (sampling) - на великому трафіку надсилати частину однакових помилок, а не всі;
  • обмеження частоти з одного браузера - одна помилка в циклі рендеру може згенерувати тисячі звітів за хвилину;
  • конфіденційність: не відправляти вміст полів форм, токени, повні URL з персональними даними в параметрах.

Метрика, на яку дивитися: не загальна кількість помилок, а кількість користувачів, які зіткнулися з кожною помилкою, і помилки, що з'явилися в останньому релізі.

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

Блок finally виконується після try і catch у будь-якому разі. Але якщо він сам завершується через return, throw, break чи continue, це замінює результат усього try...catch.

return у finally ковтає виняток:

function save() {
  try {
    throw new Error('Диск заповнено');
  } finally {
    return 'ok';
  }
}

save();   // 'ok' - помилка зникла без сліду

І перекриває return з try:

function getStatus() {
  try {
    return 'from try';
  } finally {
    return 'from finally';
  }
}
getStatus();   // 'from finally'

throw у finally замінює початкову помилку:

try {
  throw new Error('справжня причина');
} finally {
  cleanup();   // якщо cleanup() кине свою помилку, «справжня причина» загубиться
}

Це особливо підступно: у логах буде помилка прибирання («Cannot read properties of null»), а справжня проблема - прихована.

Що відбувається насправді. Коли try завершується (return, виняток), результат «запам'ятовується», виконується finally, і лише потім запам'ятований результат застосовується. Але якщо finally сам завершився «різко», запам'ятоване відкидається.

Ще одна тонкість - значення return обчислюється до finally:

function f() {
  let x = 1;
  try {
    return x;     // повернеться 1
  } finally {
    x = 2;        // змінна змінилася, але значення вже обчислено
  }
}

З об'єктом інакше: повертається посилання, і зміна властивостей у finally буде видна.

Правила для finally:

  • лише прибирання: закрити, зняти блокування, приховати індикатор;
  • ніколи не return, break, continue - лінтер no-unsafe-finally ловить це автоматично;
  • прибирання, що саме може впасти, загортати у власний try...catch, щоб не перекрити основну помилку:
} finally {
  try {
    await connection.close();
  } catch (closeError) {
    report(closeError);   // повідомити, але не перекрити помилку з try
  }
}

Сучасна альтернатива для гарантованого прибирання ресурсів - using з явним керуванням ресурсами (Symbol.dispose): ресурс звільняється автоматично при виході з блоку. Він уже є в TypeScript і поступово з'являється в рушіях JavaScript.

Аналогія з PHP: там return у finally теж перекриває і return, і виняток з try - правило однакове для обох мов.

Докладніше в документації: try...catch: блок finally

Найчастіша проблема з помилками - не відсутність обробки, а обробка не в тому місці: try...catch у кожній функції, де помилку логують і повертають null, а вищий рівень так і не дізнається, що щось пішло не так.

Принцип: ловити там, де знаєш, що робити.

Функція, яка не може осмислено відреагувати на помилку, має пропустити її далі. «Що робити» майже завжди вирішує рівень, близький до користувача.

Шари типового застосунку:

1. Низький рівень (HTTP-клієнт, парсери) - перетворює помилки на зрозумілі типи, але не ховає їх:

async function apiRequest(url, options) {
  const response = await fetch(url, options);
  if (response.status === 422) throw new ValidationError((await response.json()).errors);
  if (response.status === 401) throw new UnauthenticatedError();
  if (!response.ok) throw new HttpError(response);
  return response.json();
}

2. Сервіси й бізнес-логіка - зазвичай не ловлять нічого. Винятки - очікувані ситуації з альтернативною поведінкою: кеш недоступний - читаємо напряму; необов'язкова рекомендація не завантажилася - показуємо сторінку без неї.

3. Межа взаємодії (обробник форми, дія користувача) - тут вирішують, що показати:

async function onSubmit() {
  try {
    await saveOrder(form);
    showToast('Замовлення збережено');
  } catch (error) {
    if (error instanceof ValidationError) return showFieldErrors(error.errors);
    if (error instanceof UnauthenticatedError) return redirectToLogin();
    report(error);                                  // неочікуване - у моніторинг
    showToast('Не вдалося зберегти. Спробуйте ще раз');
  }
}

4. Глобальний рубіж - window.onerror, unhandledrejection, errorHandler фреймворку, error boundary: для того, що проскочило, - звіт у моніторинг і запасний інтерфейс замість білого екрана.

Очікувані й неочікувані помилки:

  • очікувані (валідація, 404 «товар не знайдено», немає прав) - частина звичайного сценарію. Їх обробляють і не надсилають у моніторинг як помилки;
  • неочікувані (TypeError, 500) - баги. Їх показують користувачеві загальним повідомленням і обов'язково звітують.

Змішування призводить до того, що моніторинг завалений тисячами «помилок валідації», а справжній баг тоне в шумі.

Антипатерни:

  • catch (e) { console.log(e) } без подальшої дії - помилка «оброблена», але застосунок у зламаному стані;
  • повернення null/false замість винятку - виклики далі мусять перевіряти результат, і рано чи пізно перевірку забудуть;
  • однакове «Щось пішло не так» для всього - і для помилки валідації, і для недоступного сервера;
  • catch лише для того, щоб throw ту саму помилку - шум.

Альтернатива винятками - тип-результат ({ ok: true, value } / { ok: false, error }), популярний у TypeScript для очікуваних помилок: компілятор змушує обробити обидва варіанти. Але для неочікуваних помилок винятки лишаються природнішими.

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