Пошук уроків, статей та іншого контенту
Використовуватимете MutationObserver для відстеження змін у структурі та вмісті документа.
MutationObserverMutationObserver — це API браузера для асинхронного спостереження за змінами в DOM. За його допомогою можна відстежувати:
додавання або видалення дочірніх вузлів;
зміни атрибутів;
зміни текстового вмісту;
зміни всередині всього піддерева елементів.
На відміну від застарілих подій мутацій, таких як DOMNodeInserted і DOMSubtreeModified, MutationObserver не запускає обробник після кожної окремої зміни негайно. Браузер накопичує зміни та передає їх спостерігачу групою.
Базовий алгоритм складається з трьох кроків:
Створити екземпляр MutationObserver.
Передати йому функцію-обробник.
Викликати метод observe() для конкретного DOM-вузла.
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
console.log(mutation.type);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});У цьому прикладі спостерігач відстежує додавання та видалення вузлів у всьому body.
Метод observe() приймає два аргументи:
observer.observe(target, options);target — вузол, за яким потрібно спостерігати;
options — об’єкт із типами змін, які потрібно відстежувати.
childListВідстежує додавання або видалення безпосередніх дочірніх вузлів:
observer.observe(container, {
childList: true
});Ця опція реагує на такі операції:
container.append(newElement);
container.removeChild(oldElement);
container.replaceChildren();Вона не відстежує зміни всередині наявного дочірнього елемента без опції subtree.
subtreeРозширює спостереження на все піддерево:
observer.observe(container, {
childList: true,
subtree: true
});Тепер будуть помічені зміни не лише в container, а й у всіх його нащадках.
attributesВідстежує зміни атрибутів:
observer.observe(element, {
attributes: true
});Наприклад:
element.setAttribute("data-state", "active");
element.classList.add("visible");
element.id = "new-id";Щоб обмежити спостереження конкретними атрибутами, використовуйте attributeFilter:
observer.observe(element, {
attributes: true,
attributeFilter: ["class", "data-state"]
});Це зменшує кількість непотрібних викликів обробника.
characterDataВідстежує зміни текстового вмісту текстових вузлів:
observer.observe(element, {
characterData: true,
subtree: true
});Зазвичай subtree: true потрібен тому, що текстовий вузол є нащадком елемента, а не самим елементом.
const textNode = document.createTextNode("Початковий текст");
element.append(textNode);
textNode.data = "Оновлений текст";attributeOldValueЯкщо потрібно отримати попереднє значення атрибута:
observer.observe(element, {
attributes: true,
attributeOldValue: true
});Попереднє значення буде доступне через oldValue у записі мутації.
characterDataOldValueАналогічна опція для текстових вузлів:
observer.observe(element, {
characterData: true,
characterDataOldValue: true
});Не всі конфігурації є коректними. Наприклад, об’єкт без childList, attributes і characterData не має сенсу та призведе до помилки.
Також деякі опції взаємопов’язані:
attributeOldValue: true вимагає attributes: true;
attributeFilter вимагає attributes: true;
characterDataOldValue: true вимагає characterData: true.
MutationRecordДля кожної групи змін обробник отримує масив об’єктів MutationRecord.
Основні властивості запису:
type — тип зміни: "childList", "attributes" або "characterData";
target — вузол, у якому відбулася зміна;
addedNodes — додані вузли;
removedNodes — видалені вузли;
previousSibling — попередній сусід доданих або видалених вузлів;
nextSibling — наступний сусід;
attributeName — назва зміненого атрибута;
attributeNamespace — простір імен атрибута, якщо він використовується;
oldValue — попереднє значення атрибута або текстового вузла, якщо його було запитано.
Наприклад:
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === "childList") {
console.log("Змінено вузол:", record.target);
console.log("Додано:", [...record.addedNodes]);
console.log("Видалено:", [...record.removedNodes]);
}
if (record.type === "attributes") {
console.log("Атрибут:", record.attributeName);
console.log("Старе значення:", record.oldValue);
console.log(
"Нове значення:",
record.target.getAttribute(record.attributeName)
);
}
}
});Наведений приклад можна зберегти як HTML-файл і відкрити без додаткових залежностей. Він відстежує:
додавання та видалення елементів;
зміни класу;
зміни тексту;
зміни всередині вкладених елементів.
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<title>MutationObserver</title>
<style>
body {
font-family: sans-serif;
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
.panel {
border: 1px solid #ccc;
padding: 1rem;
margin-bottom: 1rem;
}
.item {
padding: 0.5rem;
margin: 0.5rem 0;
background: #f1f1f1;
}
.active {
background: #c8f7c5;
}
#log {
max-height: 260px;
overflow: auto;
padding: 0.75rem;
background: #111;
color: #eee;
font-family: monospace;
white-space: pre-wrap;
}
</style>
</head>
<body>
<h1>Спостереження за DOM</h1>
<div id="panel" class="panel">
<p id="status">Стан: початковий</p>
<div id="items"></div>
</div>
<button id="add">Додати елемент</button>
<button id="toggle">Змінити клас панелі</button>
<button id="change-text">Змінити текст</button>
<button id="clear-log">Очистити журнал</button>
<h2>Журнал змін</h2>
<pre id="log"></pre>
<script>
const panel = document.querySelector("#panel");
const items = document.querySelector("#items");
const status = document.querySelector("#status");
const log = document.querySelector("#log");
let itemNumber = 0;
function writeLog(message) {
const time = new Date().toLocaleTimeString("uk-UA");
log.textContent += `[${time}] ${message}\n`;
log.scrollTop = log.scrollHeight;
}
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === "childList") {
if (record.addedNodes.length > 0) {
writeLog(
`Додано вузлів: ${record.addedNodes.length} у ${record.target.nodeName}`
);
}
if (record.removedNodes.length > 0) {
writeLog(
`Видалено вузлів: ${record.removedNodes.length} у ${record.target.nodeName}`
);
}
}
if (record.type === "attributes") {
const newValue = record.target.getAttribute(record.attributeName);
writeLog(
`Атрибут "${record.attributeName}" змінено: ` +
`"${record.oldValue}" → "${newValue}"`
);
}
if (record.type === "characterData") {
writeLog(
`Текст змінено: "${record.oldValue}" → "${record.target.data}"`
);
}
}
});
observer.observe(panel, {
childList: true,
attributes: true,
attributeOldValue: true,
characterData: true,
characterDataOldValue: true,
subtree: true
});
document.querySelector("#add").addEventListener("click", () => {
itemNumber += 1;
const item = document.createElement("div");
item.className = "item";
item.textContent = `Елемент №${itemNumber}`;
items.append(item);
});
document.querySelector("#toggle").addEventListener("click", () => {
panel.classList.toggle("active");
});
document.querySelector("#change-text").addEventListener("click", () => {
status.firstChild.data =
status.textContent === "Стан: початковий"
? "Стан: оновлений"
: "Стан: початковий";
});
document.querySelector("#clear-log").addEventListener("click", () => {
log.textContent = "";
});
</script>
</body>
</html>Обробник MutationObserver викликається не обов’язково одразу після операції з DOM.
const observer = new MutationObserver(() => {
console.log("Обробник викликано");
});
observer.observe(document.body, {
childList: true
});
console.log("До зміни");
document.body.append(document.createElement("div"));
console.log("Після зміни");Зазвичай порядок буде таким:
До зміни
Після зміни
Обробник викликаноЦе дає браузеру змогу об’єднати кілька синхронних змін в одну доставку:
container.append(
document.createElement("div"),
document.createElement("div"),
document.createElement("div")
);Обробник може отримати кілька записів або один запис із кількома доданими вузлами — конкретна структура записів залежить від виконаних DOM-операцій.
Не покладайтеся на те, що один виклик обробника відповідає одній зміні. Завжди обробляйте весь масив записів.
Один синхронний фрагмент коду може створити кілька мутацій:
const element = document.createElement("div");
element.className = "card";
element.textContent = "Вміст";
container.append(element);Тут можуть виникнути записи для:
зміни атрибута class;
зміни текстового дочірнього вузла;
додавання нового елемента до container.
Важливо розрізняти:
DOM-операцію — дію вашого коду;
запис мутації — спостережуваний результат цієї дії.
Одна операція може створити кілька записів, а одна доставка може містити результати багатьох операцій.
Метод takeRecords() повертає мутації, які вже накопичені, але ще не були передані обробнику.
const observer = new MutationObserver((records) => {
console.log("Автоматично отримано:", records);
});
observer.observe(container, {
childList: true
});
container.append(document.createElement("div"));
const pendingRecords = observer.takeRecords();
console.log("Отримано вручну:", pendingRecords);Після виклику takeRecords() ці записи вилучаються з черги. Вони не будуть повторно передані звичайному обробнику.
Це може бути корисно під час:
контрольованого завершення роботи компонента;
тестування;
синхронного збору змін перед видаленням спостерігача.
Метод disconnect() припиняє спостереження:
observer.disconnect();Після цього нові мутації не передаватимуться цьому спостерігачу.
Це особливо важливо для компонентів, які видаляються з DOM:
function destroyComponent() {
observer.disconnect();
root.remove();
}Якщо не викликати disconnect(), спостерігач може продовжувати утримувати посилання на об’єкти та виконувати непотрібну роботу.
Після disconnect() той самий екземпляр можна під’єднати до іншого вузла:
observer.disconnect();
observer.observe(anotherElement, {
childList: true,
subtree: true
});Один екземпляр MutationObserver може спостерігати за кількома вузлами:
const observer = new MutationObserver((records) => {
console.log(records);
});
observer.observe(firstContainer, {
childList: true,
subtree: true
});
observer.observe(secondContainer, {
attributes: true
});Для припинення всіх спостережень достатньо одного виклику:
observer.disconnect();Під час спостереження за великим піддеревом record.target може бути не кореневим елементом, а конкретним нащадком.
const observer = new MutationObserver((records) => {
for (const record of records) {
console.log("Безпосередній target:", record.target);
if (record.type === "childList") {
for (const node of record.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
console.log("Додано елемент:", node);
}
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});Під час обробки addedNodes перевіряйте тип вузла. Колекція може містити:
елементи;
текстові вузли;
коментарі.
Для фільтрації елементів використовуйте:
if (node.nodeType === Node.ELEMENT_NODE) {
// Обробка елемента
}Або:
if (node instanceof Element) {
// Обробка елемента
}Частий сценарій — сторонній код або серверний процес додає елементи після початкового завантаження сторінки. MutationObserver дає змогу підключити логіку до таких елементів.
const list = document.querySelector("#list");
const observer = new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
if (!(node instanceof HTMLElement)) {
continue;
}
if (node.matches(".task")) {
initializeTask(node);
}
for (const task of node.querySelectorAll(".task")) {
initializeTask(task);
}
}
}
});
function initializeTask(task) {
if (task.dataset.initialized === "true") {
return;
}
task.dataset.initialized = "true";
task.addEventListener("click", () => {
task.classList.toggle("completed");
});
}
observer.observe(list, {
childList: true,
subtree: true
});Однак для багатьох інтерактивних списків краще використовувати делегування подій:
list.addEventListener("click", (event) => {
const task = event.target.closest(".task");
if (!task || !list.contains(task)) {
return;
}
task.classList.toggle("completed");
});У такому випадку MutationObserver взагалі не потрібен для підключення обробників до нових елементів. Використовуйте спостерігач лише тоді, коли потрібно реагувати саме на факт зміни DOM.
Спостерігач може змінювати DOM у власному обробнику. Це створює ризик нескінченного циклу.
Небезпечний приклад:
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === "attributes") {
record.target.setAttribute("data-processed", "true");
}
}
});
observer.observe(element, {
attributes: true
});Встановлення data-processed саме є зміною атрибута, тому воно може повторно запускати спостерігач.
Безпечніший варіант:
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type !== "attributes") {
continue;
}
if (record.attributeName !== "data-value") {
continue;
}
if (record.target.getAttribute("data-processed") === "true") {
continue;
}
record.target.setAttribute("data-processed", "true");
}
});
observer.observe(element, {
attributes: true,
attributeFilter: ["data-value", "data-processed"]
});Проте навіть тут варто продумати архітектуру. Часто краще:
змінювати лише потрібні атрибути;
порівнювати старе та нове значення;
використовувати прапорець обробки;
не спостерігати за атрибутом, який змінює сам обробник;
оновлювати стан у JavaScript, а не виявляти його через DOM.
MutationObserver ефективніший за старі події мутацій, але спостереження за великим піддеревом усе одно може бути дорогим.
Не використовуйте document.body без необхідності:
observer.observe(document.body, {
childList: true,
subtree: true
});Краще спостерігати за найменшим контейнером, який містить потрібні зміни:
observer.observe(document.querySelector("#notifications"), {
childList: true,
subtree: true
});Якщо потрібні лише окремі атрибути:
observer.observe(element, {
attributes: true,
attributeFilter: ["aria-expanded", "data-state"]
});Якщо запис не стосується вашої логіки, пропускайте його:
const observer = new MutationObserver((records) => {
for (const record of records) {
if (
record.type !== "attributes" ||
record.attributeName !== "data-status"
) {
continue;
}
processStatusChange(record.target);
}
});Якщо за одну доставку відбулося багато змін, можна зібрати цілі та обробити їх один раз:
const observer = new MutationObserver((records) => {
const elements = new Set();
for (const record of records) {
if (record.type !== "attributes") {
continue;
}
elements.add(record.target);
}
for (const element of elements) {
updateElementState(element);
}
});Set допомагає уникнути повторної обробки одного елемента.
Якщо ваш код сам змінює дані, краще оновлювати стан безпосередньо:
state.items.push(item);
renderItem(item);А не намагатися відновити стан, аналізуючи кожну DOM-мутацію. MutationObserver найкраще підходить для інтеграції з кодом, який ви не контролюєте, або для інструментів моніторингу й синхронізації.
Якщо спостерігач має subtree: true, він може отримати зміни у вузлі, який уже був видалений, якщо ці зміни відбулися до доставки накопичених записів.
const observer = new MutationObserver((records) => {
for (const record of records) {
console.log(record.type, record.target);
}
});
observer.observe(container, {
childList: true,
subtree: true
});
const child = container.firstElementChild;
child.remove();
// Додаткова зміна до моменту доставки записів
child.append(document.createElement("span"));Це важливо враховувати під час очищення компонентів. Не вважайте, що кожен target усе ще належить документу:
if (!record.target.isConnected) {
// Вузол уже не під’єднаний до документа
}Водночас isConnected може бути false, якщо вузол перебуває в документальному фрагменті або вже був видалений, тому логіка має відповідати конкретному сценарію.
MutationObserver спостерігає за DOM-деревом у межах доступного кореня.
Якщо спостерігати за хостом компонента:
observer.observe(customElement, {
childList: true,
subtree: true
});це не означає автоматичне спостереження за внутрішнім DOM його shadowRoot.
Для відкритого Shadow DOM потрібно спостерігати за самим коренем:
const shadowRoot = customElement.shadowRoot;
if (shadowRoot) {
observer.observe(shadowRoot, {
childList: true,
subtree: true
});
}Закритий Shadow DOM недоступний через element.shadowRoot, тому зовнішній код не може напряму під’єднати до нього спостерігач.
MutationObserver доречнийТипові практичні сценарії:
відстеження DOM, який змінює стороння бібліотека;
інтеграція з віджетами, що динамічно додають елементи;
синхронізація DOM із зовнішньою системою;
автоматичне підключення логіки до динамічного вмісту;
тестові інструменти;
діагностика неочікуваних змін DOM;
відстеження доступних станів через aria-*;
реалізація адаптерів для legacy-коду.
Для звичайного власного UI спершу перевірте, чи не підійдуть:
делегування подій;
явні виклики функцій рендерингу;
централізоване керування станом;
callback після завершення операції;
ResizeObserver, якщо потрібно стежити за розміром;
IntersectionObserver, якщо потрібно стежити за видимістю.
MutationObserver реагує на зміни структури або властивостей DOM, але не на всі можливі зміни стану сторінки.
document.bodyЦе збільшує кількість записів і ускладнює обробку.
Краще обирати конкретний контейнер і вмикати лише необхідні опції.
Один запис може містити кілька доданих або видалених вузлів, а один синхронний блок може створити багато записів.
Завжди перебирайте масив records і колекції addedNodes та removedNodes.
disconnect()Тривалий спостерігач у видаленому компоненті може спричиняти витоки ресурсів і зайві обчислення.
Підключайте disconnect() до життєвого циклу компонента.
addedNodes і removedNodes можуть містити текстові вузли та коментарі.
Перевіряйте nodeType або використовуйте instanceof Element.
Якщо обробник змінює атрибути або структуру, за якими він сам спостерігає, він може запускати себе повторно.
Обмежуйте атрибути, перевіряйте поточний стан і робіть обробку ідемпотентною.
innerHTML без урахування кількості змінМасове оновлення через innerHTML може замінити значну частину піддерева та створити багато записів. Якщо потрібно оновити лише невелику частину, використовуйте точкові DOM-операції.
Обробник не повинен для кожного запису виконувати дорогі запити, вимірювання layout або повний повторний рендер сторінки.
Групуйте цілі, фільтруйте записи та за потреби відкладайте складну роботу.
MutationObserver замість подійДля реакції на клік, введення тексту чи зміну вибраного значення використовуйте відповідні події. Спостерігач потрібен саме для змін DOM, а не як універсальний механізм реактивності.
Під час реалізації спостерігача перевірте:
Який найменший DOM-вузол можна спостерігати?
Чи потрібен subtree?
Які саме типи змін необхідні?
Чи потрібно отримувати попереднє значення?
Чи може обробник змінювати DOM?
Чи є захист від повторної обробки?
Коли спостерігач потрібно відключити?
Чи можна розв’язати задачу делегуванням подій або явним викликом функції?
Чи обробляються текстові вузли та кілька змін за одну доставку?
Чи не виконується важка робота для кожного MutationRecord?
MutationObserver асинхронно повідомляє про зміни в DOM.
Обробник отримує масив MutationRecord.
childList відстежує додавання та видалення дочірніх вузлів.
attributes відстежує зміни атрибутів.
characterData відстежує зміни текстових вузлів.
subtree поширює спостереження на всіх нащадків.
attributeFilter допомагає зменшити кількість непотрібних записів.
oldValue доступне лише за умови відповідної опції.
disconnect() потрібен для коректного завершення життєвого циклу.
Обробник має бути стійким до пакетної доставки та повторних мутацій.
Для продуктивності обмежуйте область спостереження й не використовуйте MutationObserver там, де достатньо звичайних подій або явної логіки оновлення.