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

Питання на співбесіді з JavaScript

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

114 питань

Vite читає файли .env і робить змінні доступними в коді через import.meta.env:

# .env Laravel-проєкту
APP_NAME="Laravel Ukraine"
VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY=abc123
STRIPE_SECRET=sk_live_...
import.meta.env.VITE_APP_NAME;        // 'Laravel Ukraine'
import.meta.env.VITE_PUSHER_APP_KEY;  // 'abc123'
import.meta.env.STRIPE_SECRET;        // undefined - без префікса не потрапляє

Правило префікса: у клієнтський код потрапляють лише змінні з префіксом VITE_. Це захист від випадкового витоку: .env Laravel містить паролі бази й ключі API, і без фільтра вони опинилися б у JavaScript, який завантажує кожен відвідувач.

Вбудовані змінні: import.meta.env.MODE (development/production), DEV, PROD (булеві), BASE_URL, SSR.

Як це працює: під час збирання Vite підставляє значення прямо в код як рядкові константи. Наслідки:

  • змінна з префіксом VITE_ - публічна. Будь-хто відкриє зібраний файл і побачить значення. Туди можна класти лише те, що й так публічне: публічний ключ Pusher/Reverb, ідентифікатор аналітики, URL API;
  • значення фіксуються на момент збирання. Змінити .env на сервері після npm run build - нічого не змінить у вже зібраних файлах. Потрібна нова збірка. Якщо один образ має працювати в різних середовищах, конфігурацію передають з бекенду під час виконання (наприклад, через <meta>-теги чи JSON у Blade);
  • код у гілках if (import.meta.env.DEV) видаляється з продакшен-збірки.

Режими й файли: .env.local (не в Git), .env.production для vite build, .env.staging для vite build --mode staging. Пріоритет - від конкретнішого до загального.

Чому секрет у фронтенді - не секрет взагалі. Мініфікація, обфускація, «приховані» змінні - нічого не захищає від того, хто відкриє DevTools. Якщо фронтенду потрібно звернутися до API з секретним ключем (оплата, OpenAI, SMS), запит іде через власний бекенд: браузер звертається до Laravel, Laravel - до стороннього API з ключем із серверного .env.

TypeScript: типи власних змінних оголошують в env.d.ts (interface ImportMetaEnv { readonly VITE_APP_NAME: string }) - тоді редактор підказує назви й ловить друкарські помилки.

Докладніше в документації: Vite: змінні оточення й режими

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

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.

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

Браузер застосовує політику одного джерела (same-origin policy): JavaScript зі сторінки https://app.example.com не може читати відповіді від https://api.other.com. Джерело (origin) - це схема + домен + порт: http і https, example.com і api.example.com, порти 3000 і 8000 - різні джерела.

Навіщо: інакше будь-який сайт, який ви відкрили, міг би від вашого імені (з вашими cookies) читати пошту, банківський кабінет чи адмінку.

CORS (Cross-Origin Resource Sharing) - спосіб, яким сервер дозволяє певним джерелам читати свої відповіді, через заголовки:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

Попередній запит (preflight). Для «непростих» запитів браузер спершу надсилає OPTIONS, щоб запитати дозволу:

  • методи, крім GET, HEAD, POST;
  • власні заголовки (Authorization, X-Requested-With);
  • Content-Type: application/json (простими вважаються лише формові типи й text/plain).
OPTIONS /api/orders
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization

Сервер відповідає, що дозволено (Access-Control-Allow-Methods, -Headers, -Max-Age для кешування дозволу). Лише після цього йде справжній запит.

Що важливо розуміти:

  • CORS виконує браузер, а не сервер. curl, Postman і бекенд-код не мають жодних обмежень. CORS - не захист API від чужих клієнтів;
  • простий запит доходить до сервера і виконується - браузер лише не дає JavaScript прочитати відповідь. Тому CORS не замінює захист від CSRF;
  • з cookies (credentials: 'include') заборонено Access-Control-Allow-Origin: * - потрібне конкретне джерело і Access-Control-Allow-Credentials: true;
  • помилка CORS у консолі часто маскує іншу: сервер повернув 500 чи 404 без CORS-заголовків, і браузер повідомляє про CORS, а не про справжню причину. Варто дивитися запит у вкладці Network.

У Laravel CORS обробляє вбудований middleware HandleCors з налаштуваннями в config/cors.php (публікується php artisan config:publish cors):

'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => ['https://app.example.com'],
'supports_credentials' => true,

Як обійтися без CORS: розмістити фронтенд і API на одному джерелі (Laravel віддає і сторінки, і /api), або проксувати запити через сервер розробки Vite (server.proxy). Тоді запити same-origin, і CORS не потрібен.

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

History API дає змогу змінювати адресу в рядку браузера і записи в історії переходів без завантаження нової сторінки. На ньому побудовані маршрутизатори SPA (Vue Router, React Router), wire:navigate у Livewire, фільтри каталогів, що відображаються в URL.

pushState - додати новий запис в історію:

history.pushState({ page: 2 }, '', '/jobs?page=2');

Адреса змінилася, кнопка «Назад» поверне на попередню, але запиту на сервер не було і сторінка не перезавантажилася. Що показати - вирішує ваш код.

replaceState - замінити поточний запис, не додаючи нового. Для змін, які не повинні засмічувати історію: введення в поле пошуку, сортування, позиція прокрутки.

popstate - подія, коли користувач переходить по історії кнопками «Назад»/«Вперед»:

window.addEventListener('popstate', (event) => {
  renderPage(event.state?.page ?? 1);   // відновити стан для цього запису
});

Важливо: pushState і replaceState не викликають popstate. Подія спрацьовує лише при навігації по історії. Тому після pushState оновлювати інтерфейс треба самостійно.

Об'єкт стану (перший аргумент) зберігається разом із записом історії і повертається в event.state. Він серіалізується (як structuredClone), має обмеження розміру - туди кладуть невеликі дані (номер сторінки, id), а не весь список товарів.

Що треба зробити серверу: якщо користувач оновить сторінку чи відкриє посилання /jobs?page=2 напряму, запит піде на сервер. Сервер мусить уміти віддати цю сторінку - інакше 404. Для SPA це «fallback» на index.html, у Laravel - звичайний маршрут, що рендерить сторінку з відповідним станом.

Типові вимоги до SPA-навігації, які легко забути:

  • заголовок сторінки (document.title) - змінюється вручну;
  • прокрутка: при переході вперед - на початок, при «Назад» - туди, де користувач був. Браузерне history.scrollRestoration інколи доводиться вимикати й керувати самостійно;
  • фокус і доступність: зчитувачі екрана не знають, що «сторінка змінилася», - фокус переводять на заголовок нового вмісту;
  • аналітика: перегляд сторінки треба відправляти вручну.

Navigation API - новіший інтерфейс із єдиною подією navigate для всіх переходів (посилання, форми, кнопки історії) і перехопленням через event.intercept(). Він простіший для маршрутизаторів, але підтримується ще не всіма браузерами.

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

Обидві технології дають змогу серверу надсилати дані в браузер без запиту від клієнта. Різниця - у напрямку, протоколі й складності.

Server-Sent Events (SSE) - однобічний потік від сервера до клієнта через звичайне HTTP-з'єднання:

const source = new EventSource('/orders/42/status');

source.addEventListener('status', (event) => {
  const { status } = JSON.parse(event.data);
  updateBadge(status);
});

source.onerror = () => { /* браузер сам перепідключиться */ };

Сервер відповідає з Content-Type: text/event-stream і пише рядки:

event: status
id: 17
data: {"status":"shipped"}

  • автоматичне перепідключення вбудоване; при перепідключенні браузер надсилає Last-Event-ID, і сервер може дослати пропущене;
  • працює через звичайний HTTP: проксі, балансувальники, автентифікація cookies - як у будь-якого запиту;
  • лише текст, лише від сервера;
  • на HTTP/1.1 браузер обмежує ~6 з'єднань на домен - кілька вкладок з SSE можуть їх вичерпати. На HTTP/2 і HTTP/3 обмеження немає.

WebSocket - двобічне постійне з'єднання з власним протоколом:

const socket = new WebSocket('wss://example.com/ws');
socket.onmessage = (event) => render(JSON.parse(event.data));
socket.send(JSON.stringify({ type: 'typing', chat: 7 }));
  • обидві сторони надсилають повідомлення будь-коли, з мінімальними накладними витратами;
  • текст і бінарні дані;
  • перепідключення, «серцебиття», підписки на канали - пишете самі або беруть бібліотеку;
  • потрібен сервер, що тримає тисячі постійних з'єднань, і налаштовані проксі.

Коли що:

Задача Вибір
статус замовлення, сповіщення, прогрес задачі, стрічка подій SSE
потокова відповідь LLM («друкується» по словах) SSE / потоковий fetch
чат, спільне редагування, ігри, присутність користувачів WebSocket
оновлення раз на хвилину звичайне опитування - найпростіше

У Laravel-екосистемі:

  • WebSocket - Laravel Reverb (свій сервер) чи Pusher, на клієнті Laravel Echo; трансляція подій через ShouldBroadcast;
  • SSE - response()->eventStream() у контролері; Livewire має wire:stream для потокового оновлення частини компонента.

Головна перевага SSE - простота: не потрібен окремий сервер і протокол, це звичайний маршрут Laravel. Але кожен відкритий потік тримає процес PHP-FPM зайнятим - під багато одночасних клієнтів потрібні Octane чи окремий сервіс.

Докладніше в документації: Server-Sent Events

Опція credentials визначає, чи надсилає fetch cookies і чи приймає їх з відповіді:

  • 'same-origin' (за замовчуванням) - лише для запитів на той самий домен;
  • 'include' - і для інших доменів (потрібна відповідна CORS-конфігурація сервера);
  • 'omit' - ніколи.

Для звичайного Laravel-застосунку, де сторінки й запити з одного домену, сесійна cookie надсилається автоматично.

CSRF-захист у Laravel перевіряє, що запит, який змінює дані (POST, PUT, PATCH, DELETE), справді надіслано з вашого сайту. Способи передати токен з JavaScript:

1. Заголовок X-CSRF-TOKEN з мета-тегу:

<meta name="csrf-token" content="{{ csrf_token() }}">
await fetch('/orders', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json',
    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
  },
  body: JSON.stringify(order),
});

2. Заголовок X-XSRF-TOKEN з cookie. Laravel ставить cookie XSRF-TOKEN (зашифрований токен, доступний JavaScript). Axios автоматично читає її і додає заголовок для запитів на той самий домен - тому в Laravel-проєктах з axios про CSRF зазвичай не думають. З fetch це треба робити самому.

3. Поле _token у тілі запиту - якщо відправляється FormData з форми, де є @csrf.

Laravel 13 перевіряє ще й джерело запиту. Middleware PreventRequestForgery пропускає запит без токена, якщо браузер надіслав заголовок Sec-Fetch-Site: same-origin. Сучасні браузери додають його самі, а підробити з іншого сайту неможливо. Токен лишається запасним механізмом для старих браузерів і запитів без цього заголовка.

Помилка 419 (Page Expired / CSRF token mismatch) - токен не передано, він застарів (сесія закінчилася, поки сторінка була відкрита) або сесійна cookie не дійшла. Для довго відкритих сторінок варто обробляти 419: оновити токен чи запропонувати перезавантажити сторінку.

SPA на іншому піддомені зі Sanctum:

await fetch('https://api.example.com/sanctum/csrf-cookie', { credentials: 'include' });

await fetch('https://api.example.com/api/orders', {
  method: 'POST',
  credentials: 'include',
  headers: { 'X-XSRF-TOKEN': decodeURIComponent(getCookie('XSRF-TOKEN')), Accept: 'application/json' },
  body: JSON.stringify(order),
});

Потрібні також supports_credentials у config/cors.php, домен SPA у SANCTUM_STATEFUL_DOMAINS і спільний домен сесійних cookies.

Без Accept: application/json Laravel на помилку валідації відповість редиректом, а не JSON з помилками.

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

Оператор ?. звертається до властивості чи викликає метод, лише якщо значення ліворуч не null і не undefined. Інакше весь вираз повертає undefined замість TypeError.

const city = order?.customer?.address?.city;

// раніше
const city = order && order.customer && order.customer.address && order.customer.address.city;

Три форми:

user?.name             // властивість
user?.['first-name']   // обчислена властивість чи індекс
callback?.()           // виклик, якщо функція існує
user.save?.()          // виклик методу, якщо він є

Коротке замикання. Якщо ?. зустрів null/undefined, решта ланцюжка не обчислюється:

null?.a.b.c;               // undefined, а не TypeError на .b
user?.profile.update(log()); // log() не викликається, якщо user - null

Поєднання з ?? - значення за замовчуванням:

const theme = settings?.ui?.theme ?? 'light';
const total = response?.meta?.total ?? 0;

Обмеження:

  • ліворуч від присвоєння не працює: user?.name = 'Оля' - синтаксична помилка;
  • з new не працює: new Foo?.();
  • ?.() захищає лише від null/undefined, а не від «не функції»: { f: 42 }.f?.() кине TypeError: f is not a function.

Де ?. приховує помилки:

function renderInvoice(order) {
  const total = order?.items?.reduce((sum, item) => sum + item.price, 0);
  return `Разом: ${total} грн`;   // 'Разом: undefined грн'
}

Якщо order не повинен бути порожнім, ?. ховає баг: замість зрозумілої помилки в місці, де дані зламалися, отримуємо undefined у інтерфейсі чи NaN у розрахунку - далеко від справжньої причини.

Правило: ?. - для значень, відсутність яких очікувана й нормальна (необов'язкові поля API, опційні колбеки, елемент, якого може не бути на сторінці). Для обов'язкових даних краще явна перевірка з помилкою на вході:

if (!order) throw new Error('Замовлення не завантажено');

TypeScript робить це правило природним: для необов'язкових типів (address?: Address) він вимагає ?., а для обов'язкових - не дозволяє безпідставно сподіватися на «а раптом там null».

Аналог у PHP 8 - nullsafe-оператор $order?->customer?->address.

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

Префікс # робить поле, метод чи аксесор класу справді приватним: доступ до нього можливий лише з коду всередині тіла класу.

class Cart {
  #items = [];
  static #instances = 0;

  constructor() {
    Cart.#instances++;
  }

  add(product, qty = 1) {
    this.#validate(qty);
    this.#items.push({ product, qty });
  }

  get total() {
    return this.#items.reduce((sum, { product, qty }) => sum + product.price * qty, 0);
  }

  #validate(qty) {
    if (qty <= 0) throw new RangeError('Кількість має бути додатною');
  }
}

const cart = new Cart();
cart.add({ price: 100 }, 2);
cart.total;     // 200
cart.#items;    // SyntaxError - навіть не помилка під час виконання, а помилка розбору

Чим відрізняється від конвенції _items:

  • _items - лише домовленість: властивість доступна, видна в Object.keys, у JSON.stringify, її можна змінити ззовні;
  • #items - приватність гарантована мовою: не видно в Object.keys, не серіалізується в JSON, не доступна через obj['#items'], проксі й рефлексію.

Корисні можливості:

  • перевірка «бренду» - чи об'єкт створено цим класом, без instanceof (якого можна обдурити підміною прототипу):
class Money {
  #amount;
  static isMoney(value) {
    return #amount in value;
  }
}
  • статичні приватні поля й методи (static #instances);
  • статичні блоки ініціалізації (static { ... }) - мають доступ до приватних елементів класу.

Обмеження й підводні камені:

  • приватні поля не наслідуються в доступі: підклас не бачить #items батьківського класу. Для «захищених» (як protected у PHP) окремого синтаксису немає;
  • Proxy над об'єктом з приватними полями ламає методи: метод, викликаний через проксі, отримує this = проксі, а в проксі немає приватних полів - TypeError. Це помітно в реактивних системах: Vue робить об'єкти реактивними через Proxy, тож класи з # полями в reactive() можуть падати. Для таких класів - markRaw чи shallowRef;
  • приватні поля оголошуються в тілі класу - додати їх динамічно неможливо;
  • structuredClone і JSON.stringify їх не копіюють.

TypeScript має і модифікатор private, і #. private - лише перевірка компілятора, після компіляції поле звичайне; # - справжня приватність під час виконання.

Докладніше в документації: Приватні елементи класу

Symbol - примітивний тип для унікальних ідентифікаторів. Кожен виклик Symbol() створює значення, що не дорівнює жодному іншому, навіть з тим самим описом:

const id1 = Symbol('id');
const id2 = Symbol('id');
id1 === id2;   // false

Опис ('id') - лише для налагодження, на унікальність не впливає.

Символи як ключі властивостей - не конфліктують з жодними іншими ключами:

const internal = Symbol('internal');

const user = { name: 'Оля', [internal]: { loadedAt: Date.now() } };

Object.keys(user);        // ['name'] - символьний ключ прихований
JSON.stringify(user);     // '{"name":"Оля"}'
user[internal];           // доступ лише з посиланням на сам символ

Бібліотека може додати службові дані до чужого об'єкта, не ризикуючи зіткнутися з його полями і не потрапляючи в серіалізацію чи for...in. Але символьні ключі не приватні: Object.getOwnPropertySymbols() чи Reflect.ownKeys() їх покажуть.

Глобальний реєстр - Symbol.for(key) повертає той самий символ для того самого ключа в усьому застосунку (навіть між iframe):

Symbol.for('app.cache') === Symbol.for('app.cache');   // true

Найважливіше практичне застосування - вбудовані (well-known) символи. Через них ваші об'єкти підключаються до механізмів мови:

  • Symbol.iterator - об'єкт стає ітерованим (for...of, spread):
class Range {
  constructor(from, to) { this.from = from; this.to = to; }
  *[Symbol.iterator]() {
    for (let n = this.from; n <= this.to; n++) yield n;
  }
}
[...new Range(1, 3)];   // [1, 2, 3]
  • Symbol.asyncIterator - для for await...of;
  • Symbol.toPrimitive - перетворення на число чи рядок;
  • Symbol.toStringTag - що показує Object.prototype.toString ([object Money]);
  • Symbol.hasInstance - поведінка instanceof;
  • Symbol.dispose / Symbol.asyncDispose - для явного керування ресурсами (using).

Ще застосування:

  • значення-«константи», які гарантовано не збігаються з даними: const NOT_FOUND = Symbol('not found') замість null чи -1, які можуть бути реальними значеннями;
  • ключі для provide/inject у Vue - щоб різні бібліотеки не перезаписали одна одній залежності.

Обмеження: символ не перетворюється неявно на рядок (`${sym}` - TypeError), лише явно через String(sym) чи sym.description.

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

Гетер - метод, який викликається при читанні властивості. Сетер - при записі. Ззовні це виглядає як звичайна властивість, без дужок.

class Temperature {
  #celsius = 0;

  get fahrenheit() {
    return this.#celsius * 9 / 5 + 32;
  }

  set fahrenheit(value) {
    this.#celsius = (value - 32) * 5 / 9;
  }

  get celsius() {
    return this.#celsius;
  }

  set celsius(value) {
    if (value < -273.15) throw new RangeError('Нижче абсолютного нуля');
    this.#celsius = value;
  }
}

const t = new Temperature();
t.celsius = 25;
t.fahrenheit;       // 77
t.fahrenheit = 32;
t.celsius;          // 0

У об'єктних літералах так само:

const user = {
  first: 'Оля',
  last: 'Коваль',
  get fullName() { return `${this.first} ${this.last}`; },
};

Де доречні:

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

Підводні камені:

  • гетер без сетера - запис мовчки ігнорується в нестрогому режимі й кидає TypeError у строгому (модулі й класи завжди строгі);
  • дорогий гетер виглядає як дешеве поле. list.total у циклі на тисячу ітерацій, що щоразу перераховує суму, - прихована проблема продуктивності. Важкі обчислення краще робити явним методом чи кешувати;
  • побічні ефекти в гетері - погана практика: читання властивості не повинно нічого змінювати;
  • рекурсія: сетер set name(v) { this.name = v; } викличе сам себе нескінченно - зберігати значення треба в іншому полі (#name);
  • серіалізація: JSON.stringify викликає гетери власних властивостей об'єкта-літерала, але гетери класу живуть у прототипі й не потрапляють у JSON. Для класу потрібен toJSON().

Низькорівнево гетери й сетери - це дескриптори властивостей: Object.defineProperty(obj, 'x', { get() {...}, set(v) {...} }). Саме так працювала реактивність Vue 2 - кожна властивість даних перетворювалася на пару гетер/сетер, що відстежувала читання й записи.

У Vue 3 аналог - computed, у Laravel - аксесори й мутатори моделі (Attribute::make(get: ..., set: ...)).

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

vi.fn() - функція-шпигун: запам'ятовує виклики й може повертати задане значення.

const onSave = vi.fn();
saveForm({ name: 'Олена' }, onSave);

expect(onSave).toHaveBeenCalledOnce();
expect(onSave).toHaveBeenCalledWith({ name: 'Олена' });

vi.spyOn() - стежити за методом існуючого об'єкта чи підмінити його:

const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
// ...
expect(spy).toHaveBeenCalled();

vi.mock() - замінити весь модуль:

import { getRates } from './api';
import { convert } from './converter';

vi.mock('./api', () => ({
  getRates: vi.fn().mockResolvedValue({ USD: 41.5 }),
}));

it('конвертує за курсом', async () => {
  expect(await convert(100, 'USD')).toBe(4150);
});

vi.mock піднімається на початок файлу - він виконується до імпортів, навіть якщо записаний нижче. Тому всередині фабрики не можна використовувати змінні з файлу: для цього є vi.hoisted().

fetch - підмінити глобальну функцію:

vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
  ok: true,
  json: async () => ({ id: 1 }),
}));

Для багатьох запитів зручніше MSW (Mock Service Worker): він перехоплює запити на мережевому рівні, і код працює зі справжнім fetch.

Фейкові таймери - не чекати по-справжньому:

it('зберігає чернетку через 2 секунди', () => {
  vi.useFakeTimers();
  const save = vi.fn();
  const autosave = debounce(save, 2000);

  autosave('текст');
  vi.advanceTimersByTime(1999);
  expect(save).not.toHaveBeenCalled();

  vi.advanceTimersByTime(1);
  expect(save).toHaveBeenCalledWith('текст');

  vi.useRealTimers();
});

vi.setSystemTime(new Date('2026-10-04')) фіксує «поточну» дату - для коду, що залежить від Date.now().

Прибирати за собою: моки, що «протікають» між тестами, - головна причина тестів, які падають лише в певному порядку:

afterEach(() => {
  vi.restoreAllMocks();
  vi.unstubAllGlobals();
});

Або в конфігурації: restoreMocks: true.

Міра: мокайте межі системи - мережу, час, сховище, сторонні SDK. Якщо в тесті замоковано все, він перевіряє лише те, що моки викликаються, а не те, що код працює.

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

Код, що працює з DOM (Alpine-компоненти, скрипти на сторінках Blade, веб-компоненти), потребує документа. У Node.js його немає - тому тест запускають у середовищі, що імітує браузер.

Середовища Vitest:

Середовище Що це Особливості
node без DOM (за замовчуванням) для чистої логіки
jsdom реалізація DOM на JavaScript найповніша сумісність, повільніший
happy-dom легша реалізація DOM швидший, але деякі API відсутні чи поводяться інакше
Browser Mode справжній браузер через Playwright реальний рендеринг, макет, події
// vitest.config.js
export default defineConfig({
  test: { environment: 'jsdom' },
});

Чи лише для одного файлу - коментарем на початку: // @vitest-environment happy-dom.

Що jsdom і happy-dom не вміють: макет і розміри (getBoundingClientRect повертає нулі), прокрутку, IntersectionObserver, CSS-анімації, справжню навігацію. Тести, що від цього залежать, - для браузера.

DOM Testing Library - пошук елементів так, як їх бачить користувач:

import { screen, within } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { mountSubscribeForm } from './subscribe';

it('показує помилку для неправильної пошти', async () => {
  document.body.innerHTML = '<div id="app"></div>';
  mountSubscribeForm(document.getElementById('app'));

  const user = userEvent.setup();
  await user.type(screen.getByLabelText('Email'), 'not-an-email');
  await user.click(screen.getByRole('button', { name: 'Підписатися' }));

  expect(await screen.findByRole('alert')).toHaveTextContent('Неправильна адреса');
});

Пріоритет запитів:

  1. getByRole з name - як елемент бачать допоміжні технології;
  2. getByLabelText - поля форм;
  3. getByText - звичайний текст;
  4. getByTestId - останній варіант, коли нічого іншого немає.

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

getBy / queryBy / findBy: getBy кидає помилку, якщо елемента немає; queryBy повертає null (для перевірки відсутності); findBy - асинхронний, чекає появи елемента.

@testing-library/jest-dom додає зручні матчери: toBeVisible, toBeDisabled, toHaveValue, toHaveTextContent (працює і з Vitest).

Не тестуйте розмітку: перевірки на кшталт «третій div має клас error» ламаються від кожної зміни верстки, хоча поведінка не змінилася.

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

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

Рівні
Junior 37 Middle 41 Senior 36

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