JavaScript: питання на співбесіді рівня Senior
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
36 питань
Сам Promise скасувати не можна - лише проігнорувати його результат. Справжнє скасування роблять через AbortController: операція отримує signal і сама стежить, чи його не скасували.
const controller = new AbortController();
fetch('/api/search?q=laravel', { signal: controller.signal })
.then((r) => r.json())
.then(render)
.catch((error) => {
if (error.name === 'AbortError') return; // скасовано свідомо - не помилка
showError(error);
});
controller.abort(); // запит перервано, з'єднання закрито
Типові сценарії:
- Пошук під час набору: кожна нова літера скасовує попередній запит, щоб старіша відповідь не перезаписала новішу (класична гонка).
- Компонент зник (React-ефект, Vue
onUnmounted) - скасувати його запити й підписки. - Тайм-аут:
fetch(url, { signal: AbortSignal.timeout(5000) }). - Кілька причин скасування одразу:
AbortSignal.any([userSignal, AbortSignal.timeout(5000)]).
Сигнал працює не лише з fetch: addEventListener(type, fn, { signal }) знімає обробник при скасуванні, а у власних функціях можна перевіряти signal.aborted чи слухати подію abort:
async function processAll(items, signal) {
for (const item of items) {
signal.throwIfAborted();
await process(item);
}
}
Важливо: скасування на клієнті не зупиняє вже розпочату роботу на сервері. Якщо запит змінює дані, сервер міг їх уже змінити.
forEach викликає колбек і ігнорує те, що він повертає. async-колбек повертає Promise, який ніхто не чекає: цикл завершується одразу, помилки губляться.
items.forEach(async (item) => { await save(item); });
console.log('готово'); // виведеться ДО того, як щось збережеться
Правильні варіанти залежно від задачі:
// Послідовно, по одному
for (const item of items) {
await save(item);
}
// Усі паралельно
await Promise.all(items.map((item) => save(item)));
Проблема «всіх паралельно»: тисяча елементів - тисяча одночасних запитів. API почне відповідати 429, браузер обмежить кількість з'єднань, база отримає піку навантаження.
Обмежена паралельність - не більше N одночасно:
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const index = next++;
results[index] = await fn(items[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
const saved = await mapLimit(items, 5, save);
Тут next++ безпечний, бо JavaScript однопотоковий: між читанням і збільшенням лічильника інший «воркер» виконатися не може.
На практиці часто беруть готові бібліотеки (p-limit, p-map), які додають ще й обробку помилок і скасування.
fetch за замовчуванням не має тайм-ауту: запит до сервера, що завис, може «висіти» хвилинами. А тимчасові збої мережі чи відповідь 503 часто зникають після повтору.
Тайм-аут через AbortSignal.timeout():
try {
const response = await fetch('/api/report', { signal: AbortSignal.timeout(5000) });
} catch (error) {
if (error.name === 'TimeoutError') {
showError('Сервер не відповів за 5 секунд');
}
}
При тайм-ауті Promise відхиляється з TimeoutError, а при ручному скасуванні через AbortController - з AbortError. Це дає змогу розрізнити причини.
Поєднати тайм-аут і ручне скасування (користувач пішов зі сторінки) - AbortSignal.any():
const controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]);
Повтор з експоненційною затримкою і випадковим розкидом:
async function fetchWithRetry(url, options = {}, { retries = 3, baseDelay = 300 } = {}) {
for (let attempt = 0; ; attempt++) {
try {
const response = await fetch(url, { ...options, signal: AbortSignal.timeout(5000) });
if (response.status < 500 && response.status !== 429) {
return response; // успіх або помилка клієнта - не повторюємо
}
if (attempt >= retries) return response;
} catch (error) {
if (attempt >= retries) throw error;
}
const delay = baseDelay * 2 ** attempt * (0.5 + Math.random());
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
Що важливо:
- повторювати лише те, що має сенс повторювати: мережеві помилки, тайм-аути, 502/503/504, 429. Помилки 4xx (валідація, 403, 404) повтор не виправить;
- ідемпотентність:
GETповторювати безпечно.POSTна створення замовлення - ні: перша спроба могла дійти до сервера, а загубилася лише відповідь. Для таких запитів потрібен ключ ідемпотентності, який сервер перевіряє; - розкид затримки (jitter) потрібен, щоб тисячі клієнтів після збою не повторювали запити синхронно й не «добили» сервер, що відновлюється;
Retry-Afterу відповіді 429/503 варто поважати замість власної затримки;- загальний бюджет часу важливіший за кількість спроб: користувач не чекатиме хвилину на три повтори з тайм-аутом по 20 секунд.
Поки JavaScript виконується, браузер не може обробити клік, натискання клавіші чи перемалювати сторінку. Довга задача (long task) - це шматок роботи головного потоку понад 50 мс. Користувач бачить «завислий» інтерфейс, а метрика INP (Interaction to Next Paint) погіршується.
// обробка 50 000 рядків одним циклом - інтерфейс завмирає на секунду
rows.forEach((row) => renderRow(row));
Async-функція сама по собі не допомагає. await всередині циклу над синхронною роботою не віддає керування браузеру надовго: мікрозадачі виконуються одна за одною до порожньої черги, і браузер між ними не малює.
Рішення 1 - віддавати керування між шматками:
async function processInChunks(items, handle) {
let deadline = performance.now() + 40;
for (const item of items) {
handle(item);
if (performance.now() > deadline) {
await yieldToMain();
deadline = performance.now() + 40;
}
}
}
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield(); // продовження отримує пріоритет
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
scheduler.yield() віддає керування браузеру, але ставить продовження попереду інших задач, тож робота не «загубиться» в черзі. Він підтримується не всюди, тому потрібен запасний варіант через setTimeout.
Рішення 2 - винести обчислення з головного потоку у Web Worker. Підходить для чистих обчислень (парсинг великого файлу, стиснення, криптографія), але воркер не має доступу до DOM.
Рішення 3 - не робити зайвої роботи:
- віртуалізація довгих списків - рендерити лише видимі 30 рядків замість 50 000;
requestIdleCallbackдля некритичної роботи (аналітика, попереднє завантаження) - коли браузеру нічим зайнятися;- debounce для обробників, що спрацьовують на кожне натискання клавіші.
Як знайти довгі задачі: вкладка Performance у DevTools (позначені червоним), або програмно:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) console.warn('Long task', entry.duration);
}).observe({ type: 'longtask', buffered: true });
Ітератор - об'єкт з методом next(), який щоразу повертає { value, done }. Ітерований об'єкт має метод [Symbol.iterator](), що повертає ітератор.
Саме на цьому протоколі працюють for...of, spread ([...x]), деструктуризація, Array.from, Promise.all, конструктори Map і Set. Масиви, рядки, Map, Set, NodeList - ітеровані з коробки; звичайні об'єкти - ні.
Власний ітерований об'єкт найпростіше зробити генератором:
class Range {
constructor(from, to) {
this.from = from;
this.to = to;
}
*[Symbol.iterator]() {
for (let i = this.from; i <= this.to; i++) {
yield i;
}
}
}
[...new Range(1, 5)]; // [1, 2, 3, 4, 5]
for (const n of new Range(1, 3)) console.log(n);
Навіщо це на практиці:
- Ліниві послідовності: значення обчислюються лише тоді, коли їх просять. Можна описати навіть нескінченну послідовність і взяти з неї 10 елементів.
- Однаковий інтерфейс для власних колекцій - їх можна передавати скрізь, де очікується ітероване.
- Асинхронні ітератори (
Symbol.asyncIterator,for await...of) - для потоків даних: сторінки API, рядки великого файлу, потік відповідіfetch.
Нові «ітераторні хелпери» (Iterator.prototype.map, filter, take, drop) дозволяють працювати з ітераторами ліниво, не перетворюючи їх на масив. Вони вже є в сучасних браузерах і Node.js 22+.
Збирач сміття звільняє об'єкт, коли на нього немає посилань. Звичайний Map тримає свої ключі: поки об'єкт лежить у Map як ключ, його не звільнять, навіть якщо більше ніде він не потрібен. Звідси витоки пам'яті в кешах і реєстрах.
WeakMap тримає ключі слабко: запис не заважає збирачу сміття. Коли на об'єкт-ключ не лишилося інших посилань, запис зникає разом з ним.
const metadata = new WeakMap();
function track(element) {
metadata.set(element, { clicks: 0, addedAt: Date.now() });
}
// DOM-елемент видалили зі сторінки й забули - запис у metadata зникне сам
Обмеження WeakMap: ключі - лише об'єкти (і незареєстровані символи), немає size, обходу й clear(). Інакше результат залежав би від того, коли саме пройшов збирач сміття.
Де застосовують:
- приватні дані, прив'язані до чужих об'єктів, яких не можна змінювати;
- кеш результатів для об'єктів (мемоізація за аргументом-об'єктом);
- метадані DOM-вузлів у бібліотеках.
WeakSet - те саме для множини: «чи бачили ми цей об'єкт».
WeakRef - слабке посилання на окремий об'єкт: ref.deref() повертає об'єкт або undefined, якщо його вже зібрано. Разом з FinalizationRegistry дозволяє дізнатися про збирання. Але момент збирання непередбачуваний, тож MDN прямо радить уникати WeakRef там, де можна без нього, і ніколи не будувати на ньому логіку, від якої залежить коректність.
const захищає лише змінну від переприсвоєння, але не вміст об'єкта. Для захисту самого об'єкта є три рівні:
| Метод | Додати властивість | Видалити | Змінити значення |
|---|---|---|---|
Object.preventExtensions |
ні | так | так |
Object.seal |
ні | ні | так |
Object.freeze |
ні | ні | ні |
const config = Object.freeze({ apiUrl: '/api', retries: 3 });
config.retries = 10; // тихо ігнорується
delete config.apiUrl; // тихо ігнорується
config.retries; // 3
Тихо - лише в нестрогому режимі. У строгому режимі (а ES-модулі та класи завжди строгі) спроба змінити заморожений об'єкт кидає TypeError. Це добре: помилка видна одразу.
Заморожування поверхневе:
const settings = Object.freeze({ theme: 'dark', limits: { upload: 10 } });
settings.limits.upload = 1000; // працює!
Вкладені об'єкти лишаються змінюваними. Для глибокого заморожування потрібна рекурсія:
function deepFreeze(object) {
for (const value of Object.values(object)) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
deepFreeze(value);
}
}
return Object.freeze(object);
}
Інші нюанси:
- масиви теж можна заморозити:
push,sort, присвоєння елемента кидають помилку в строгому режимі; Map,Set,Dateзаморожування не захищає: їхні дані зберігаються у внутрішніх слотах, а не у властивостях.Object.freeze(new Map()).set('a', 1)спрацює;- розморозити неможливо - лише створити змінювану копію (
{ ...frozen },structuredClone); - перевірки:
Object.isFrozen,Object.isSealed,Object.isExtensible.
Де це корисно:
- константи й конфігурація, що не повинні змінюватися випадково;
- «перелічення» без TypeScript:
const Status = Object.freeze({ Paid: 'paid', New: 'new' }); - спільний стан, який мають змінювати лише через визначені функції - заморожування ловить випадкові мутації в розробці.
Що не варто робити: заморожувати все підряд «для іммутабельності». Реальну незмінність у застосунках зазвичай забезпечують підходом - нові об'єкти замість зміни старих (toSorted, spread), - а TypeScript з readonly ловить мутації ще на етапі компіляції без витрат під час виконання.
Без компаратора sort() перетворює елементи на рядки і порівнює їх за кодами символів UTF-16:
[10, 9, 1].sort(); // [1, 10, 9] - '10' < '9', бо '1' < '9'
Для чисел потрібна функція порівняння, що повертає від'ємне, нуль чи додатне число:
[10, 9, 1].sort((a, b) => a - b); // [1, 9, 10]
users.sort((a, b) => b.age - a.age); // за спаданням віку
Українські рядки. Порівняння за кодами UTF-16 ламає абетку: літери є, і, ї, ґ мають коди більші за я, бо в Unicode стоять поза основним блоком:
['яблуко', 'їжак', 'ґанок', 'гора', 'єнот'].sort();
// ['гора', 'яблуко', 'єнот', 'їжак', 'ґанок'] - неправильно
Правильно - Intl.Collator з українською локаллю:
const collator = new Intl.Collator('uk');
words.sort(collator.compare);
// ['гора', 'ґанок', 'єнот', 'їжак', 'яблуко']
users.sort((a, b) => collator.compare(a.name, b.name));
Корисні опції колатора:
numeric: true- «природне» сортування чисел у рядках:файл2передфайл10;sensitivity: 'base'- ігнорувати регістр і діакритику при порівнянні (зручно для пошуку збігів);caseFirst: 'upper'- великі літери першими.
a.localeCompare(b, 'uk') дає той самий результат, але створює колатор при кожному виклику. Для сортування великих масивів один Intl.Collator значно швидший.
Інші факти про sort:
- сортування змінює масив на місці і повертає той самий масив. Для копії -
toSorted()(ES2023); - сортування стабільне (гарантовано з ES2019): елементи з однаковим ключем зберігають взаємний порядок. Тому сортування за кількома ключами можна робити послідовно, але простіше одним компаратором:
items.sort((a, b) => a.category.localeCompare(b.category, 'uk') || b.price - a.price);
- компаратор має бути узгодженим: повертати
true/false((a, b) => a > b) - помилка. Результат залежить від рушія і може бути неправильним; undefinedзавжди йдуть у кінець, компаратор для них не викликається.
Сортування на сервері й на клієнті має збігатися. Якщо список посторінково сортує база (з колацією uk), а клієнт досортовує сторінку через sort() без колатора, порядок «стрибатиме».
Кожен об'єкт має приховане посилання на інший об'єкт - прототип (Object.getPrototypeOf(obj)). Якщо властивості немає в самому об'єкті, рушій шукає її в прототипі, потім у прототипі прототипу - і так до null. Це ланцюжок прототипів.
const animal = { speak() { return `${this.name} подає голос`; } };
const dog = Object.create(animal);
dog.name = 'Рекс';
dog.speak(); // знайдено в прототипі; this - dog
Класи - синтаксис над тим самим механізмом:
class Animal {
constructor(name) { this.name = name; }
speak() { return `${this.name} подає голос`; }
}
class Dog extends Animal {
speak() { return `${super.speak()}: гав`; }
}
Під капотом Animal - функція-конструктор, методи лежать в Animal.prototype, а extends будує ланцюжок Dog.prototype → Animal.prototype → Object.prototype. Методи не копіюються в кожен екземпляр - усі екземпляри ділять одні й ті самі методи через прототип.
Що відрізняє класи від «ручних» конструкторів:
- клас не можна викликати без
new; - тіло класу завжди в строгому режимі;
- приватні поля
#secret- справді приватні, на рівні мови, а не за домовленістю; - поля класу (
count = 0) створюються на кожному екземплярі, а не в прототипі.
Пастки:
- Змінюваний об'єкт у прототипі спільний для всіх екземплярів: масив, доданий у прототип, - один на всіх.
- Розширювати вбудовані прототипи (
Array.prototype.myMethod = ...) погано: конфлікти з бібліотеками й майбутніми версіями мови.
Докладніше в документації: Успадкування і ланцюжок прототипів
Обидва обмежують, як часто спрацьовує функція, яку викликають дуже часто: введення тексту, scroll, resize, рух миші.
- Debounce - виконати один раз, коли виклики припинилися на заданий час. Пошук під час набору: запит іде, коли користувач перестав друкувати на 300 мс.
- Throttle - виконувати не частіше ніж раз на заданий проміжок, поки виклики тривають. Позиція скролу для прогрес-бару: раз на 100 мс, а не 60 разів на секунду.
Обидва - класичний приклад замикань: таймер і час останнього виклику живуть у зовнішній функції.
function debounce(fn, wait) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
function throttle(fn, wait) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn.apply(this, args);
}
};
}
input.addEventListener('input', debounce((e) => search(e.target.value), 300));
Що питають на старших рівнях:
- Leading / trailing: чи виконувати функцію на початку серії, наприкінці чи обидва рази. Throttle вище втрачає останній виклик серії - для прогрес-бару це помилка, бо фінальна позиція не потрапить.
- Скасування: debounce у компоненті треба вміти скасувати при його знищенні, інакше колбек спрацює вже після того.
requestAnimationFrame- природний «throttle» для всього, що малює: не частіше за кадр.
Для debounce пошукових запитів додають ще AbortController: пізня відповідь на старий запит не має перезаписати свіжу.
JavaScript звільняє пам'ять автоматично: збирач сміття видаляє об'єкти, до яких неможливо дістатися від «коренів» (глобальних об'єктів, стеку викликів, активних обробників). Витік - це об'єкт, який уже не потрібен, але на який досі хтось посилається.
Замикання тримає змінні своєї області видимості, поки живе сама функція:
function attachHandler(element) {
const hugeReport = buildReport(); // 50 МБ даних
element.addEventListener('click', () => {
console.log(hugeReport.title); // замикання тримає hugeReport
});
}
Поки обробник зареєстрований, hugeReport у пам'яті. Якщо обробнику потрібен лише заголовок - краще захопити лише його: const title = hugeReport.title.
Типові джерела витоків у фронтенді:
- обробники на глобальних об'єктах (
window,document), додані компонентом і не зняті при його знищенні:
// при кожному монтуванні компонента - новий обробник, старі живуть вічно
window.addEventListener('resize', this.onResize);
- таймери:
setInterval, що посилається на дані компонента, працює й після того, як компонент зник зі сторінки; - від'єднані вузли DOM: елемент видалено зі сторінки, але посилання на нього лишилося в масиві, кеші чи замиканні - разом з ним живе все його піддерево;
- кеші без обмеження:
Map, куди дані лише додають; - спостерігачі й підписки:
IntersectionObserver, WebSocket-обробники, підписки на сховище стану.
Як прибирати:
const controller = new AbortController();
window.addEventListener('resize', onResize, { signal: controller.signal });
document.addEventListener('keydown', onKey, { signal: controller.signal });
// при знищенні компонента - одним викликом знімаються всі обробники
controller.abort();
У фреймворках для цього є хуки знищення: onUnmounted у Vue, функція очищення в useEffect React, destroy() в Alpine-компонентах.
Для кешів - WeakMap (запис зникає разом з об'єктом-ключем) чи обмеження розміру з витісненням.
Як знайти витік: у DevTools вкладка Memory - зробити знімок купи, повторити дію кілька разів (відкрити й закрити модальне вікно), зробити ще знімок і порівняти. Об'єкти, кількість яких росте з кожним повтором, і поле Detached для від'єднаних вузлів DOM вказують на винуватця. Панель Retainers показує ланцюжок посилань, що тримає об'єкт.
Особливо важливо для SPA і wire:navigate: сторінка не перезавантажується, тож витоки накопичуються годинами роботи.
Мемоізація - кешування результату функції за її аргументами: повторний виклик з тими самими аргументами повертає збережений результат замість повторного обчислення.
function memoize(fn) {
const cache = new Map();
return (arg) => {
if (!cache.has(arg)) {
cache.set(arg, fn(arg));
}
return cache.get(arg);
};
}
const slowSquare = (n) => { /* важке обчислення */ return n * n; };
const fastSquare = memoize(slowSquare);
Кеш живе в замиканні - прямо до нього ніхто не дістанеться.
Умови, за яких мемоізація коректна:
- функція чиста: результат залежить лише від аргументів і не має побічних ефектів. Мемоізувати
getCurrentUser()чи функцію зDate.now()всередині - отримати застарілі дані; - аргументи можна порівняти як ключ. Для примітивів - просто. Для об'єктів
Mapпорівнює за посиланням: два однакові за вмістом об'єкти - два різні ключі. Для кількох аргументів ключ доводиться будувати (JSON.stringify(args)), а це має свою ціну.
Коли мемоізація шкодить:
- дешеві функції. Перевірка кешу, побудова ключа й зберігання можуть коштувати більше, ніж саме обчислення;
- рідкісні повтори. Якщо аргументи майже завжди нові, кеш лише росте;
- пам'ять. Кеш без обмеження - витік. Для довгоживучих сторінок потрібне обмеження розміру (LRU) або
WeakMap, якщо ключ - об'єкт, і запис має зникати разом з ним; - змінні аргументи. Якщо об'єкт-аргумент змінили «на місці», посилання те саме - кеш поверне результат для старого вмісту.
Мемоізація у фреймворках:
- Vue
computed- мемоізоване значення, що перераховується лише при зміні реактивних залежностей; - React
useMemo,useCallback,memo- зберігають значення чи функцію між рендерами. Найчастіша помилка - обгортати ними все підряд: порівняння залежностей теж коштує, а код стає складнішим. Їх застосовують при виміряній проблемі продуктивності чи для стабільних посилань, від яких залежать інші ефекти; - у Laravel аналог -
once(), що мемоізує результат у межах запиту.
Класичний приклад виправданої мемоізації - рекурсія з перекриттям підзадач (числа Фібоначчі, динамічне програмування): без кешу експоненційна складність, з кешем - лінійна.
Універсального способу немає - для різних типів різні інструменти.
typeof - для примітивів і функцій:
typeof 'x'; // 'string'
typeof 1; // 'number' (і для NaN теж)
typeof 1n; // 'bigint'
typeof undefined; // 'undefined'
typeof Symbol(); // 'symbol'
typeof (() => {}); // 'function'
typeof null; // 'object' - історична помилка
typeof []; // 'object'
Масив: Array.isArray(value), а не typeof чи instanceof.
instanceof - чи є в ланцюжку прототипів конструктор: err instanceof TypeError. Пастка - різні realm'и: масив з iframe чи з іншого vm-контексту в Node.js має інший Array, і instanceof Array дає false. Array.isArray таку проблему не має.
Точний «внутрішній» тип:
Object.prototype.toString.call(new Date()); // '[object Date]'
Object.prototype.toString.call(null); // '[object Null]'
Інші корисні перевірки:
Number.isNaN(x),Number.isInteger(x),Number.isFinite(x)- на відміну від глобальнихisNaN/isFinite, не приводять аргумент до числа.value === null- дляnull.value !== null && typeof value === 'object'- «справжній» об'єкт.
Для даних ззовні (відповідь API, localStorage, форма) ручні перевірки швидко стають громіздкими. Тут використовують схеми валідації (Zod, Valibot): вони одночасно перевіряють дані під час виконання й дають TypeScript-тип.
Number точно представляє цілі числа лише до Number.MAX_SAFE_INTEGER = 2^53 - 1 = 9 007 199 254 740 991. Далі сусідні цілі числа вже неможливо розрізнити:
9007199254740993 === 9007199254740992; // true
BigInt - окремий тип цілих чисел довільної довжини:
const big = 9007199254740993n; // суфікс n
big + 2n; // 9007199254740995n
BigInt('123456789012345678901234567890');
Обмеження BigInt: не можна змішувати з Number в арифметиці (1n + 1 - TypeError), Math.* з ним не працює, ділення відкидає дробову частину, а JSON.stringify кидає помилку.
Реальна проблема - ID з бекенду. Twitter/X ID, Snowflake-ідентифікатори, bigint-ключі з бази можуть перевищувати 2^53. JSON.parse перетворює число на Number і тихо спотворює останні цифри:
JSON.parse('{"id": 1234567890123456789}').id; // 1234567890123456800
Запит з таким ID потім шукає не той запис - і помилка проявляється далеко від причини.
Рішення:
- Віддавати великі ID рядками - найпоширеніший підхід (Twitter API повертає і
id, іid_str). У Laravel - каст до рядка в API Resource. JSON.parseз reviver іcontext.source(нові браузери й Node.js) дає доступ до сирого тексту числа, щоб перетворити його наBigInt.- ID - це ідентифікатор, а не число: арифметика над ним не потрібна, тож рядок - природний тип.
Коли об'єкт опиняється там, де потрібен примітив (+obj, obj + '', `${obj}`, obj > 5, obj == 1), рушій викликає перетворення на примітив з «підказкою» (hint), якого типу очікують:
'number'- арифметика й порівняння:+obj,obj * 2,obj > 5;'string'- шаблонні рядки,String(obj), ключі об'єкта;'default'- коли незрозуміло: бінарний+,==.
Алгоритм:
- якщо є метод
obj[Symbol.toPrimitive](hint)- викликається він; - інакше для
'string'пробуютьсяtoString(), потімvalueOf(); - для
'number'і'default'- спершуvalueOf(), потімtoString(); - перший результат-примітив і використовується. Якщо обидва повернули об'єкт -
TypeError.
const price = {
amount: 42,
valueOf() { return this.amount; },
toString() { return `${this.amount} грн`; },
};
price + 1; // 43 (default → valueOf)
price * 2; // 84 (number → valueOf)
`${price}`; // '42 грн' (string → toString)
String(price); // '42 грн'
Повний контроль - Symbol.toPrimitive:
const money = {
[Symbol.toPrimitive](hint) {
if (hint === 'number') return 42;
if (hint === 'string') return '42 грн';
return 'default';
},
};
+money; // 42
`${money}`; // '42 грн'
money + ''; // 'default'
Як це пояснює «дивацтва» JavaScript:
[] + []→'': масиви черезtoString()стають порожніми рядками;[] + {}→'[object Object]';[5] * 2→10:[5].toString()='5', потім число;Date- єдиний вбудований об'єкт, для якого'default'означає рядок:date + 1дає рядок з датою й одиницею, аdate - 1- число (мітку часу мінус 1).
Практична цінність:
- порівняння дат
a < bпрацює черезvalueOf(), що повертає мілісекунди; - власні числові типи (гроші, вектори) можуть поводитися природно в шаблонах, але арифметику через
valueOfкраще не будувати:price1 + price2дасть число й «загубить» валюту. Явні методи (add,format) надійніші; - в об'єктах-значеннях, що потрапляють у шаблони, корисний змістовний
toString()- замість[object Object]у логах і повідомленнях.
Питання рівня Senior з реальних технічних співбесід - 36 питань у 10 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 46 відкритих вакансій рівня Senior. Переглянути вакансії