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

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

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

4 питання

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

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.

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