← JavaScript/MutationObserver: наблюдение за DOM#137 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

MutationObserver: наблюдение за DOM

Вы встраиваете виджет аналитики в чужой сайт: сторонний скрипт динамически добавляет контент через AJAX. Вы не можете изменить их код. Нужно реагировать когда они добавят новые элементы. Опросы через setInterval — расточительно. Решение: MutationObserver — реактивное наблюдение за изменениями DOM.

Что такое MutationObserver

MutationObserver — API браузера для наблюдения за изменениями в DOM без постоянного опроса. Срабатывает только когда что-то реально изменилось, эффективнее setInterval.

На основе предыдущих уроков

  • DOM basics: узлы, childNodes, attributes
  • события: addEventListener — другой способ реагировать на изменения
  • Proxy/Reflect: похожая идея — перехват операций над объектом
  • Создание наблюдателя

    // Создаём наблюдатель — передаём callback
    const observer = new MutationObserver((mutationsList, observer) => {
      for (const mutation of mutationsList) {
        console.log('Тип изменения:', mutation.type)
        // 'childList' | 'attributes' | 'characterData'
      }
    })

    observer.observe() — начать наблюдение

    const targetElement = document.getElementById('chat-messages')
    
    observer.observe(targetElement, {
      childList: true,      // следить за добавлением/удалением дочерних элементов
      attributes: true,     // следить за изменением атрибутов
      subtree: true,        // наблюдать за всем поддеревом, не только прямыми детьми
      characterData: true,  // следить за изменением текстового содержимого
      attributeFilter: ['class', 'style'],  // только эти атрибуты (опционально)
    })

    MutationRecord — объект изменения

    Callback получает массив объектов MutationRecord:

    const observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        console.log(mutation.type)           // тип изменения
        console.log(mutation.target)         // элемент, который изменился
        console.log(mutation.addedNodes)     // добавленные узлы (NodeList)
        console.log(mutation.removedNodes)   // удалённые узлы (NodeList)
        console.log(mutation.attributeName) // имя изменённого атрибута
        console.log(mutation.oldValue)      // старое значение (если включено)
      })
    })

    observer.disconnect() — остановить наблюдение

    // Важно отключать наблюдатель, когда он не нужен — иначе утечка памяти
    observer.disconnect()

    Реальные применения

    1. Lazy loading изображений

    const lazyImageObserver = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        mutation.addedNodes.forEach(node => {
          if (node.nodeType !== Node.ELEMENT_NODE) return
          const images = node.querySelectorAll('img[data-src]')
          images.forEach(img => {
            img.src = img.dataset.src  // загружаем реальное изображение
            delete img.dataset.src
          })
        })
      })
    })
    
    lazyImageObserver.observe(document.body, { childList: true, subtree: true })

    2. Виджет для CMS — реагируем на изменения от стороннего скрипта

    // Сторонний скрипт меняет DOM — мы должны реагировать
    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (mutation.type === 'childList') {
          // Новый контент добавлен — обновляем наш виджет
          updateWidget(mutation.target)
        }
        if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
          // Класс изменился — обновляем стили нашего виджета
          syncStyles(mutation.target)
        }
      }
    })
    
    observer.observe(document.querySelector('.cms-content'), {
      childList: true,
      attributes: true,
      subtree: true,
      attributeFilter: ['class'],
    })

    Отличие от DOM-событий

    | | MutationObserver | DOM события |

    |---|---|---|

    | Когда срабатывает | После всех изменений (batch) | Синхронно при каждом изменении |

    | Производительность | Высокая | Может быть ниже при частых изменениях |

    | Что отслеживает | Структурные изменения DOM | Пользовательские действия |

    | Доступность | Только браузер | Только браузер |

    Типичные ошибки

    Ошибка 1: забывают disconnect() — утечка памяти

    // Сломано: observer живёт вечно, даже когда элемент удалён из DOM
    const observer = new MutationObserver(callback)
    observer.observe(element, { childList: true })
    // element удалён, но observer продолжает держать ссылку
    
    // Исправлено: отключать в cleanup
    function attachObserver(element) {
      const observer = new MutationObserver(callback)
      observer.observe(element, { childList: true })
      return () => observer.disconnect()  // возвращаем функцию отписки
    }
    const cleanup = attachObserver(chatContainer)
    // При размонтировании:
    cleanup()

    Ошибка 2: subtree: true без необходимости

    // Сломано: наблюдаем за всем document.body — получим тысячи событий
    observer.observe(document.body, { childList: true, subtree: true })
    
    // Исправлено: наблюдаем за конкретным элементом
    observer.observe(document.getElementById('chat-messages'), { childList: true })

    Ошибка 3: мутации внутри callback создают бесконечный цикл

    // Сломано: callback меняет DOM → наблюдатель видит изменение → снова callback...
    const observer = new MutationObserver((mutations) => {
      mutations.forEach(m => {
        m.target.setAttribute('data-processed', 'true')  // вызовет новую мутацию!
      })
    })
    observer.observe(el, { attributes: true })
    
    // Исправлено: отключаем перед изменением, включаем после
    const observer = new MutationObserver((mutations) => {
      observer.disconnect()  // пауза
      mutations.forEach(m => {
        if (!m.target.dataset.processed) {
          m.target.dataset.processed = 'true'
        }
      })
      observer.observe(el, { attributes: true })  // снова включаем
    })

    В реальных проектах

  • Виджеты и SDK: реагируют на изменения DOM стороннего сайта (чат-виджеты, аналитика)
  • Автоматизация тестов: Playwright/Puppeteer ждут появления элементов через MutationObserver
  • Синтаксическая подсветка: highlight.js перехватывает динамически добавленные <code> блоки
  • Бесконечная прокрутка: наблюдают за добавлением новых элементов для инициализации lazy-load
  • Примечание о песочнице

    В этой sandbox нет DOM — следующий пример демонстрирует тот же паттерн реактивного наблюдения через Proxy.

    Примеры

    Симуляция паттерна MutationObserver через наблюдаемый объект на основе Proxy

    // Симуляция паттерна MutationObserver для демонстрации в sandbox (без DOM)
    // Та же идея: наблюдатель реагирует на изменения объекта (вместо DOM-узла)
    
    class MockMutationObserver {
      constructor(callback) {
        this._callback = callback
        this._targets = new Map()
      }
    
      // observe(target, options) — начать наблюдение за объектом
      observe(target, options = {}) {
        const proxy = new Proxy(target, {
          set: (obj, prop, value) => {
            const oldValue = obj[prop]
            obj[prop] = value
    
            // Создаём MutationRecord-подобный объект
            const record = {
              type: 'property',
              target: obj,
              property: prop,
              oldValue,
              newValue: value,
            }
    
            // Вызываем callback с массивом записей (как настоящий MutationObserver)
            this._callback([record], this)
            return true
          },
        })
    
        this._targets.set(target, proxy)
        return proxy  // возвращаем proxy-версию для использования
      }
    
      // disconnect() — прекратить наблюдение
      disconnect() {
        this._targets.clear()
        console.log('Наблюдение прекращено')
      }
    }
    
    // --- Демонстрация ---
    
    // Наш "DOM-элемент" — обычный объект
    const chatContainer = {
      messageCount: 0,
      lastMessage: null,
      unreadCount: 0,
    }
    
    // Создаём наблюдатель
    const observer = new MockMutationObserver((mutations) => {
      for (const mutation of mutations) {
        const { property, oldValue, newValue } = mutation
    
        if (property === 'messageCount') {
          console.log(`[Observer] Новых сообщений: ${newValue} (было: ${oldValue})`)
        }
    
        if (property === 'lastMessage') {
          console.log(`[Observer] Последнее сообщение: "${newValue}"`)
        }
    
        if (property === 'unreadCount' && newValue > 0) {
          console.log(`[Observer] Обновление badge: ${newValue} непрочитанных`)
        }
      }
    })
    
    // Начинаем наблюдение — получаем proxy-версию объекта
    const observedChat = observer.observe(chatContainer)
    
    // Изменяем объект — наблюдатель реагирует автоматически
    console.log('--- Приходят новые сообщения ---\n')
    
    observedChat.lastMessage = 'Привет! Как дела?'
    observedChat.messageCount = 1
    observedChat.unreadCount = 1
    
    observedChat.lastMessage = 'Готов к встрече в 15:00?'
    observedChat.messageCount = 2
    observedChat.unreadCount = 2
    
    // Пользователь прочитал сообщения
    console.log('\n--- Пользователь прочитал сообщения ---\n')
    observedChat.unreadCount = 0
    
    // Отключаем наблюдатель
    console.log('\n--- Отключаем наблюдатель ---')
    observer.disconnect()
    
    // Изменения больше не отслеживаются
    observedChat.messageCount = 5
    console.log('Изменение после disconnect не вызвало callback')

    MutationObserver: наблюдение за DOM

    Вы встраиваете виджет аналитики в чужой сайт: сторонний скрипт динамически добавляет контент через AJAX. Вы не можете изменить их код. Нужно реагировать когда они добавят новые элементы. Опросы через setInterval — расточительно. Решение: MutationObserver — реактивное наблюдение за изменениями DOM.

    Что такое MutationObserver

    MutationObserver — API браузера для наблюдения за изменениями в DOM без постоянного опроса. Срабатывает только когда что-то реально изменилось, эффективнее setInterval.

    На основе предыдущих уроков

  • DOM basics: узлы, childNodes, attributes
  • события: addEventListener — другой способ реагировать на изменения
  • Proxy/Reflect: похожая идея — перехват операций над объектом
  • Создание наблюдателя

    // Создаём наблюдатель — передаём callback
    const observer = new MutationObserver((mutationsList, observer) => {
      for (const mutation of mutationsList) {
        console.log('Тип изменения:', mutation.type)
        // 'childList' | 'attributes' | 'characterData'
      }
    })

    observer.observe() — начать наблюдение

    const targetElement = document.getElementById('chat-messages')
    
    observer.observe(targetElement, {
      childList: true,      // следить за добавлением/удалением дочерних элементов
      attributes: true,     // следить за изменением атрибутов
      subtree: true,        // наблюдать за всем поддеревом, не только прямыми детьми
      characterData: true,  // следить за изменением текстового содержимого
      attributeFilter: ['class', 'style'],  // только эти атрибуты (опционально)
    })

    MutationRecord — объект изменения

    Callback получает массив объектов MutationRecord:

    const observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        console.log(mutation.type)           // тип изменения
        console.log(mutation.target)         // элемент, который изменился
        console.log(mutation.addedNodes)     // добавленные узлы (NodeList)
        console.log(mutation.removedNodes)   // удалённые узлы (NodeList)
        console.log(mutation.attributeName) // имя изменённого атрибута
        console.log(mutation.oldValue)      // старое значение (если включено)
      })
    })

    observer.disconnect() — остановить наблюдение

    // Важно отключать наблюдатель, когда он не нужен — иначе утечка памяти
    observer.disconnect()

    Реальные применения

    1. Lazy loading изображений

    const lazyImageObserver = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        mutation.addedNodes.forEach(node => {
          if (node.nodeType !== Node.ELEMENT_NODE) return
          const images = node.querySelectorAll('img[data-src]')
          images.forEach(img => {
            img.src = img.dataset.src  // загружаем реальное изображение
            delete img.dataset.src
          })
        })
      })
    })
    
    lazyImageObserver.observe(document.body, { childList: true, subtree: true })

    2. Виджет для CMS — реагируем на изменения от стороннего скрипта

    // Сторонний скрипт меняет DOM — мы должны реагировать
    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (mutation.type === 'childList') {
          // Новый контент добавлен — обновляем наш виджет
          updateWidget(mutation.target)
        }
        if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
          // Класс изменился — обновляем стили нашего виджета
          syncStyles(mutation.target)
        }
      }
    })
    
    observer.observe(document.querySelector('.cms-content'), {
      childList: true,
      attributes: true,
      subtree: true,
      attributeFilter: ['class'],
    })

    Отличие от DOM-событий

    | | MutationObserver | DOM события |

    |---|---|---|

    | Когда срабатывает | После всех изменений (batch) | Синхронно при каждом изменении |

    | Производительность | Высокая | Может быть ниже при частых изменениях |

    | Что отслеживает | Структурные изменения DOM | Пользовательские действия |

    | Доступность | Только браузер | Только браузер |

    Типичные ошибки

    Ошибка 1: забывают disconnect() — утечка памяти

    // Сломано: observer живёт вечно, даже когда элемент удалён из DOM
    const observer = new MutationObserver(callback)
    observer.observe(element, { childList: true })
    // element удалён, но observer продолжает держать ссылку
    
    // Исправлено: отключать в cleanup
    function attachObserver(element) {
      const observer = new MutationObserver(callback)
      observer.observe(element, { childList: true })
      return () => observer.disconnect()  // возвращаем функцию отписки
    }
    const cleanup = attachObserver(chatContainer)
    // При размонтировании:
    cleanup()

    Ошибка 2: subtree: true без необходимости

    // Сломано: наблюдаем за всем document.body — получим тысячи событий
    observer.observe(document.body, { childList: true, subtree: true })
    
    // Исправлено: наблюдаем за конкретным элементом
    observer.observe(document.getElementById('chat-messages'), { childList: true })

    Ошибка 3: мутации внутри callback создают бесконечный цикл

    // Сломано: callback меняет DOM → наблюдатель видит изменение → снова callback...
    const observer = new MutationObserver((mutations) => {
      mutations.forEach(m => {
        m.target.setAttribute('data-processed', 'true')  // вызовет новую мутацию!
      })
    })
    observer.observe(el, { attributes: true })
    
    // Исправлено: отключаем перед изменением, включаем после
    const observer = new MutationObserver((mutations) => {
      observer.disconnect()  // пауза
      mutations.forEach(m => {
        if (!m.target.dataset.processed) {
          m.target.dataset.processed = 'true'
        }
      })
      observer.observe(el, { attributes: true })  // снова включаем
    })

    В реальных проектах

  • Виджеты и SDK: реагируют на изменения DOM стороннего сайта (чат-виджеты, аналитика)
  • Автоматизация тестов: Playwright/Puppeteer ждут появления элементов через MutationObserver
  • Синтаксическая подсветка: highlight.js перехватывает динамически добавленные <code> блоки
  • Бесконечная прокрутка: наблюдают за добавлением новых элементов для инициализации lazy-load
  • Примечание о песочнице

    В этой sandbox нет DOM — следующий пример демонстрирует тот же паттерн реактивного наблюдения через Proxy.

    Примеры

    Симуляция паттерна MutationObserver через наблюдаемый объект на основе Proxy

    // Симуляция паттерна MutationObserver для демонстрации в sandbox (без DOM)
    // Та же идея: наблюдатель реагирует на изменения объекта (вместо DOM-узла)
    
    class MockMutationObserver {
      constructor(callback) {
        this._callback = callback
        this._targets = new Map()
      }
    
      // observe(target, options) — начать наблюдение за объектом
      observe(target, options = {}) {
        const proxy = new Proxy(target, {
          set: (obj, prop, value) => {
            const oldValue = obj[prop]
            obj[prop] = value
    
            // Создаём MutationRecord-подобный объект
            const record = {
              type: 'property',
              target: obj,
              property: prop,
              oldValue,
              newValue: value,
            }
    
            // Вызываем callback с массивом записей (как настоящий MutationObserver)
            this._callback([record], this)
            return true
          },
        })
    
        this._targets.set(target, proxy)
        return proxy  // возвращаем proxy-версию для использования
      }
    
      // disconnect() — прекратить наблюдение
      disconnect() {
        this._targets.clear()
        console.log('Наблюдение прекращено')
      }
    }
    
    // --- Демонстрация ---
    
    // Наш "DOM-элемент" — обычный объект
    const chatContainer = {
      messageCount: 0,
      lastMessage: null,
      unreadCount: 0,
    }
    
    // Создаём наблюдатель
    const observer = new MockMutationObserver((mutations) => {
      for (const mutation of mutations) {
        const { property, oldValue, newValue } = mutation
    
        if (property === 'messageCount') {
          console.log(`[Observer] Новых сообщений: ${newValue} (было: ${oldValue})`)
        }
    
        if (property === 'lastMessage') {
          console.log(`[Observer] Последнее сообщение: "${newValue}"`)
        }
    
        if (property === 'unreadCount' && newValue > 0) {
          console.log(`[Observer] Обновление badge: ${newValue} непрочитанных`)
        }
      }
    })
    
    // Начинаем наблюдение — получаем proxy-версию объекта
    const observedChat = observer.observe(chatContainer)
    
    // Изменяем объект — наблюдатель реагирует автоматически
    console.log('--- Приходят новые сообщения ---\n')
    
    observedChat.lastMessage = 'Привет! Как дела?'
    observedChat.messageCount = 1
    observedChat.unreadCount = 1
    
    observedChat.lastMessage = 'Готов к встрече в 15:00?'
    observedChat.messageCount = 2
    observedChat.unreadCount = 2
    
    // Пользователь прочитал сообщения
    console.log('\n--- Пользователь прочитал сообщения ---\n')
    observedChat.unreadCount = 0
    
    // Отключаем наблюдатель
    console.log('\n--- Отключаем наблюдатель ---')
    observer.disconnect()
    
    // Изменения больше не отслеживаются
    observedChat.messageCount = 5
    console.log('Изменение после disconnect не вызвало callback')

    Задание

    Реализуй класс Observable, который принимает объект и возвращает реактивную версию. При изменении любого свойства Observable должен уведомлять всех подписанных наблюдателей, вызывая их с объектом { property, oldValue, newValue }. Реализуй методы subscribe(callback) и unsubscribe(callback).

    Подсказка

    Use Proxy to intercept set operations, call registered callbacks with { property, oldValue, newValue }. subscribe: this._observers.push(callback); notify: this._observers.forEach(cb => cb({ property, oldValue, newValue }))

    Загружаем среду выполнения...
    Загружаем AI-помощника...