Питання на співбесіді з JavaScript
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
114 питань
Універсального способу немає - для різних типів різні інструменти.
typeof - для примітивів і функцій:
typeof 'x'; // 'string'
typeof 1; // 'number' (і для NaN теж)
typeof 1n; // 'bigint'
typeof undefined; // 'undefined'
typeof Symbol(); // 'symbol'
typeof (() => {}); // 'function'
typeof null; // 'object' - історична помилка
typeof []; // 'object'
Масив: Array.isArray(value), а не typeof чи instanceof.
instanceof - чи є в ланцюжку прототипів конструктор: err instanceof TypeError. Пастка - різні realm'и: масив з iframe чи з іншого vm-контексту в Node.js має інший Array, і instanceof Array дає false. Array.isArray таку проблему не має.
Точний «внутрішній» тип:
Object.prototype.toString.call(new Date()); // '[object Date]'
Object.prototype.toString.call(null); // '[object Null]'
Інші корисні перевірки:
Number.isNaN(x),Number.isInteger(x),Number.isFinite(x)- на відміну від глобальнихisNaN/isFinite, не приводять аргумент до числа.value === null- дляnull.value !== null && typeof value === 'object'- «справжній» об'єкт.
Для даних ззовні (відповідь API, localStorage, форма) ручні перевірки швидко стають громіздкими. Тут використовують схеми валідації (Zod, Valibot): вони одночасно перевіряють дані під час виконання й дають TypeScript-тип.
Number точно представляє цілі числа лише до Number.MAX_SAFE_INTEGER = 2^53 - 1 = 9 007 199 254 740 991. Далі сусідні цілі числа вже неможливо розрізнити:
9007199254740993 === 9007199254740992; // true
BigInt - окремий тип цілих чисел довільної довжини:
const big = 9007199254740993n; // суфікс n
big + 2n; // 9007199254740995n
BigInt('123456789012345678901234567890');
Обмеження BigInt: не можна змішувати з Number в арифметиці (1n + 1 - TypeError), Math.* з ним не працює, ділення відкидає дробову частину, а JSON.stringify кидає помилку.
Реальна проблема - ID з бекенду. Twitter/X ID, Snowflake-ідентифікатори, bigint-ключі з бази можуть перевищувати 2^53. JSON.parse перетворює число на Number і тихо спотворює останні цифри:
JSON.parse('{"id": 1234567890123456789}').id; // 1234567890123456800
Запит з таким ID потім шукає не той запис - і помилка проявляється далеко від причини.
Рішення:
- Віддавати великі ID рядками - найпоширеніший підхід (Twitter API повертає і
id, іid_str). У Laravel - каст до рядка в API Resource. JSON.parseз reviver іcontext.source(нові браузери й Node.js) дає доступ до сирого тексту числа, щоб перетворити його наBigInt.- ID - це ідентифікатор, а не число: арифметика над ним не потрібна, тож рядок - природний тип.
Коли об'єкт опиняється там, де потрібен примітив (+obj, obj + '', `${obj}`, obj > 5, obj == 1), рушій викликає перетворення на примітив з «підказкою» (hint), якого типу очікують:
'number'- арифметика й порівняння:+obj,obj * 2,obj > 5;'string'- шаблонні рядки,String(obj), ключі об'єкта;'default'- коли незрозуміло: бінарний+,==.
Алгоритм:
- якщо є метод
obj[Symbol.toPrimitive](hint)- викликається він; - інакше для
'string'пробуютьсяtoString(), потімvalueOf(); - для
'number'і'default'- спершуvalueOf(), потімtoString(); - перший результат-примітив і використовується. Якщо обидва повернули об'єкт -
TypeError.
const price = {
amount: 42,
valueOf() { return this.amount; },
toString() { return `${this.amount} грн`; },
};
price + 1; // 43 (default → valueOf)
price * 2; // 84 (number → valueOf)
`${price}`; // '42 грн' (string → toString)
String(price); // '42 грн'
Повний контроль - Symbol.toPrimitive:
const money = {
[Symbol.toPrimitive](hint) {
if (hint === 'number') return 42;
if (hint === 'string') return '42 грн';
return 'default';
},
};
+money; // 42
`${money}`; // '42 грн'
money + ''; // 'default'
Як це пояснює «дивацтва» JavaScript:
[] + []→'': масиви черезtoString()стають порожніми рядками;[] + {}→'[object Object]';[5] * 2→10:[5].toString()='5', потім число;Date- єдиний вбудований об'єкт, для якого'default'означає рядок:date + 1дає рядок з датою й одиницею, аdate - 1- число (мітку часу мінус 1).
Практична цінність:
- порівняння дат
a < bпрацює черезvalueOf(), що повертає мілісекунди; - власні числові типи (гроші, вектори) можуть поводитися природно в шаблонах, але арифметику через
valueOfкраще не будувати:price1 + price2дасть число й «загубить» валюту. Явні методи (add,format) надійніші; - в об'єктах-значеннях, що потрапляють у шаблони, корисний змістовний
toString()- замість[object Object]у логах і повідомленнях.
JSON.stringify вміє лише те, що є в JSON: об'єкти, масиви, рядки, скінченні числа, true/false, null. Усе інше перетворюється - і часто тихо.
JSON.stringify({
a: undefined, // властивість зникає
b: () => 1, // зникає
c: Symbol('x'), // зникає
d: NaN, // null
e: Infinity, // null
f: new Date(0), // '1970-01-01T00:00:00.000Z'
g: [undefined, () => 1], // [null, null] - у масиві не зникають, а стають null
});
// '{"d":null,"e":null,"f":"1970-01-01T00:00:00.000Z","g":[null,null]}'
Що варто знати:
undefinedу об'єкті - властивість зникає. Для API, що розрізняє «поле не передано» і «поле очистити», треба явно надсилатиnull;NaNіInfinityстаютьnull- сервер отримаєnullзамість помилки в розрахунку;- дати стають рядками ISO в UTC (через метод
toJSON).JSON.parseназад їх не перетворює - на клієнті після розбору це рядки; MapіSetсеріалізуються як{}- дані втрачаються без попередження. Перетворюйте явно:Object.fromEntries(map),[...set];BigIntкидаєTypeError(«Do not know how to serialize a BigInt»). Потрібно перетворювати на рядок вручну;- циклічні посилання (
obj.self = obj, вузли DOM, моделі з двосторонніми зв'язками) кидаютьTypeError.
Керування серіалізацією:
// метод toJSON - об'єкт сам вирішує, як виглядати в JSON
class Money {
constructor(amount, currency) { Object.assign(this, { amount, currency }); }
toJSON() { return { amount: String(this.amount), currency: this.currency }; }
}
// replacer-функція: перетворити BigInt, приховати пароль
JSON.stringify(data, (key, value) => {
if (typeof value === 'bigint') return value.toString();
if (key === 'password') return undefined;
return value;
});
// replacer-масив - білий список полів
JSON.stringify(user, ['id', 'name']);
// відступи для читабельності
JSON.stringify(data, null, 2);
Зворотний бік - JSON.parse з reviver:
JSON.parse(text, (key, value) =>
typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value) ? new Date(value) : value,
);
Наслідок для «глибокого копіювання» через JSON.parse(JSON.stringify(x)): воно губить undefined, функції, перетворює дати на рядки, Map на {}, падає на циклах. Для копіювання є structuredClone(), що коректно обробляє дати, Map, Set і цикли.
Щоб показати сторінку, браузер рахує layout (розміри й позиції елементів, у Firefox це називають reflow), а потім paint - малює пікселі, і composite - збирає шари.
- Reflow (layout) - перерахунок геометрії. Дорогий: зміна розміру одного елемента може зачепити батьків, сусідів і нащадків.
- Repaint - перемальовування без зміни геометрії (колір, тінь). Дешевше.
- Лише composite -
transformіopacityна окремому шарі: найдешевше, часто на GPU.
Layout thrashing - чергування записів і читань геометрії в циклі. Кожне читання (offsetHeight, getBoundingClientRect(), scrollTop) після запису змушує браузер синхронно перерахувати layout:
// Погано: N примусових перерахунків
for (const card of cards) {
card.style.height = `${card.offsetWidth * 0.75}px`; // читання після запису попередньої ітерації
}
// Добре: спершу всі читання, потім усі записи
const widths = cards.map((card) => card.offsetWidth);
cards.forEach((card, i) => { card.style.height = `${widths[i] * 0.75}px`; });
Інші правила:
- Анімувати
transformіopacity, а неtop,left,width,height. - Групувати зміни стилів через клас, а не десяток окремих присвоєнь
style. - Візуальні оновлення - у
requestAnimationFrame, щоб вони збігалися з кадром. content-visibility: autoдозволяє браузеру пропускати layout і paint для невидимих частин довгої сторінки.
Як знайти проблему: вкладка Performance у DevTools - фіолетові блоки «Layout» з попередженням «Forced reflow» показують рядок коду, що спричинив перерахунок.
Старий спосіб дізнатися, чи елемент з'явився на екрані, - обробник scroll з getBoundingClientRect() для кожного елемента. Він спрацьовує десятки разів на секунду, виконується в головному потоці й примушує браузер рахувати layout - звідси «смикання» прокрутки.
IntersectionObserver повідомляє, коли елемент перетинає область видимості (або інший елемент-контейнер). Обчислення виконує браузер, асинхронно, без примусового layout.
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src; // завантажити зображення
observer.unobserve(img); // більше не стежити
}
}, { rootMargin: '200px' }); // почати трохи заздалегідь
document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));
Типові застосування:
- Нескінченний скрол: спостерігати за «маркером» наприкінці списку й підвантажувати наступну сторінку, коли він стає видимим.
- Аналітика переглядів: реклама чи блок вважається показаним, коли видно 50% протягом секунди (
threshold: 0.5). - Анімації при появі, пауза відео поза екраном.
Що варто знати:
- Для простих зображень лінивого завантаження вже вбудовано:
<img loading="lazy">. Спостерігач потрібен для складніших випадків. thresholdзадає, яку частку елемента має бути видно;rootMargin- розширює чи звужує область перевірки.- Спостерігача треба відключати (
disconnect()), коли компонент знищено, інакше він триматиме посилання на елементи. - Для зміни розміру елементів є споріднений
ResizeObserver.
MutationObserver повідомляє про зміни в DOM: додавання й видалення вузлів, зміну атрибутів чи тексту. Він замінив застарілі й повільні mutation events (DOMNodeInserted), які браузери поступово видаляють (Chrome - з версії 127).
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node instanceof Element) {
node.querySelectorAll('[data-tooltip]').forEach(initTooltip);
if (node.matches('[data-tooltip]')) initTooltip(node);
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
Що можна спостерігати (опції observe):
childList- додавання й видалення дочірніх вузлів;subtree- разом з усіма нащадками, а не лише прямими дітьми;attributes(+attributeFilter: ['class', 'disabled'],attributeOldValue) - зміни атрибутів;characterData- зміни тексту у текстових вузлах.
Як він виконується. Колбек викликається асинхронно, пакетом, як мікрозадача після поточного коду. Сто змін в одному циклі - один виклик з масивом записів. Тому він значно дешевший за старі синхронні події.
Де корисний:
- ініціалізація сторонніх віджетів на вмісті, що з'являється динамічно (підвантаження, Livewire-оновлення,
wire:navigate); - реакція на зміни, які робить чужий код, якого ви не контролюєте;
- автоматичні тести й інструменти доступності, що стежать за змінами сторінки.
Підводні камені:
subtree: trueнаdocument.body- колбек на кожну зміну всієї сторінки. На складних сторінках це відчутно. Спостерігати варто найменший можливий контейнер з мінімумом опцій;- нескінченний цикл: колбек змінює DOM у тій самій області - це породжує нові записи. Потрібна перевірка, чи елемент уже оброблено (наприклад, атрибут-позначка);
- відключення:
observer.disconnect()при знищенні компонента, інакше спостерігач триматиме посилання на вузли.takeRecords()дає змогу забрати ще не доставлені записи перед відключенням; - не для розміру й видимості: для них є
ResizeObserverіIntersectionObserver- зміна розміру не є мутацією DOM.
Альтернатива, якщо ви контролюєте код, що змінює DOM, - викликати ініціалізацію явно чи через власну подію. Спостерігач потрібен саме тоді, коли зміни відбуваються «десь» поза вашим контролем.
Livewire і Alpine самі використовують MutationObserver: Alpine так помічає нові елементи з x-data і ініціалізує їх.
Веб-компоненти - вбудований у браузер спосіб створювати власні HTML-елементи. Три частини:
1. Користувацькі елементи (custom elements) - власний тег з поведінкою:
class CopyButton extends HTMLElement {
connectedCallback() {
this.addEventListener('click', () => {
navigator.clipboard.writeText(this.getAttribute('text'));
});
}
}
customElements.define('copy-button', CopyButton);
<copy-button text="composer require laravel/sanctum">Копіювати</copy-button>
Назва обов'язково містить дефіс - щоб не зіткнутися з майбутніми стандартними тегами. Колбеки життєвого циклу: connectedCallback, disconnectedCallback, attributeChangedCallback (для атрибутів зі списку observedAttributes).
2. Shadow DOM - ізольоване піддерево всередині елемента:
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>button { color: red; }</style>
<button><slot></slot></button>
`;
- стилі сторінки не проникають усередину, а стилі компонента не витікають назовні - справжня ізоляція CSS;
document.querySelectorне знаходить елементи всередині тіньового дерева;- налаштування ззовні - через CSS-змінні (вони успадковуються крізь межу) і
::part().
3. <template> і <slot> - розмітка-заготовка й місця, куди потрапляє вміст, переданий компоненту.
Коли веб-компоненти доречні:
- елементи, що мають працювати будь-де: у Blade, Vue, React, на статичній сторінці. Дизайн-системи великих компаній і віджети для вбудовування на чужі сайти;
- ізоляція стилів від CSS сторінки, яку ви не контролюєте;
- «острівці» інтерактивності на серверно-рендерених сторінках без фреймворку;
- довговічність: стандарт браузера не застаріває так, як версії фреймворків.
Обмеження:
- немає реактивності й шаблонізації з коробки: оновлення DOM при зміні даних - вручну (або бібліотека на кшталт Lit);
- серверний рендеринг Shadow DOM можливий через декларативний
<template shadowrootmode="open">, але екосистема тут слабша, ніж у фреймворків; - форми: елементи всередині Shadow DOM не беруть участі у відправці форми без
ElementInternals; - доступність: зв'язки
aria-labelledbyіforне працюють крізь межу тіньового дерева; - Tailwind не стилізує вміст Shadow DOM - глобальні класи туди не потрапляють.
З фреймворками: Vue вміє компілювати компоненти у веб-компоненти (defineCustomElement), React 19 повноцінно підтримує їх як елементи. Alpine і Livewire працюють з користувацькими елементами як із звичайними тегами (без Shadow DOM).
Поле exports у package.json визначає, які файли пакета можна імпортувати і який файл віддати залежно від умов (ES-модулі чи CommonJS, браузер чи Node.js, типи TypeScript).
Старий підхід - поле main (одна точка входу) плюс можливість імпортувати будь-який внутрішній файл: import x from 'lib/dist/internal/helpers.js'. Користувачі пакета покладалися на внутрішню структуру, і будь-яке перейменування файлу ламало їхній код.
З exports:
{
"name": "@acme/ui",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./button": {
"types": "./dist/button.d.ts",
"import": "./dist/button.js"
},
"./styles.css": "./dist/styles.css",
"./package.json": "./package.json"
}
}
import '@acme/ui'→dist/index.js, аrequire('@acme/ui')→dist/index.cjs;import '@acme/ui/button'- дозволена «підточка»;import '@acme/ui/dist/internal.js'→ помилкаERR_PACKAGE_PATH_NOT_EXPORTED. Внутрішні файли інкапсульовані.
Умови перевіряються в порядку, в якому записані в об'єкті, - перша відповідна перемагає:
types- для TypeScript, завжди першою;import/require- залежно від способу імпорту;browser,node,deno,worker- середовище (збирачі передаютьbrowser);development/production- режим (підтримують збирачі);default- запасний варіант, завжди останнім.
Шаблони: "./icons/*": "./dist/icons/*.js" - для пакетів з сотнями файлів.
Поле imports - дзеркальне: внутрішні псевдоніми для коду самого пакета, що починаються з #:
"imports": { "#utils/*": "./src/utils/*.js" }
import { slugify } from '#utils/strings';
Працює без налаштувань збирача - на відміну від псевдонімів @/ у Vite чи TypeScript.
Підводні камені:
- додавання
exportsдо існуючого пакета - ламаюча зміна: усі глибокі імпорти, які раніше працювали, перестануть. Тому це роблять у мажорній версії; - подвійний пакет (dual package hazard): якщо застосунок завантажить і ESM-, і CJS-версію пакета, у пам'яті буде два екземпляри - два різні класи, два синглтони,
instanceofміж ними не працює; - TypeScript читає
exportsлише зmoduleResolution: "node16"/"nodenext"/"bundler". Зі старимnodeвін бачить лишеtypes/main, і помилки типів з'являються лише у споживачів пакета.
Перевірка перед публікацією: інструмент @arethetypeswrong/cli і publint знаходять невідповідності між exports, файлами й типами.
Після збирання код у браузері не схожий на написаний: TypeScript перетворено на JavaScript, .vue-файли - на функції рендеру, усе мініфіковано в один рядок з іменами a, b, c. Помилка в продакшені виглядає як TypeError at app-3f9a.js:1:48213.
Source map - файл (app-3f9a.js.map), що зіставляє кожну позицію зібраного коду з файлом, рядком і колонкою вихідного коду, а також з оригінальними іменами змінних. DevTools автоматично його використовують: налагоджувач показує ваш TypeScript, точки зупинки ставляться у вихідних файлах, стеки помилок вказують на справжні рядки.
Зібраний файл посилається на карту коментарем наприкінці:
//# sourceMappingURL=app-3f9a.js.map
Увімкнення у Vite:
// vite.config.js
export default defineConfig({
build: {
sourcemap: true, // файли .map + коментар
// sourcemap: 'hidden' - файли .map без коментаря в коді
},
});
У режимі розробки карти генеруються завжди.
Чи публікувати на продакшені - аргументи:
Проти публічних карт:
- вихідний код стає повністю читабельним - з коментарями й оригінальними назвами. Для фронтенду це не порушення безпеки (усе, що виконується в браузері, і так можна дослідити), але полегшує копіювання й пошук вразливостей у логіці;
- у вихідний код можуть потрапити коментарі з внутрішньою інформацією, назви внутрішніх сервісів, закоментовані фрагменти.
За точні стеки помилок:
- без карт звіти про помилки з продакшену майже марні.
Компроміс, який використовують найчастіше:
- генерувати карти як
'hidden'- файли є, але браузери про них не знають; - завантажувати карти в систему моніторингу помилок (Sentry, Bugsnag, Flare) під час деплою - вона розшифровує стеки на своєму боці;
- не публікувати
.map-файли на вебсервері (видаляти після завантаження чи забороняти доступ правилом сервера).
Тоді розробники бачать у звітах точні рядки, а відвідувачі - лише мініфікований код.
Що варто знати:
- карти не впливають на швидкість для звичайних користувачів: браузер завантажує їх лише з відкритими DevTools;
- карта має відповідати саме цій збірці: карта від іншого деплою дасть хибні рядки. Тому їх завантажують у Sentry з ідентифікатором релізу;
- генерування карт сповільнює збирання й збільшує розмір артефактів.
Живі прив'язки (live bindings). Імпорт у ES-модулях - не копія значення, а посилання на змінну модуля-експортера. Якщо модуль змінить свою змінну, імпортери побачать нове значення:
// counter.js
export let count = 0;
export function increment() { count++; }
// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 - значення оновилося
count = 5; // TypeError: Assignment to constant variable
Змінити імпорт ззовні не можна - лише через функції самого модуля. У CommonJS навпаки: const { count } = require('./counter') - копія значення на момент виклику, і increment() її не змінить.
Циклічні імпорти - модуль A імпортує B, а B імпортує A (напряму чи через ланцюжок). ES-модулі це дозволяють, але порядок виконання стає неочевидним:
// a.js
import { b } from './b.js';
export const a = 'A';
console.log('a бачить', b);
// b.js
import { a } from './a.js';
export const b = 'B';
console.log('b бачить', a); // ReferenceError: Cannot access 'a' before initialization
Запускаємо a.js. Він імпортує b.js - той виконується першим, повністю. На цей момент a.js ще не дійшов до рядка export const a, тож a - у тимчасовій мертвій зоні.
Коли цикл безпечний: якщо звернення до імпорту відбувається пізніше, не під час виконання модуля, а у функції, яку викличуть після завантаження всього графа:
// b.js
import { a } from './a.js';
export function describe() { return `b + ${a}`; } // a читається при виклику - вже ініціалізовано
Завдяки живим прив'язкам функція побачить значення, щойно його буде встановлено.
Чим небезпечні цикли:
- помилки залежать від того, який модуль імпортовано першим - змінили точку входу чи порядок імпортів, і код, що працював, падає;
- з
export default classчиconstна верхньому рівні -ReferenceErrorпри завантаженні; при наслідуванніclass B extends A, де A ще не ініціалізовано, - теж; - HMR у Vite у циклічних графах часто перезавантажує всю сторінку замість одного модуля;
- «бочки» (
index.js, що реекспортує все) - типове джерело неявних циклів: компонент імпортує сусіда черезindex.js, аindex.jsімпортує сам компонент.
Як лікувати:
- винести спільний код у третій модуль, від якого залежать обидва;
- імпортувати напряму з файлу, а не через «бочку»;
- передавати залежність параметром замість імпорту;
- знаходити цикли інструментами:
madge --circular, правило ESLintimport/no-cycle.
Помилки фронтенду відбуваються в браузерах користувачів, а не на вашому сервері: якщо їх не збирати, про них дізнаються зі скарг (або не дізнаються взагалі).
Джерела помилок:
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 з персональними даними в параметрах.
Метрика, на яку дивитися: не загальна кількість помилок, а кількість користувачів, які зіткнулися з кожною помилкою, і помилки, що з'явилися в останньому релізі.
Блок 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 у кожній функції, де помилку логують і повертають 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 для очікуваних помилок: компілятор змушує обробити обидва варіанти. Але для неочікуваних помилок винятки лишаються природнішими.
Web Worker - JavaScript, що виконується в окремому потоці. Важкі обчислення у воркері не блокують головний потік, тож інтерфейс лишається чутливим до кліків і прокрутки.
// main.js - синтаксис, який розуміє Vite
const worker = new Worker(new URL('./csv-worker.js', import.meta.url), { type: 'module' });
worker.postMessage({ file });
worker.onmessage = (event) => renderTable(event.data.rows);
worker.onerror = (event) => showError(event.message);
// csv-worker.js
self.onmessage = async (event) => {
const text = await event.data.file.text();
const rows = parseCsv(text); // секунди роботи - але не в головному потоці
self.postMessage({ rows });
};
Обмеження воркерів:
- немає доступу до DOM,
window,document. Лише обчислення й мережа (fetch, WebSocket),IndexedDB, таймери; - спілкування лише повідомленнями. Дані копіюються (алгоритм structured clone): передати мегабайтний масив туди й назад - теж робота. Функції й класи з методами не передаються;
- запуск коштує - завантаження скрипта й ініціалізація. Для обчислень на кілька мілісекунд воркер повільніший за звичайний виклик.
Передача без копіювання - transferable objects:
const buffer = new ArrayBuffer(50_000_000);
worker.postMessage(buffer, [buffer]); // власність переходить до воркера
buffer.byteLength; // 0 - у головному потоці буфер більше недоступний
Працює для ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, потоків.
Коли воркер доречний:
- розбір великих файлів (CSV, Excel, JSON на десятки мегабайтів);
- обробка зображень, стиснення перед завантаженням на сервер;
- криптографія, хешування файлів;
- пошук і фільтрація по великому локальному набору даних;
- рендер у
OffscreenCanvas(графіки, візуалізації).
Коли не допоможе:
- повільний рендер великого списку - проблема в DOM, а DOM недоступний воркеру. Тут допомагає віртуалізація;
- очікування мережі - воно й так не блокує потік.
Різновиди:
- dedicated worker - належить одній сторінці (приклад вище);
- SharedWorker - один на кілька вкладок того самого сайту: одне спільне WebSocket-з'єднання на всі вкладки;
- Service Worker - зовсім інша роль: проксі між сторінкою й мережею.
Зручність: бібліотека Comlink перетворює обмін повідомленнями на виклик звичайних async-функцій, приховуючи postMessage.
Питання з реальних технічних співбесід - 114 питань у 10 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Браузерні API й мережа 12 Сучасний синтаксис 12 Асинхронність 12 Типи й приведення 12 DOM і події 12 Масиви й об'єкти 12 Функції й замикання 12 Тестування 10
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії