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чи кодом.
Якщо 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) знаходить такі місця автоматично.
Об'єкт 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']), а для справжніх помилок потрібна система моніторингу, а не консоль користувача.
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.
Докладніше в документації: Керування потоком і обробка помилок