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

Питання на співбесіді: Масиви й об'єкти

Питання з реальних співбесід з відповідями: 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(...).

Докладніше в документації: Array.prototype.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 змінений стан зазвичай будують поверхневими копіями лише тих гілок, що змінилися, - це й дешевше, і дозволяє швидко порівнювати посилання.

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

Метод залежить від того, що шукаємо і що потрібно отримати.

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)

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

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.

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

Обидва зберігають пари «ключ - значення», але 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.

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

Змінюють масив на місці (мутують): 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').

Докладніше в документації: Array.prototype.toSorted

Групування масиву за ознакою довго писали вручну через 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.

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

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+.

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

Ітератор - об'єкт з методом 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 там, де можна без нього, і ніколи не будувати на ньому логіку, від якої залежить коректність.

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

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 ловить мутації ще на етапі компіляції без витрат під час виконання.

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

Без компаратора 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() без колатора, порядок «стрибатиме».

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