Реализация MutationObserver для реактивных обновлений DOM на сайте
MutationObserver следит за изменениями в DOM-дереве: добавление/удаление узлов, изменение атрибутов, изменение текстового содержимого. Работает асинхронно через микрозадачи — колбэк вызывается после завершения текущего синхронного кода, батчем мутаций.
Где это нужно: интеграция с легаси-кодом, сторонними виджетами, CMS-редакторами, аналитика изменений страницы, реализация custom elements без Web Components API, отслеживание динамически вставляемого контента.
Базовая настройка
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
switch (mutation.type) {
case 'childList':
// mutation.addedNodes — добавленные узлы (NodeList)
// mutation.removedNodes — удалённые узлы
break
case 'attributes':
// mutation.attributeName — имя атрибута
// mutation.oldValue — старое значение (если attributeOldValue: true)
break
case 'characterData':
// mutation.oldValue — старый текст (если characterDataOldValue: true)
break
}
}
})
observer.observe(element, {
childList: true, // следить за добавлением/удалением дочерних узлов
subtree: true, // рекурсивно по всему поддереву
attributes: true, // следить за атрибутами
attributeFilter: ['class', 'data-state'], // только эти атрибуты
attributeOldValue: true, // сохранять старое значение
characterData: false, // следить за текстовым содержимым
})
observer.disconnect() // отключить
observer.takeRecords() // получить накопленные мутации и очистить очередь
Ожидание появления элемента в DOM
Полезно для работы со сторонними виджетами, которые вставляют элементы асинхронно:
function waitForElement<T extends HTMLElement>(
selector: string,
root: HTMLElement | Document = document,
timeoutMs = 10000
): Promise<T> {
const existing = root.querySelector<T>(selector)
if (existing) return Promise.resolve(existing)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
observer.disconnect()
reject(new Error(`Элемент "${selector}" не появился за ${timeoutMs}ms`))
}, timeoutMs)
const observer = new MutationObserver(() => {
const el = root.querySelector<T>(selector)
if (el) {
clearTimeout(timer)
observer.disconnect()
resolve(el)
}
})
observer.observe(root, { childList: true, subtree: true })
})
}
// Использование:
const chatWidget = await waitForElement<HTMLDivElement>('#intercom-container')
chatWidget.style.bottom = '80px' // переопределить стиль виджета
Отслеживание динамически добавляемых элементов
Когда нужно инициализировать логику для элементов, которые могут появляться в любой момент:
type ElementHandler = (element: HTMLElement) => (() => void) | void
function watchForElements(
selector: string,
handler: ElementHandler,
root: HTMLElement | Document = document
): () => void {
const cleanups = new Map<HTMLElement, () => void>()
function processElement(el: HTMLElement): void {
if (cleanups.has(el)) return
const cleanup = handler(el)
if (cleanup) cleanups.set(el, cleanup)
}
function processRemoval(el: HTMLElement): void {
const cleanup = cleanups.get(el)
if (cleanup) {
cleanup()
cleanups.delete(el)
}
}
// Инициализируем существующие элементы
root.querySelectorAll<HTMLElement>(selector).forEach(processElement)
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node as HTMLElement
if (el.matches(selector)) processElement(el)
el.querySelectorAll<HTMLElement>(selector).forEach(processElement)
})
mutation.removedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node as HTMLElement
if (el.matches(selector)) processRemoval(el)
el.querySelectorAll<HTMLElement>(selector).forEach(processRemoval)
})
}
})
observer.observe(root, { childList: true, subtree: true })
return () => {
observer.disconnect()
cleanups.forEach((cleanup) => cleanup())
cleanups.clear()
}
}
// Пример: автоматически инициализировать кастомные компоненты
const stop = watchForElements('[data-tooltip]', (el) => {
const tooltip = new TooltipController(el)
return () => tooltip.destroy()
})
Отслеживание изменений атрибутов
function watchAttribute(
element: HTMLElement,
attribute: string,
onChange: (newValue: string | null, oldValue: string | null) => void
): () => void {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.attributeName === attribute) {
onChange(
element.getAttribute(attribute),
mutation.oldValue
)
}
}
})
observer.observe(element, {
attributes: true,
attributeFilter: [attribute],
attributeOldValue: true,
})
return () => observer.disconnect()
}
// Синхронизация с классом стороннего компонента
watchAttribute(someWidget, 'class', (newValue, oldValue) => {
const wasOpen = oldValue?.includes('is-open')
const isOpen = newValue?.includes('is-open')
if (!wasOpen && isOpen) onWidgetOpen()
if (wasOpen && !isOpen) onWidgetClose()
})
React-хук
function useMutationObserver(
target: HTMLElement | null,
callback: MutationCallback,
options: MutationObserverInit
): void {
const callbackRef = useRef(callback)
callbackRef.current = callback
useEffect(() => {
if (!target) return
const observer = new MutationObserver((...args) => callbackRef.current(...args))
observer.observe(target, options)
return () => observer.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target, JSON.stringify(options)])
}
// Использование:
function DynamicContent() {
const containerRef = useRef<HTMLDivElement>(null)
const [childCount, setChildCount] = useState(0)
useMutationObserver(
containerRef.current,
(mutations) => {
setChildCount(containerRef.current?.childElementCount ?? 0)
},
{ childList: true }
)
return <div ref={containerRef}>{/* динамическое содержимое */}</div>
}
Производительность
MutationObserver может накапливать тысячи мутаций в секунду при активных DOM-изменениях. Несколько правил:
- Не использовать
subtree: trueбез необходимости — дорогостоящее наблюдение - Фильтровать мутации внутри колбэка максимально быстро
- Использовать
observer.takeRecords()для принудительного сброса очереди перед disconnect - Не обращаться к DOM внутри колбэка без необходимости — каждый
querySelectorэто layout query
Срок: 0.5–1 день в зависимости от сложности сценариев.







