← Браузер/MutationObserver и ResizeObserver#191 из 383← ПредыдущийСледующий →+20 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: async и сетьТермин: Event LoopТермин: Core Web Vitals

MutationObserver и ResizeObserver

До этих API разработчики использовали мутационные события (DOMSubtreeModified, DOMNodeInserted) — они были медленными и вызывали проблемы с производительностью, потому что срабатывали синхронно. MutationObserver и ResizeObserver — современная асинхронная альтернатива.

MutationObserver

Отслеживает изменения в DOM-дереве. Колбэк вызывается асинхронно, пакетами, после всех мутаций.

const observer = new MutationObserver((mutationList, obs) => {
  mutationList.forEach(mutation => {
    console.log('Тип мутации:', mutation.type)

    if (mutation.type === 'childList') {
      console.log('Добавлено:', mutation.addedNodes)
      console.log('Удалено:', mutation.removedNodes)
    }

    if (mutation.type === 'attributes') {
      console.log('Атрибут:', mutation.attributeName)
      console.log('Старое значение:', mutation.oldValue)
    }
  })
})

Конфигурация наблюдения

observer.observe(targetNode, {
  childList: true,       // изменения дочерних узлов
  attributes: true,      // изменения атрибутов
  subtree: true,         // рекурсивно по всему поддереву
  characterData: true,   // изменения текстовых узлов
  attributeOldValue: true,    // сохранять старые значения атрибутов
  characterDataOldValue: true, // сохранять старое текстовое содержимое
  attributeFilter: ['class', 'style'],  // только эти атрибуты
})

MutationRecord

Каждая запись содержит:

  • type — 'childList', 'attributes', 'characterData'
  • target — изменённый элемент
  • addedNodes / removedNodes — NodeList добавленных/удалённых узлов
  • attributeName — имя изменённого атрибута
  • oldValue — предыдущее значение
  • Случаи применения MutationObserver

  • Отслеживание изменений, сделанных сторонним кодом
  • Полифиллы для новых HTML-элементов (Web Components)
  • Редакторы с богатым форматированием
  • Автосохранение содержимого contenteditable
  • Логирование изменений DOM для дебаггинга
  • Отключение наблюдения

    // Временно — можно получить накопленные, необработанные записи
    const pendingRecords = observer.takeRecords()
    
    // Полностью
    observer.disconnect()

    ResizeObserver

    Отслеживает изменение размеров элементов. Заменяет прослушивание window.resize для конкретных элементов.

    const resizeObserver = new ResizeObserver((entries) => {
      entries.forEach(entry => {
        const { width, height } = entry.contentRect
        console.log(`Элемент: ${entry.target.id}`)
        console.log(`Новый размер: ${width}×${height}`)
    
        // Адаптивная логика без медиа-запросов
        if (width < 400) {
          entry.target.classList.add('compact')
        } else {
          entry.target.classList.remove('compact')
        }
      })
    })
    
    resizeObserver.observe(document.querySelector('.my-component'))

    contentRect vs borderBoxSize

    entry.contentRect         // { width, height, top, left } — контентная область (без padding/border)
    entry.borderBoxSize       // [{ inlineSize, blockSize }] — с border и padding
    entry.contentBoxSize      // [{ inlineSize, blockSize }] — только контент
    entry.devicePixelContentBoxSize  // в физических пикселях устройства

    Responsive Components без медиа-запросов

    Медиа-запросы реагируют на размер viewport, а не элемента. ResizeObserver позволяет компонентам адаптироваться к своему собственному размеру — это называется Container Queries (теперь есть и CSS @container):

    const ro = new ResizeObserver(entries => {
      entries.forEach(({ target, contentRect }) => {
        target.dataset.size =
          contentRect.width < 300 ? 'small' :
          contentRect.width < 600 ? 'medium' : 'large'
      })
    })
    
    ro.observe(document.querySelector('.card'))

    Производительность

    Оба Observer работают асинхронно и пакетируют изменения. Не вызывают forced layout. Используют меньше ресурсов, чем polling или синхронные event listeners.

    Примеры

    Симуляция MutationObserver через событийную систему с виртуальным DOM-деревом

    // Симулируем MutationObserver с виртуальными DOM-узлами
    
    class VirtualNode {
      constructor(id, tag = 'div') {
        this.id = id
        this.tag = tag
        this.children = []
        this.attributes = {}
        this.textContent = ''
        this._observers = []
      }
    
      setAttribute(name, value) {
        const oldValue = this.attributes[name]
        this.attributes[name] = value
        this._notifyObservers('attributes', { attributeName: name, oldValue })
      }
    
      appendChild(child) {
        this.children.push(child)
        this._notifyObservers('childList', { addedNodes: [child], removedNodes: [] })
        return child
      }
    
      removeChild(child) {
        const index = this.children.indexOf(child)
        if (index !== -1) {
          this.children.splice(index, 1)
          this._notifyObservers('childList', { addedNodes: [], removedNodes: [child] })
        }
        return child
      }
    
      setTextContent(text) {
        const oldValue = this.textContent
        this.textContent = text
        this._notifyObservers('characterData', { oldValue })
      }
    
      _notifyObservers(type, detail) {
        for (const { observer, config, target } of this._observers) {
          const shouldNotify =
            (type === 'childList' && config.childList) ||
            (type === 'attributes' && config.attributes) ||
            (type === 'characterData' && config.characterData)
    
          if (shouldNotify) {
            const record = { type, target, ...detail }
            observer._queueRecord(record)
          }
        }
        // Оповещаем родительских наблюдателей при subtree: true
        // (упрощённо — для демонстрации)
      }
    
      _addObserver(observer, config) {
        this._observers.push({ observer, config, target: this })
      }
    
      toString() { return `<${this.tag}#${this.id}>` }
    }
    
    class SimulatedMutationObserver {
      constructor(callback) {
        this._callback = callback
        this._queue = []
        this._scheduled = false
      }
    
      observe(node, config) {
        node._addObserver(this, config)
        console.log('[MO] Наблюдаем за ' + node + ', config:', JSON.stringify(config))
      }
    
      _queueRecord(record) {
        this._queue.push(record)
        if (!this._scheduled) {
          this._scheduled = true
          // В браузере это microtask, симулируем синхронно для простоты
          Promise.resolve().then(() => {
            const records = [...this._queue]
            this._queue = []
            this._scheduled = false
            if (records.length > 0) this._callback(records, this)
          })
        }
      }
    
      takeRecords() {
        const records = [...this._queue]
        this._queue = []
        return records
      }
    
      disconnect() {
        console.log('[MO] Отключён')
      }
    }
    
    // Демо
    const root = new VirtualNode('app')
    const list = new VirtualNode('list', 'ul')
    const header = new VirtualNode('header', 'h1')
    
    const observer = new SimulatedMutationObserver((mutations) => {
      console.log(`\n[Callback] Получено ${mutations.length} мутаций:`)
      mutations.forEach(m => {
        if (m.type === 'childList') {
          const added = m.addedNodes.map(n => `${n}`).join(', ')
          const removed = m.removedNodes.map(n => `${n}`).join(', ')
          if (added) console.log(`  + Добавлены: ${added} в ${m.target}`)
          if (removed) console.log(`  - Удалены: ${removed} из ${m.target}`)
        }
        if (m.type === 'attributes') {
          console.log(`  ~ Атрибут "${m.attributeName}" изменён в ${m.target} (было: ${m.oldValue})`)
        }
      })
    })
    
    observer.observe(root, { childList: true, attributes: false })
    observer.observe(list, { childList: true, attributes: true })
    
    root.appendChild(header)
    root.appendChild(list)
    
    const item1 = new VirtualNode('item-1', 'li')
    const item2 = new VirtualNode('item-2', 'li')
    list.appendChild(item1)
    list.appendChild(item2)
    
    list.setAttribute('class', 'active-list')
    list.setAttribute('class', 'inactive-list')
    
    // Ждём microtask
    await Promise.resolve()
    await Promise.resolve()
    
    list.removeChild(item1)
    await Promise.resolve()

    MutationObserver и ResizeObserver

    До этих API разработчики использовали мутационные события (DOMSubtreeModified, DOMNodeInserted) — они были медленными и вызывали проблемы с производительностью, потому что срабатывали синхронно. MutationObserver и ResizeObserver — современная асинхронная альтернатива.

    MutationObserver

    Отслеживает изменения в DOM-дереве. Колбэк вызывается асинхронно, пакетами, после всех мутаций.

    const observer = new MutationObserver((mutationList, obs) => {
      mutationList.forEach(mutation => {
        console.log('Тип мутации:', mutation.type)
    
        if (mutation.type === 'childList') {
          console.log('Добавлено:', mutation.addedNodes)
          console.log('Удалено:', mutation.removedNodes)
        }
    
        if (mutation.type === 'attributes') {
          console.log('Атрибут:', mutation.attributeName)
          console.log('Старое значение:', mutation.oldValue)
        }
      })
    })

    Конфигурация наблюдения

    observer.observe(targetNode, {
      childList: true,       // изменения дочерних узлов
      attributes: true,      // изменения атрибутов
      subtree: true,         // рекурсивно по всему поддереву
      characterData: true,   // изменения текстовых узлов
      attributeOldValue: true,    // сохранять старые значения атрибутов
      characterDataOldValue: true, // сохранять старое текстовое содержимое
      attributeFilter: ['class', 'style'],  // только эти атрибуты
    })

    MutationRecord

    Каждая запись содержит:

  • type — 'childList', 'attributes', 'characterData'
  • target — изменённый элемент
  • addedNodes / removedNodes — NodeList добавленных/удалённых узлов
  • attributeName — имя изменённого атрибута
  • oldValue — предыдущее значение
  • Случаи применения MutationObserver

  • Отслеживание изменений, сделанных сторонним кодом
  • Полифиллы для новых HTML-элементов (Web Components)
  • Редакторы с богатым форматированием
  • Автосохранение содержимого contenteditable
  • Логирование изменений DOM для дебаггинга
  • Отключение наблюдения

    // Временно — можно получить накопленные, необработанные записи
    const pendingRecords = observer.takeRecords()
    
    // Полностью
    observer.disconnect()

    ResizeObserver

    Отслеживает изменение размеров элементов. Заменяет прослушивание window.resize для конкретных элементов.

    const resizeObserver = new ResizeObserver((entries) => {
      entries.forEach(entry => {
        const { width, height } = entry.contentRect
        console.log(`Элемент: ${entry.target.id}`)
        console.log(`Новый размер: ${width}×${height}`)
    
        // Адаптивная логика без медиа-запросов
        if (width < 400) {
          entry.target.classList.add('compact')
        } else {
          entry.target.classList.remove('compact')
        }
      })
    })
    
    resizeObserver.observe(document.querySelector('.my-component'))

    contentRect vs borderBoxSize

    entry.contentRect         // { width, height, top, left } — контентная область (без padding/border)
    entry.borderBoxSize       // [{ inlineSize, blockSize }] — с border и padding
    entry.contentBoxSize      // [{ inlineSize, blockSize }] — только контент
    entry.devicePixelContentBoxSize  // в физических пикселях устройства

    Responsive Components без медиа-запросов

    Медиа-запросы реагируют на размер viewport, а не элемента. ResizeObserver позволяет компонентам адаптироваться к своему собственному размеру — это называется Container Queries (теперь есть и CSS @container):

    const ro = new ResizeObserver(entries => {
      entries.forEach(({ target, contentRect }) => {
        target.dataset.size =
          contentRect.width < 300 ? 'small' :
          contentRect.width < 600 ? 'medium' : 'large'
      })
    })
    
    ro.observe(document.querySelector('.card'))

    Производительность

    Оба Observer работают асинхронно и пакетируют изменения. Не вызывают forced layout. Используют меньше ресурсов, чем polling или синхронные event listeners.

    Примеры

    Симуляция MutationObserver через событийную систему с виртуальным DOM-деревом

    // Симулируем MutationObserver с виртуальными DOM-узлами
    
    class VirtualNode {
      constructor(id, tag = 'div') {
        this.id = id
        this.tag = tag
        this.children = []
        this.attributes = {}
        this.textContent = ''
        this._observers = []
      }
    
      setAttribute(name, value) {
        const oldValue = this.attributes[name]
        this.attributes[name] = value
        this._notifyObservers('attributes', { attributeName: name, oldValue })
      }
    
      appendChild(child) {
        this.children.push(child)
        this._notifyObservers('childList', { addedNodes: [child], removedNodes: [] })
        return child
      }
    
      removeChild(child) {
        const index = this.children.indexOf(child)
        if (index !== -1) {
          this.children.splice(index, 1)
          this._notifyObservers('childList', { addedNodes: [], removedNodes: [child] })
        }
        return child
      }
    
      setTextContent(text) {
        const oldValue = this.textContent
        this.textContent = text
        this._notifyObservers('characterData', { oldValue })
      }
    
      _notifyObservers(type, detail) {
        for (const { observer, config, target } of this._observers) {
          const shouldNotify =
            (type === 'childList' && config.childList) ||
            (type === 'attributes' && config.attributes) ||
            (type === 'characterData' && config.characterData)
    
          if (shouldNotify) {
            const record = { type, target, ...detail }
            observer._queueRecord(record)
          }
        }
        // Оповещаем родительских наблюдателей при subtree: true
        // (упрощённо — для демонстрации)
      }
    
      _addObserver(observer, config) {
        this._observers.push({ observer, config, target: this })
      }
    
      toString() { return `<${this.tag}#${this.id}>` }
    }
    
    class SimulatedMutationObserver {
      constructor(callback) {
        this._callback = callback
        this._queue = []
        this._scheduled = false
      }
    
      observe(node, config) {
        node._addObserver(this, config)
        console.log('[MO] Наблюдаем за ' + node + ', config:', JSON.stringify(config))
      }
    
      _queueRecord(record) {
        this._queue.push(record)
        if (!this._scheduled) {
          this._scheduled = true
          // В браузере это microtask, симулируем синхронно для простоты
          Promise.resolve().then(() => {
            const records = [...this._queue]
            this._queue = []
            this._scheduled = false
            if (records.length > 0) this._callback(records, this)
          })
        }
      }
    
      takeRecords() {
        const records = [...this._queue]
        this._queue = []
        return records
      }
    
      disconnect() {
        console.log('[MO] Отключён')
      }
    }
    
    // Демо
    const root = new VirtualNode('app')
    const list = new VirtualNode('list', 'ul')
    const header = new VirtualNode('header', 'h1')
    
    const observer = new SimulatedMutationObserver((mutations) => {
      console.log(`\n[Callback] Получено ${mutations.length} мутаций:`)
      mutations.forEach(m => {
        if (m.type === 'childList') {
          const added = m.addedNodes.map(n => `${n}`).join(', ')
          const removed = m.removedNodes.map(n => `${n}`).join(', ')
          if (added) console.log(`  + Добавлены: ${added} в ${m.target}`)
          if (removed) console.log(`  - Удалены: ${removed} из ${m.target}`)
        }
        if (m.type === 'attributes') {
          console.log(`  ~ Атрибут "${m.attributeName}" изменён в ${m.target} (было: ${m.oldValue})`)
        }
      })
    })
    
    observer.observe(root, { childList: true, attributes: false })
    observer.observe(list, { childList: true, attributes: true })
    
    root.appendChild(header)
    root.appendChild(list)
    
    const item1 = new VirtualNode('item-1', 'li')
    const item2 = new VirtualNode('item-2', 'li')
    list.appendChild(item1)
    list.appendChild(item2)
    
    list.setAttribute('class', 'active-list')
    list.setAttribute('class', 'inactive-list')
    
    // Ждём microtask
    await Promise.resolve()
    await Promise.resolve()
    
    list.removeChild(item1)
    await Promise.resolve()

    Задание

    Реализуй createDOMWatcher() с методами: watchNode(nodeId, config) где config — { childList: boolean, attributes: boolean }, начинает отслеживать узел; simulateChange(nodeId, changeType, detail) симулирует мутацию (changeType: "childList" | "attributes", detail: { addedNode? } или { attributeName?, oldValue? }); getRecords() возвращает массив всех накопленных записей мутаций в формате { nodeId, type, detail }.

    Подсказка

    watchers.set(nodeId, config) сохраняет конфигурацию. В simulateChange() получай config через watchers.get(nodeId). Проверяй config.childList для childList и config.attributes для attributes. getRecords() возвращает [...records] чтобы вернуть копию.

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