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

JavaScript: питання на співбесіді рівня Middle

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

41 питань

JavaScript виконує код в одному потоці. Асинхронні дії (таймери, мережа, події) виконує середовище - браузер чи Node.js, а колбеки, що мають спрацювати після них, стають у черги. Event loop бере з черг наступне завдання, коли стек викликів порожній.

Дві черги з різним пріоритетом:

  • Макрозадачі (tasks): setTimeout, setInterval, події користувача, повідомлення з мережі.
  • Мікрозадачі (microtasks): колбеки Promise (then, продовження після await), queueMicrotask, MutationObserver.

Порядок: виконати одну макрозадачу → виконати всі мікрозадачі, що накопичилися (включно з тими, що додалися під час виконання) → (у браузері) відмалювати сторінку → наступна макрозадача.

console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
// 1, 4, 3, 2

setTimeout(fn, 0) не означає «одразу»: це макрозадача, і вона чекає, поки виконаються всі мікрозадачі.

Практичні наслідки:

  • Довгий синхронний код (великий цикл, важкий JSON.parse) блокує все: кліки, анімацію, рендер. Важку роботу ділять на частини або виносять у Web Worker.
  • Нескінченний ланцюжок мікрозадач теж заморожує сторінку: рендер чекає, поки черга мікрозадач спорожніє.

Докладніше в документації: Модель виконання JavaScript

Усі чотири приймають масив Promise і запускаються паралельно, але по-різному вирішують, коли й чим завершитися:

  • Promise.all - чекає всі. Результат - масив значень у тому ж порядку. Перша ж помилка відхиляє весь результат (інші Promise продовжують виконуватися, але їхні результати губляться).
  • Promise.allSettled - чекає всі, ніколи не відхиляється. Результат - масив об'єктів {status: 'fulfilled', value} або {status: 'rejected', reason}.
  • Promise.race - результат першого, що завершився, хай успіхом, хай помилкою.
  • Promise.any - перший успішний. Відхиляється з AggregateError, лише якщо відхилено всі.
// Усе потрібне для сторінки - інакше показати помилку
const [user, settings] = await Promise.all([getUser(), getSettings()]);

// Розіслати сповіщення й порахувати, скільки не дійшло
const results = await Promise.allSettled(recipients.map(send));
const failed = results.filter((r) => r.status === 'rejected').length;

// Тайм-аут
const data = await Promise.race([fetchData(), sleep(5000).then(() => { throw new Error('timeout'); })]);

// Перше доступне дзеркало
const file = await Promise.any(mirrors.map((url) => fetch(url)));

Нюанс race для тайм-ауту: «програлий» запит не скасовується, він продовжує виконуватися. Для справжнього скасування потрібен AbortController (у fetch - AbortSignal.timeout(5000)).

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

Старі API (події, таймери, бібліотеки з колбеками) зручно «загорнути» в Promise, щоб використовувати з await. Це називають промісифікацією.

Класичний спосіб - конструктор new Promise:

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function loadImage(src) {
  return new Promise((resolve, reject) => {
    const image = new Image();
    image.onload = () => resolve(image);
    image.onerror = () => reject(new Error(`Не вдалося завантажити ${src}`));
    image.src = src;
  });
}

await sleep(500);
const image = await loadImage('/logo.png');

Правила:

  • resolve і reject діють лише перший раз - повторні виклики ігноруються;
  • виняток, кинутий синхронно всередині функції-виконавця, автоматично відхиляє Promise;
  • не загортайте в new Promise те, що вже повертає Promise, - це зайвий шар і місце для втрачених помилок.

Promise.withResolvers() (ES2024) повертає Promise разом з його resolve і reject назовні:

const { promise, resolve, reject } = Promise.withResolvers();

Це зручно, коли виконання відбувається не в одному колбеку, а десь пізніше - наприклад, чекаємо на відповідь у WebSocket за ідентифікатором запиту:

const pending = new Map();

function request(socket, payload) {
  const id = crypto.randomUUID();
  const { promise, resolve, reject } = Promise.withResolvers();
  pending.set(id, { resolve, reject });
  socket.send(JSON.stringify({ id, ...payload }));
  return promise;
}

socket.onmessage = (event) => {
  const { id, result, error } = JSON.parse(event.data);
  const handlers = pending.get(id);
  pending.delete(id);
  error ? handlers?.reject(new Error(error)) : handlers?.resolve(result);
};

Раніше для цього писали той самий шаблон вручну, зберігаючи resolve у зовнішні змінні з конструктора.

У Node.js для функцій зі стилем (error, result) є готовий util.promisify, а більшість модулів мають Promise-версії (node:fs/promises).

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

for await...of перебирає послідовність значень, що надходять асинхронно: кожен крок циклу чекає на наступне значення.

Асинхронний генератор - найпростіший спосіб таку послідовність створити. Наприклад, обхід API з пагінацією:

async function* fetchAllPages(url) {
  let next = url;
  while (next) {
    const response = await fetch(next);
    const page = await response.json();
    yield* page.data;          // віддати елементи сторінки по одному
    next = page.links.next;    // колекція API Resource у Laravel кладе сюди URL наступної сторінки
  }
}

for await (const user of fetchAllPages('/api/users')) {
  console.log(user.name);
  if (user.id === 42) break;   // решта сторінок навіть не буде завантажена
}

Переваги перед «завантажити все в масив»:

  • лінивість - наступна сторінка запитується лише тоді, коли попередню оброблено;
  • пам'ять - не треба тримати весь набір одночасно;
  • break чи return з циклу зупиняє генератор, і зайві запити не робляться.

Де ще зустрічається:

  • потоки даних: ReadableStream у Node.js, Chrome і Firefox можна перебирати через for await (Safari поки ні - там читають через getReader()):
const response = await fetch('/export.csv');
const decoder = new TextDecoder();
for await (const chunk of response.body) {
  process(decoder.decode(chunk, { stream: true }));
}
  • читання файлів построково в Node.js (readline), події, черги повідомлень.

for await над масивом Promise теж працює, але виконує їх у порядку масиву, а не в порядку завершення, і помилки обробляє незручно: for await (const r of [p1, p2, p3]). Якщо Promise вже запущені, краще Promise.all чи Promise.allSettled.

Array.fromAsync() (ES2024) збирає асинхронну послідовність у масив: const users = await Array.fromAsync(fetchAllPages('/api/users'));.

Докладніше в документації: for await...of

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

У звичайній функції this визначається в момент виклику, а не там, де функцію написано:

  • obj.method() - this це obj;
  • просто fn() - undefined у строгому режимі (у модулях і класах він завжди строгий), глобальний об'єкт - у нестрогому;
  • new Fn() - новий об'єкт;
  • fn.call(ctx), fn.apply(ctx), fn.bind(ctx) - явно заданий ctx;
  • в обробнику addEventListener - елемент, на якому висить обробник.

Типова пастка - метод, переданий як колбек:

class Timer {
  seconds = 0;
  tick() { this.seconds++; }
}

const timer = new Timer();
setInterval(timer.tick, 1000); // this === undefined -> TypeError

Метод «відірвався» від об'єкта, і викликається вже як просто функція.

Стрілкова функція не має власного this - вона бере this з місця, де її створено (лексично), і змінити його не можна навіть через call чи bind:

setInterval(() => timer.tick(), 1000);   // працює
// або поле-стрілка в класі:
class Timer2 { seconds = 0; tick = () => { this.seconds++; }; }

Ще відмінності стрілкових функцій: немає власного arguments, їх не можна викликати через new, вони не мають prototype.

Коли НЕ стрілка: методи об'єкта, яким потрібен this цього об'єкта ({ name: 'x', greet: () => this.name } не працює), і обробники, де потрібен this як елемент.

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

Усі три дозволяють явно задати this для звичайної функції:

  • fn.call(ctx, a, b) - викликає функцію одразу, аргументи передаються через кому.
  • fn.apply(ctx, [a, b]) - те саме, але аргументи - масивом.
  • fn.bind(ctx, a) - не викликає, а повертає нову функцію з назавжди прив'язаним this (і, за бажанням, частиною аргументів).
function greet(greeting, punctuation) {
  return `${greeting}, ${this.name}${punctuation}`;
}

const user = { name: 'Оля' };

greet.call(user, 'Привіт', '!');          // 'Привіт, Оля!'
greet.apply(user, ['Вітаю', '.']);         // 'Вітаю, Оля.'

const hello = greet.bind(user, 'Привіт');
hello('?');                                // 'Привіт, Оля?'

Де це трапляється:

  • bind - щоб передати метод як колбек, не загубивши this: button.addEventListener('click', this.onClick.bind(this)). Сьогодні частіше пишуть стрілкову функцію.
  • Часткове застосування: const double = multiply.bind(null, 2).
  • apply історично використовували, щоб передати масив як аргументи: Math.max.apply(null, numbers). Тепер є spread: Math.max(...numbers).
  • Позичання методів: Array.prototype.slice.call(arguments) - тепер замість цього Array.from().

Пастки: функцію, прив'язану через bind, повторний bind уже не перевизначить. А на стрілкові функції call, apply і bind щодо this не впливають узагалі.

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

IIFE (immediately invoked function expression) - функція, яку оголошують і одразу викликають:

(function () {
  const secret = 'видно лише тут';
  // ...
})();

(() => {
  // те саме стрілковою функцією
})();

Дужки навколо функції потрібні, щоб рушій сприйняв її як вираз, а не як оголошення функції (яке не можна одразу викликати).

Навіщо їх використовували:

  • ізоляція області видимості. До ES-модулів усі скрипти на сторінці ділили одну глобальну область. Змінні з var на верхньому рівні ставали властивостями window і конфліктували між бібліотеками. IIFE створювала приватну область;
  • шаблон «модуль» - приватний стан плюс публічний інтерфейс:
const counter = (function () {
  let count = 0;
  return {
    increment: () => ++count,
    current: () => count,
  };
})();
  • копія значення в циклі з var - до появи let кожна ітерація ділила одну змінну.

Що змінилося:

  • ES-модулі мають власну область видимості: змінні верхнього рівня модуля не глобальні. Vite, вбудовані <script type="module"> - IIFE для ізоляції не потрібні;
  • let і const мають блокову область видимості - досить фігурних дужок;
  • замикання для приватного стану тепер часто замінюють приватні поля класів (#count).

Де IIFE досі трапляються:

  • асинхронний код на верхньому рівні звичайного скрипта (не модуля), де немає top-level await:
(async () => {
  const config = await loadConfig();
  start(config);
})();
  • результат збирання. Збирачі пакують код для старих оточень і бібліотеки у формат IIFE (наприклад, format: 'iife' у Rollup/Vite) - так вони підключаються одним <script> без конфліктів;
  • ініціалізація константи складною логікою в одному виразі (хоча зазвичай читабельніша окрема функція).

Пастка: рядок перед IIFE без крапки з комою. const a = b і на наступному рядку (function () {})() рушій прочитає як виклик b(...). Звідси звичка ставити ; перед IIFE у коді без крапок з комою.

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

Змінні let, const (і класи) теж «піднімаються» на початок своєї області видимості, але не ініціалізуються. Від початку блоку до рядка оголошення змінна існує, але звертання до неї кидає ReferenceError. Цей проміжок називають тимчасовою мертвою зоною (temporal dead zone).

{
  console.log(price);   // ReferenceError: Cannot access 'price' before initialization
  let price = 100;
}

З var поведінка інша - змінна ініціалізується як undefined, і помилка тихо проходить:

console.log(total);   // undefined
var total = 100;

Чому це «тимчасова», а не «просторова» зона. Важливий момент виконання, а не позиція в тексті:

function showPrice() {
  console.log(price);   // функцію оголошено вище за let...
}
let price = 100;
showPrice();            // ...але викликано після ініціалізації - працює

Неочевидні випадки:

  • typeof не рятує. Для неоголошеної змінної typeof x повертає 'undefined', але для змінної в TDZ - кидає ReferenceError;
  • затінення:
const limit = 10;
function check() {
  console.log(limit);   // ReferenceError: внутрішня limit у TDZ
  const limit = 20;
}

Внутрішнє оголошення «закриває» зовнішнє для всього блоку, а не лише від свого рядка;

  • параметри за замовчуванням: function f(a = b, b = 1) {} - при виклику f() параметр b ще в TDZ;
  • класи: new User() до рядка class User {} теж кидає помилку, на відміну від функцій.

Навіщо це зроблено так:

  • помилка доступу до неініціалізованої змінної видна одразу, а не перетворюється на загадкове undefined;
  • const повинна мати рівно одне значення - побачити її «до» присвоєння було б порушенням цього правила.

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

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

Усі числа в JavaScript (крім BigInt) - 64-бітні числа з плаваючою комою за стандартом IEEE 754. Більшість десяткових дробів, як 0.1, у двійковій системі - нескінченні дроби, тож зберігаються з крихітною похибкою.

0.1 + 0.2;           // 0.30000000000000004
0.1 + 0.2 === 0.3;   // false
1.005 * 100;         // 100.49999999999999

Це не баг JavaScript - так само поводяться float у PHP, Python, Java.

Як порівнювати дроби: з допуском:

Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON; // true

Як рахувати гроші:

  • У найменших одиницях - копійках, як цілі числа: 1999 замість 19.99. Цілі числа до Number.MAX_SAFE_INTEGER (≈ 9 квадрильйонів) представлені точно.
  • Округлення - явне й в одному місці: Math.round(price * quantity).
  • Для складних розрахунків (відсотки, курси, розподіл) - бібліотеки десяткової арифметики (decimal.js, big.js, dinero.js).
  • Показ користувачу - через Intl.NumberFormat:
new Intl.NumberFormat('uk-UA', { style: 'currency', currency: 'UAH' }).format(1999 / 100);
// '19,99 ₴'

toFixed() - для показу, не для розрахунків: він повертає рядок і сам округлює з тією ж похибкою ((1.005).toFixed(2) дає '1.00').

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

У логічному контексті (if, &&, ||, !, тернарний оператор) кожне значення перетворюється на true чи false. Хибних значень небагато:

false, 0, -0, 0n, '' (порожній рядок), null, undefined, NaN.

Усе інше - істинне, і саме тут сюрпризи:

Boolean('0');      // true - непорожній рядок
Boolean('false');  // true
Boolean([]);       // true - порожній масив
Boolean({});       // true - порожній об'єкт

Де це підводить:

  • Нуль як валідне значення:
function setVolume(level) {
  if (!level) level = 50;   // гучність 0 перетвориться на 50
}

Правильно: level ??= 50 чи явна перевірка level === undefined.

  • Перевірка порожнього масиву: if (items) завжди істинне. Потрібно if (items.length > 0).
  • Рендер у React: {count && <Badge />} при count === 0 виведе на сторінку 0. Правильно: {count > 0 && <Badge />}.
  • Рядки з форми: значення з <input> завжди рядок, і '0' - істинний.

!!value чи Boolean(value) явно перетворюють значення на логічне - корисно, коли функція має повертати саме true/false.

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

NaN (Not a Number) - спеціальне значення типу number, яке означає результат невизначеної чи неможливої числової операції:

typeof NaN;          // 'number'
Number('abc');       // NaN
0 / 0;               // NaN
Math.sqrt(-1);       // NaN
undefined + 1;       // NaN

NaN не дорівнює нічому, навіть собі. Так визначено стандартом IEEE 754 для чисел з рухомою комою:

NaN === NaN;   // false
NaN == NaN;    // false
const x = NaN;
x !== x;       // true - єдине значення з такою властивістю

Тому if (value === NaN) ніколи не спрацює.

Як перевіряти правильно:

Number.isNaN(value);     // true лише для справжнього NaN
Object.is(value, NaN);   // теж true

Не плутати з глобальною isNaN(). Вона спершу перетворює аргумент на число, тож відповідає на інше питання - «чи стане це NaN після перетворення»:

isNaN('abc');          // true - 'abc' перетворився на NaN
Number.isNaN('abc');   // false - це рядок, а не NaN
isNaN(undefined);      // true
isNaN('');             // false - '' перетворюється на 0

Для перевірки «чи є це коректним числом» найзручніше Number.isFinite(value): false для NaN, Infinity і будь-яких нечислових типів.

NaN «заражає» обчислення: будь-яка арифметика з NaN дає NaN. Одне некоректне значення в масиві перетворить суму на NaN:

[10, 20, Number('')].reduce((a, b) => a + b);       // 30 ('' дає 0)
[10, 20, Number('x')].reduce((a, b) => a + b);      // NaN

Звідси правило: перевіряти дані на вході (при парсингу форми чи відповіді API), а не шукати, де з'явився NaN, у кінці розрахунку.

Різні методи по-різному бачать NaN:

  • [NaN].includes(NaN) → true (алгоритм SameValueZero);
  • [NaN].indexOf(NaN) → -1 (строга рівність);
  • new Set([NaN, NaN]).size → 1;
  • JSON.stringify(NaN) → 'null' - NaN тихо перетворюється на null при відправці на сервер.

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

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

Інші рівні
Junior 37 Senior 36

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