Senior: питання на співбесіді з теми «DOM і події»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Щоб показати сторінку, браузер рахує 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).