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

Junior: питання на співбесіді з теми «DOM і події»

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

4 питання

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()