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

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

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

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 - це ідентифікатор, а не число: арифметика над ним не потрібна, тож рядок - природний тип.

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

Коли об'єкт опиняється там, де потрібен примітив (+obj, obj + '', `${obj}`, obj > 5, obj == 1), рушій викликає перетворення на примітив з «підказкою» (hint), якого типу очікують:

  • 'number' - арифметика й порівняння: +obj, obj * 2, obj > 5;
  • 'string' - шаблонні рядки, String(obj), ключі об'єкта;
  • 'default' - коли незрозуміло: бінарний +, ==.

Алгоритм:

  1. якщо є метод obj[Symbol.toPrimitive](hint) - викликається він;
  2. інакше для 'string' пробуються toString(), потім valueOf();
  3. для 'number' і 'default' - спершу valueOf(), потім toString();
  4. перший результат-примітив і використовується. Якщо обидва повернули об'єкт - 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] у логах і повідомленнях.

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

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 і цикли.

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

Щоб показати сторінку, браузер рахує 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» показують рядок коду, що спричинив перерахунок.

Докладніше в документації: 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.

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

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 і ініціалізує їх.

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

Веб-компоненти - вбудований у браузер спосіб створювати власні 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, файлами й типами.

Докладніше в документації: Node.js: точки входу пакетів

Після збирання код у браузері не схожий на написаний: 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 без коментаря в коді
  },
});

У режимі розробки карти генеруються завжди.

Чи публікувати на продакшені - аргументи:

Проти публічних карт:

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

За точні стеки помилок:

  • без карт звіти про помилки з продакшену майже марні.

Компроміс, який використовують найчастіше:

  1. генерувати карти як 'hidden' - файли є, але браузери про них не знають;
  2. завантажувати карти в систему моніторингу помилок (Sentry, Bugsnag, Flare) під час деплою - вона розшифровує стеки на своєму боці;
  3. не публікувати .map-файли на вебсервері (видаляти після завантаження чи забороняти доступ правилом сервера).

Тоді розробники бачать у звітах точні рядки, а відвідувачі - лише мініфікований код.

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

  • карти не впливають на швидкість для звичайних користувачів: браузер завантажує їх лише з відкритими DevTools;
  • карта має відповідати саме цій збірці: карта від іншого деплою дасть хибні рядки. Тому їх завантажують у Sentry з ідентифікатором релізу;
  • генерування карт сповільнює збирання й збільшує розмір артефактів.

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

Живі прив'язки (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, правило ESLint import/no-cycle.

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

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

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

  • 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

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.

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

Питання з реальних технічних співбесід - 114 питань у 10 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 37 Middle 41 Senior 36

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