Питання на співбесіді з 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 }) - тоді редактор підказує назви й ловить друкарські помилки.
Власні класи помилок дають змогу розрізняти помилки за типом, а не за текстом повідомлення, і передавати додаткові дані.
class HttpError extends Error {
constructor(response, options) {
super(`HTTP ${response.status} для ${response.url}`, options);
this.name = 'HttpError';
this.status = response.status;
}
}
class ValidationError extends Error {
constructor(errors) {
super('Дані не пройшли перевірку');
this.name = 'ValidationError';
this.errors = errors; // { email: ['Поле обов'язкове'] }
}
}
this.name варто задавати явно: саме воно відображається в консолі й стеку (ValidationError: Дані не пройшли перевірку). Без нього всі помилки виглядатимуть як Error.
Обробка за типом:
async function request(url, options) {
const response = await fetch(url, options);
if (response.status === 422) {
throw new ValidationError((await response.json()).errors);
}
if (!response.ok) {
throw new HttpError(response);
}
return response.json();
}
try {
await request('/api/orders', { method: 'POST', body });
} catch (error) {
if (error instanceof ValidationError) {
showFieldErrors(error.errors); // формат помилок валідації Laravel
} else if (error instanceof HttpError && error.status === 403) {
showError('Недостатньо прав');
} else {
throw error;
}
}
cause (ES2022) - посилання на початкову помилку, що спричинила поточну. Передається другим аргументом конструктора:
try {
config = JSON.parse(await readConfig());
} catch (error) {
throw new Error('Не вдалося завантажити конфігурацію', { cause: error });
}
Навіщо: на вищому рівні потрібне зрозуміле повідомлення («не вдалося завантажити конфігурацію»), але без втрати технічної причини («Unexpected token } in JSON at position 42»). Раніше доводилося або губити початкову помилку, або склеювати повідомлення рядками. Тепер ланцюжок причин зберігається повністю, і DevTools та Sentry показують його.
Це аналог previous у винятках PHP (new RuntimeException('...', previous: $e)).
Що варто знати:
instanceofпрацює з класами, що наслідуютьError, - у сучасних середовищах без хитрощів (раніше Babel ламав це при компіляції в ES5);- не варто створювати клас на кожну дрібницю - лише там, де код по-різному реагує на помилки;
- помилки, що перетинають межі (з воркера, з
postMessage, з серверу), втрачають клас - при серіалізації залишаються лишеnameіmessage. Тип доведеться відновлювати заnameчи кодом.
Якщо Promise відхилено, а обробника помилки (catch, другого аргументу then, try навколо await) немає, помилка стає необробленою.
У браузері сторінка продовжує працювати, але:
- у консолі з'являється
Uncaught (in promise) Error: ...; - на
windowспрацьовує подіяunhandledrejection.
У Node.js з версії 15 необроблене відхилення завершує процес з помилкою. Сервер на Node, де забули catch, падає від першого ж збою мережі.
Глобальні обробники в браузері:
// необроблені винятки в синхронному коді й колбеках
window.addEventListener('error', (event) => {
report({
message: event.message,
source: event.filename,
line: event.lineno,
stack: event.error?.stack,
});
});
// необроблені відхилення Promise
window.addEventListener('unhandledrejection', (event) => {
report({ message: String(event.reason), stack: event.reason?.stack });
// event.preventDefault() - прибрати повідомлення з консолі
});
event.reason - те, з чим відхилено Promise (зазвичай об'єкт Error, але може бути будь-що).
Подія rejectionhandled спрацьовує, якщо обробник додали пізніше, ніж браузер зафіксував відхилення як необроблене. Буває при «лінивому» підключенні catch.
Що ці обробники дають і чого ні:
- це останній рубіж для моніторингу, а не спосіб обробляти помилки. Зловити тут помилку й «продовжити» неможливо: функцію, що впала, уже не повернути;
- саме так працюють Sentry, Flare та інші системи: підписуються на ці події й надсилають звіти на сервер;
- помилки зі скриптів іншого домену (CDN) приходять як
Script error.без деталей - браузер приховує їх з міркувань безпеки. Деталі з'являться, якщо скрипт підключено зcrossorigin="anonymous"і сервер віддає заголовок CORS.
Типові джерела необроблених відхилень:
fetch(...).then(...)безcatchнаприкінці ланцюжка;- виклик async-функції без
await(«запустив і забув»):saveDraft();в обробнику події; Promise.all, у якому відхилився один Promise, - решта відхилень після першого вже ігноруються, але перше має бути оброблене.
Правило: кожен ланцюжок Promise має закінчуватися обробкою помилки, а «запущені й забуті» async-виклики - явним .catch(report). Лінтер (@typescript-eslint/no-floating-promises) знаходить такі місця автоматично.
Об'єкт console має значно більше, ніж log, і правильний метод часто економить час налагодження.
Рівні повідомлень - фільтруються в DevTools:
console.debug('деталі'); // приховано за замовчуванням (рівень Verbose)
console.info('інформація');
console.warn('попередження'); // жовте, зі стеком
console.error('помилка'); // червоне, зі стеком
console.table - масив об'єктів таблицею:
console.table(users, ['id', 'name', 'email']); // лише вибрані колонки
console.group / groupCollapsed / groupEnd - вкладені групи повідомлень:
console.groupCollapsed(`Замовлення ${order.id}`);
console.log('товари', order.items);
console.log('доставка', order.shipping);
console.groupEnd();
console.time / timeLog / timeEnd - вимір часу:
console.time('рендер');
renderList(items);
console.timeEnd('рендер'); // рендер: 48.2 ms
console.count - скільки разів виконався рядок (корисно для пошуку зайвих рендерів):
console.count('render ProductCard');
console.trace - стек викликів у цьому місці: відповідає на питання «хто викликав цю функцію?».
console.assert(condition, ...) - пише помилку, лише якщо умова хибна:
console.assert(total >= 0, 'Від\'ємна сума', { total, items });
console.dir(element) - DOM-елемент як об'єкт з властивостями, а не як HTML.
Підстановки й стилі:
console.log('%cУвага', 'color: red; font-weight: bold', 'звичайний текст');
Пастка - лінивий показ об'єктів. console.log(obj) показує об'єкт за посиланням: коли ви розгорнете його в консолі пізніше, побачите поточний стан, а не стан на момент виклику. Якщо об'єкт змінюється, логуйте знімок: console.log(structuredClone(obj)) чи JSON.stringify(obj).
Утиліти, доступні лише в консолі DevTools: $0 (виділений елемент), $$('selector') (масив елементів), copy(obj) (скопіювати в буфер), monitorEvents(window, 'resize').
На продакшені console.* у коді - шум і інколи витік даних. Збирач може прибрати їх (у Vite - опція esbuild.drop: ['console', 'debugger']), а для справжніх помилок потрібна система моніторингу, а не консоль користувача.
try...catch ловить лише помилки, що виникають під час виконання блоку try - поки цей код на стеку викликів.
try {
setTimeout(() => {
null.x; // помилка тут
}, 0);
} catch (error) {
console.log('спіймано'); // НЕ виконається
}
Чому: setTimeout лише реєструє колбек і одразу повертає керування. Блок try завершується без помилок. Колбек виконується пізніше, з власного стеку, коли цикл подій дійде до таймера. Зовнішнього try на цьому стеку вже немає. Помилка стає необробленою й іде в глобальний обробник window.onerror.
Те саме для:
- обробників подій (
addEventListener); - колбеків старих асинхронних API;
then-колбеків Promise, якщо не підключеноcatch.
Як правильно:
1. try всередині колбека:
setTimeout(() => {
try {
riskyWork();
} catch (error) {
report(error);
}
}, 0);
2. Перетворити на Promise і використовувати await:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
try {
await sleep(100);
riskyWork(); // тепер у тій самій async-функції - try спрацює
} catch (error) {
report(error);
}
await «зшиває» асинхронні кроки в одну логічну послідовність: після очікування виконання продовжується всередині того самого try.
3. Але await має бути:
try {
saveDraft(); // async-функція без await
} catch (error) { // не спіймає відхилення
}
try {
await saveDraft(); // спіймає
} catch (error) {
}
4. return з try в async-функції: return fetchData() повертає Promise без очікування - відхилення пройде повз catch цієї функції. return await fetchData() в try - спіймає.
Обробники подій - помилку краще ловити в самому обробнику або загорнути обробники обгорткою:
const safe = (handler) => async (event) => {
try {
await handler(event);
} catch (error) {
report(error);
showToast('Щось пішло не так');
}
};
button.addEventListener('click', safe(onSave));
Загальне правило: помилку можна спіймати лише там, де її очікують. В асинхронному коді очікування - це await чи catch на відповідному Promise.
Докладніше в документації: Керування потоком і обробка помилок
Браузер застосовує політику одного джерела (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 не потрібен.
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(). Він простіший для маршрутизаторів, але підтримується ще не всіма браузерами.
Обидві технології дають змогу серверу надсилати дані в браузер без запиту від клієнта. Різниця - у напрямку, протоколі й складності.
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 чи окремий сервіс.
Опція 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 з помилками.
Оператор ?. звертається до властивості чи викликає метод, лише якщо значення ліворуч не 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.
Префікс # робить поле, метод чи аксесор класу справді приватним: доступ до нього можливий лише з коду всередині тіла класу.
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.
Гетер - метод, який викликається при читанні властивості. Сетер - при записі. Ззовні це виглядає як звичайна властивість, без дужок.
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: ...)).
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. Якщо в тесті замоковано все, він перевіряє лише те, що моки викликаються, а не те, що код працює.
Код, що працює з 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('Неправильна адреса');
});
Пріоритет запитів:
getByRoleзname- як елемент бачать допоміжні технології;getByLabelText- поля форм;getByText- звичайний текст;getByTestId- останній варіант, коли нічого іншого немає.
Пошук за роллю водночас перевіряє доступність: якщо кнопку не знайти за роллю й назвою, її не знайде і програма читання з екрана.
getBy / queryBy / findBy: getBy кидає помилку, якщо елемента немає; queryBy повертає null (для перевірки відсутності); findBy - асинхронний, чекає появи елемента.
@testing-library/jest-dom додає зручні матчери: toBeVisible, toBeDisabled, toHaveValue, toHaveTextContent (працює і з Vitest).
Не тестуйте розмітку: перевірки на кшталт «третій div має клас error» ламаються від кожної зміни верстки, хоча поведінка не змінилася.
Питання з реальних технічних співбесід - 114 питань у 10 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Браузерні API й мережа 12 Сучасний синтаксис 12 Асинхронність 12 Типи й приведення 12 DOM і події 12 Масиви й об'єкти 12 Функції й замикання 12 Тестування 10
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії