Питання на співбесіді: Масиви й об'єкти
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
12 питань
Усі обходять масив і викликають функцію для кожного елемента:
map- новий масив тієї ж довжини з перетвореними елементами.filter- новий масив лише з тими елементами, для яких функція повернула істинне значення.reduce- згортає масив в одне значення: суму, об'єкт, інший масив.forEach- нічого не повертає (undefined), лише виконує дію для кожного елемента.
const orders = [{ total: 100, paid: true }, { total: 250, paid: false }, { total: 40, paid: true }];
orders.map((o) => o.total); // [100, 250, 40]
orders.filter((o) => o.paid); // два оплачені
orders.reduce((sum, o) => sum + o.total, 0); // 390
orders.forEach((o) => console.log(o.total)); // лише побічна дія
Типові помилки:
mapзаради побічної дії, без використання результату. Для цього єforEachчиfor...of.reduceбез початкового значення на порожньому масиві кидаєTypeError.- Перервати
forEachнеможливо (breakне працює). Якщо треба зупинитися, -for...of,some()чиfind().
Усі чотири не змінюють початковий масив (хіба що сам колбек зробить це навмисно), тож їх зручно з'єднувати в ланцюжок: orders.filter(...).map(...).
Об'єкти й масиви в змінних зберігаються за посиланням. const b = a - це не копія, а друге ім'я для того самого об'єкта.
Поверхнева копія копіює лише перший рівень. Вкладені об'єкти лишаються спільними:
const user = { name: 'Оля', address: { city: 'Київ' } };
const copy = { ...user }; // або Object.assign({}, user)
copy.name = 'Іра'; // user.name не змінився
copy.address.city = 'Львів'; // user.address.city теж став 'Львів'!
Глибока копія копіює всі рівні:
const deep = structuredClone(user);
deep.address.city = 'Одеса'; // user не зачеплено
structuredClone (вбудований у браузери й Node.js 17+) коректно копіює Date, Map, Set, вкладені масиви й навіть циклічні посилання. Не копіює функції, DOM-вузли й прототипи класів: екземпляр класу стане звичайним об'єктом.
Старий прийом JSON.parse(JSON.stringify(obj)) має купу обмежень: Date перетворюється на рядок, undefined, функції й Map зникають, циклічні посилання дають помилку.
На практиці глибока копія потрібна рідше, ніж здається: у React, Vue чи Redux змінений стан зазвичай будують поверхневими копіями лише тих гілок, що змінилися, - це й дешевше, і дозволяє швидко порівнювати посилання.
Метод залежить від того, що шукаємо і що потрібно отримати.
includes(value) - чи є значення в масиві, true/false. Для примітивів (рядки, числа):
const roles = ['admin', 'editor'];
roles.includes('admin'); // true
indexOf(value) - позиція першого входження або -1. Колись це був єдиний спосіб перевірки (indexOf(x) !== -1), тепер для перевірки є includes. Відмінність у краєвому випадку: includes знаходить NaN, indexOf - ні.
some(predicate) - чи є хоча б один елемент, що задовольняє умову. Для об'єктів, де шукаємо за властивістю:
const users = [{ id: 1, active: false }, { id: 2, active: true }];
users.some((user) => user.active); // true
find(predicate) - сам елемент (перший відповідний) або undefined; findIndex - його позиція або -1. Є й варіанти з кінця: findLast, findLastIndex.
const user = users.find((user) => user.id === 2);
every(predicate) - протилежність some: чи всі елементи задовольняють умову.
Типова помилка - includes з об'єктами:
users.includes({ id: 1, active: false }); // false
Об'єкти порівнюються за посиланням, а не за вмістом. Новий об'єкт з тими самими полями - інший об'єкт. Для об'єктів використовують some/find з умовою.
Продуктивність. Усі ці методи перебирають масив, тобто працюють за O(n). Якщо перевірок багато (наприклад, у циклі по іншому масиву), краще один раз побудувати Set чи Map:
const allowed = new Set(roles);
items.filter((item) => allowed.has(item.role)); // has - O(1)
Object.keys, Object.values, Object.entries повертають масиви власних перелічуваних властивостей об'єкта:
const prices = { coffee: 50, tea: 35 };
Object.keys(prices); // ['coffee', 'tea']
Object.values(prices); // [50, 35]
Object.entries(prices); // [['coffee', 50], ['tea', 35]]
for (const [name, price] of Object.entries(prices)) {
console.log(`${name}: ${price} грн`);
}
Оскільки це масиви, до них можна застосувати map, filter, reduce. А назад у об'єкт - через Object.fromEntries:
const withVat = Object.fromEntries(
Object.entries(prices).map(([name, price]) => [name, price * 1.2]),
);
for...in перебирає ключі, але разом з успадкованими перелічуваними властивостями з ланцюжка прототипів:
for (const key in prices) {
if (Object.hasOwn(prices, key)) { /* ... */ }
}
Для звичайних об'єктів успадкованих перелічуваних властивостей зазвичай немає, але якщо якась бібліотека додала метод в Object.prototype, for...in його побачить. Тому в сучасному коді частіше пишуть Object.entries + for...of.
Не плутати for...in і for...of:
for...in- ключі (для масиву - індекси як рядки'0','1');for...of- значення ітерованих об'єктів (масиви, рядки,Map,Set). Звичайний об'єкт не ітерований,for...ofпо ньому кинеTypeError.
Порядок ключів визначений: спершу цілочисельні ключі за зростанням, потім рядкові в порядку додавання.
Object.keys({ b: 1, 2: 1, a: 1, 1: 1 }); // ['1', '2', 'b', 'a']
Це пастка для об'єктів, де ключами є id: { 10: ..., 2: ... } перебереться як 2, 10, а не в порядку додавання. Якщо порядок важливий, краще Map чи масив.
Символьні ключі жоден з цих способів не повертає - для них є Object.getOwnPropertySymbols чи Reflect.ownKeys.
Обидва зберігають пари «ключ - значення», але Map створений саме як словник, а об'єкт - як запис з полями.
Відмінності:
- Ключі. У об'єкта ключі - лише рядки й символи (число
1стане рядком'1'). УMapключем може бути будь-що: об'єкт, функція,NaN. - Розмір.
map.size- одразу; для об'єкта -Object.keys(obj).length. - Порядок.
Mapзберігає порядок вставки. У об'єкта порядок теж визначений, але з винятком: ключі, схожі на цілі числа, йдуть першими за зростанням. - Немає успадкованих ключів. Звичайний об'єкт має прототип, і ключі на кшталт
constructorчи__proto__можуть дати сюрпризи.Mapпорожній по-справжньому. - Швидкодія на частих додаваннях і видаленнях у
Mapзазвичай краща. - JSON.
JSON.stringify(map)дає{}- його треба перетворювати:Object.fromEntries(map).
const visits = new Map();
const button = document.querySelector('#buy');
visits.set(button, 1); // ключ - DOM-елемент
visits.set(button, visits.get(button) + 1);
visits.has(button); // true
for (const [element, count] of visits) { /* ... */ }
Коли що: об'єкт - для структур з відомими полями ({ id, name, email }), JSON і конфігурацій. Map - для словників з динамічними ключами, частих змін і ключів-не-рядків. Для набору унікальних значень - Set.
Змінюють масив на місці (мутують): push, pop, shift, unshift, splice, sort, reverse, fill, copyWithin.
Повертають новий, не чіпаючи початковий: map, filter, slice, concat, flat, flatMap, а також нові (ES2023) toSorted, toReversed, toSpliced і with.
const scores = [30, 10, 20];
const sorted = scores.sort((a, b) => a - b);
// scores теж став [10, 20, 30]: sort змінює масив і повертає його ж
const safe = [30, 10, 20];
const sortedCopy = safe.toSorted((a, b) => a - b); // safe лишився [30, 10, 20]
const replaced = safe.with(0, 99); // [99, 10, 20], safe не змінився
Чому це важливо:
- У React, Vue, Redux мутація стану на місці ламає виявлення змін: посилання на масив те саме, і компонент може не оновитися. Тому
toSorted()чи[...items].sort()замістьitems.sort(). - Масив з аргументу функції належить коду, який його передав. Відсортувати його на місці - змінити чужі дані непомітно.
Ще пастка sort: без функції порівняння елементи порівнюються як рядки, тож [10, 9, 1].sort() дає [1, 10, 9]. Для чисел потрібен (a, b) => a - b, для українського тексту - (a, b) => a.localeCompare(b, 'uk').
Групування масиву за ознакою довго писали вручну через reduce. З ES2024 для цього є вбудовані функції.
Object.groupBy(items, callback) - результат у звичайному об'єкті, ключі - те, що повернув колбек:
const orders = [
{ id: 1, status: 'paid', total: 100 },
{ id: 2, status: 'new', total: 50 },
{ id: 3, status: 'paid', total: 70 },
];
const byStatus = Object.groupBy(orders, (order) => order.status);
// { paid: [{ id: 1, ... }, { id: 3, ... }], new: [{ id: 2, ... }] }
const bySize = Object.groupBy(orders, ({ total }) => (total >= 70 ? 'large' : 'small'));
Map.groupBy(items, callback) - результат у Map. Потрібен, коли ключ - не рядок: об'єкт, дата, число, яке не має перетворюватися на рядок:
const byCustomer = Map.groupBy(orders, (order) => customersById.get(order.customerId));
byCustomer.get(someCustomer); // замовлення конкретного об'єкта-клієнта
Особливості:
- це статичні функції, а не методи масиву:
Object.groupBy(arr, fn), а неarr.groupBy(fn). Метод масиву планували, але він конфліктував зі старими бібліотеками, що розширювалиArray.prototype; - об'єкт від
Object.groupByмає прототипnull: у нього немаєhasOwnProperty,toStringтощо.result.hasOwnProperty('paid')кине помилку - перевіряйте черезObject.hasOwn(result, 'paid')чи'paid' in result; - групи відсутніх значень просто не створюються - порожніх масивів для «ненайдених» статусів не буде;
- елементи в групах ідуть у порядку вихідного масиву.
Як це робили раніше (і досі доводиться в старих оточеннях):
const byStatus = orders.reduce((groups, order) => {
(groups[order.status] ??= []).push(order);
return groups;
}, {});
Порівняння з Laravel: це аналог collect($orders)->groupBy('status'). Агрегати по групах далі рахують звичайними методами: Object.entries(byStatus).map(([status, list]) => [status, list.length]).
Підтримка: усі сучасні браузери з 2024 року і Node.js 21+. Для старших середовищ - поліфіл (core-js) чи reduce.
Set - колекція унікальних значень. Повторне додавання того самого значення нічого не змінює.
const tags = new Set(['php', 'laravel', 'php']);
tags.size; // 2
tags.add('vue');
tags.has('laravel'); // true, за O(1)
tags.delete('php');
[...tags]; // ['laravel', 'vue'] - порядок додавання зберігається
Найчастіший прийом - прибрати дублікати:
const unique = [...new Set(ids)];
Унікальність визначається як === (з винятком: NaN дорівнює NaN). Тому об'єкти порівнюються за посиланням - два різні об'єкти { id: 1 } обидва потраплять у множину. Для унікальності об'єктів за полем використовують Map з ключем-полем.
Методи множин (ES2025) - нарешті вбудовані операції теорії множин. Кожен повертає новий Set:
const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);
a.union(b); // {1, 2, 3, 4}
a.intersection(b); // {2, 3}
a.difference(b); // {1} - є в a, немає в b
a.symmetricDifference(b); // {1, 4} - є лише в одній з множин
new Set([2]).isSubsetOf(a); // true
a.isSupersetOf(new Set([1])); // true
a.isDisjointFrom(new Set([9])); // true - спільних елементів немає
Практичні застосування:
- синхронізація зв'язків: які теги додати й які прибрати при збереженні форми:
const toAttach = selected.difference(current);
const toDetach = current.difference(selected);
Це те, що в Laravel робить sync() для зв'язків «багато-до-багатьох».
- права:
required.isSubsetOf(userPermissions)- чи має користувач усі потрібні права; - швидкі перевірки належності у фільтрах замість
array.includesу циклі.
Аргументом нових методів може бути не лише Set, а будь-який об'єкт із size, has() і keys() - наприклад, Map (порівнюються ключі). Масив напряму не підходить: a.union([4]) кине помилку, потрібно a.union(new Set([4])).
Підтримка: усі сучасні браузери з 2024 року, Node.js 22+.
Ітератор - об'єкт з методом 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() без колатора, порядок «стрибатиме».