Xiper

MutationObserver и MutationEvent

Автор: Валерий Съестов Дата публикации:

Часто бывают ситуации, когда при динамической вставке элемента в DOM, или изменении атрибута элемента, необходимо выполнить с этим элементом некоторые действия. Например, это может быть запуск сценария для конвертации стандартных элементов ввода в нестандартные (checkbox, radio кнопок, выпадающих списков, загрузчика файлов, и т.д.), навешивание событий на новые элементы. В современных веб-приложениях это дает особую пользу, в том случае когда нам неизвестно, какое содержимое будет вставлено в DOM, когда данные приходят с сервера используя AJAX.

Ранее, для того чтобы вести контроль над тем, какие данные были вставлены в DOM, необходимо было выполнять поиск по содержимому, проводить различные проверки DOM элементов, что накладывало собой большую нагрузку на производительность. Вдобавок к этому, нужно было учитывать в каких местах кода, есть необходимость выполнять эти действия, особенно если над проектом параллельно работает несколько человек - это накладывает определенные сложности и нарушает чистоту кода.

Современные браузеры поддерживают инструменты, с помощью которых предоставляется возможность реагировать на изменения DOM.

Первый из таких инструментов, это MutationEvents, интерфейс которого описан в спецификации DOM2, в котором определено несколько событий, которые срабатывают при модификации DOM.

Событие Описание
DOMSubtreeModified Это событие уведомляет о всех изменениях элементов документа. Он может быть использован вместо более конкретных событий, перечисленных ниже.
DOMNodeInserted Срабатывает при вставке элементов в DOM.
DOMNodeRemoved Срабатывает при удалении DOM элемента.
DOMNodeRemovedFromDocument Срабатывает, когда элемент удаляется из документа, либо путем прямого удаления узла или удаления поддерева, в котором он содержится. Это событие срабатывает перед удалением. Событие DOMNodeRemoved срабатывает до начала этого события.
DOMNodeInsertedIntoDocument Это событие срабатывает после того как в документв вставлен элемент. Событие DOMNodeInserted срабатывает раньше этого события.
DOMAttrModified Срабатывает, когда выполняется изменение атрибутов элемента DOM.
DOMCharacterDataModified Срабатывает, когда выполняется модификация текстового элемента DOM.

Однако использование мутационных событий имеет некоторые проблемы.

Проблемы связанные с MutationEvents

Идея таких мутационных событий хороша в теории, но на практике появились две основные проблемы:

  1. MutationEvents синхронны. События запускаются при их вызове, и могут предотвращать вызов других событий, которые находятся в очереди. Так же добавление и удаление узлов, может привести к замедлению или зависанию приложения.
  2. Потому, что это события, и реализована эта технология как события. События могут срабатывать, а иногда и всплывать. При срабатывании этих событий или всплытии, могут происходить изменения в DOM, что может способствовать повторному срабатыванию MutationEvents - и такое поведение может привести к полному зависанию браузера.

В итоге, получается, что события мутации DOM достаточно запутаны, по этому они не рекомендуются в DOM Level 3 спецификации. Но если события мутации являются устаревшими, нам нужно что-то, чтобы заменить их. На замену событий к нам приходит MutationObserver

MutationObserver - предоставляет разработчикам возможность реагировать на изменения в DOM. Он предназначен в качестве замены для события типа MutationEvent, определенных в спецификации событий DOM2.

В чем заключается разница между MutationEvents и MutationObserver

MutationObserver определяется в стандарте DOM, и отличаются от MutationEvents одним ключевым свойством - MutationObserver является асинхронными. Он не срабатывает каждый раз, когда происходит событие. Вместо этого:

  • Ожидает окончания других сценариев или задач.
  • Сообщает об изменении в виде массива мутаций а не один за одним.
  • Можно наблюдать все изменения в элементы, или только отдельные.

Более того, поскольку MutationObserver не является событием, по этому не накладывает нагрузку на систему событий, а так же менее вероятно может затормозить UI или вызвать "падение" браузера.

Давайте рассмотрим пример. В приведенном ниже коде, мы добавляя 2500 параграфов к фрагменту документа, а затем добавим фрагмент в документ.

var docFrag  = document.createDocumentFragment(),
    thismany = 2500,
    i=0,
    a = document.querySelector("article"),
    p;
while ( i < thismany) {
    // Creates a new p element if one doesn't exists. 
    // Clones the existing element if it does.
    p = (p === undefined) ? document.createElement("p") : p.cloneNode(false); 
    docFrag.appendChild(p);
    i++;
}
a.appendChild( docFrag );

Даже при таком оптимизированном способе вставки элементов на страницу, при использовании MutationEvents, произойдет генерация 2500 DOMNodeInserted событий, по одному на каждый параграф. В случае использования MutationObserver, функция обратного вызова будет вызвана только один раз, и будет содержать все 2500 элементов.

Для начала работы с MutationObserver необходимо выполнить инициализацию с одним параметром, функцией обратного вызова, которая будет срабатывать при изменении DOM.

var observer = new MutationObserver(function (mutations) {
	// выполнение требуемых действий
});

Инстанцированный объект имеет три метода:

Наименование Описание
observe Регистрирует экземпляр объекта MutationObserver получать уведомления о DOM мутаций на указанном узле, иными словами - выполняет запуск работы прослушивания изменений в DOM.
disconnect Останавливает экземпляр объекта MutationObserver от получения уведомлений о DOM мутациях.
takeRecords Очищает очередь записи экземпляра MutationObserver и возвращает то, что было там.

При старте прослушивания, функция observe принимает два параметра, первый - DOM элемент изменения в котором будут наблюдаться, и второй параметр, который является настройками отвечающими за сам процесс наблюдения, представляет из себя объект со следующими свойствами:

Свойство Описание
childList Устанавливается в true, что бы наблюдать за дочерними узлами внутри заданного контейнера
attributes Устанавливается в true, что бы наблюдать за атрибутами дочерних элементов заданного контейнера
characterData Устанавливается в true, что бы наблюдать за изменениями текстовых DOM элементов.
subtree По умолчанию, объект наблюдает только за элементам верхнего уровня, заданного контейнера, для того что бы отслеживать мутации во всех уровнях внутри контейнера, нужно установить этот параметр в true.
attributeOldValue Необходимо установить параметр в true, что бы значение атрибута перед мутацией было записано
characterDataOldValue Необходимо установить параметр в true, что бы значение текстового элемента перед мутацией было записано
attributeFilter Устанавливается массив атрибутов, на которые не должен реагировать наблюдатель

При изменении узла в DOM элементе наблюдаемого контейнера, в функцию обратного вызова передается массив записей. Каждая запись состоит из таких атрибутов:

Наименование Описание
type Содержит наименование типа изменяемого значения: childList, attributes, characterData
target Возвращает DOM узел, на которых повешен наблюдатель
addedNodes Возвращает массив добавленных узлов, или null
removedNodes Возвращает массив удаленных узлов, или null
previousSibling Возвращает предыдущий соседний узел из добавленных или удаленных узлов, или null.
nextSibling Возвращает следующий соседний узел из добавленных или удаленных узлов, или null.
attributeName Возвращает имя измененного атрибута, или null.
attributeNamespace Возвращает пространство имен измененного атрибута, или null.
oldValue Возвращает значение, зависящие от типа. Для attribute - значение измененного атрибута до изменения. Для CharacterData, это данные измененной узла до изменения. Для ChildList, он является недействительным.

Пример использования

/**
 * Mutation observer. Test application
 *
 * @module MutationObserverTest
 */
(function MutationObserverTest() {
    "use strict";
    var /**
         * Contains main objects
         *
         * @property controls
         */
        controls = {},
        /**
         * Contains event handlers
         *
         * @property handlers
         */
        handlers = {
            /**
             * Add new element to document
             *
             * @method addElementClick
             */
            addElementClick: function () {
                var div = document.createElement("div"),
                    newId = parseInt((Math.random() * 1000)).toString();
                div.setAttribute("data-name", "element");
                div.innerHTML =
                    '<label for=" + newId + "> </label>' +
                    '<input id=" + newId + " type="checkbox" name="some-check">' +
                    '<input type="text" name="some-text">';
                controls.content.appendChild(div);
            },
            /**
             * triggered when click by remove button
             *
             * @method removeElementClick
             */
            removeElementClick: function () {
                var elementForRemove = controls.content.querySelectorAll("input:checked"),
                    elementsCount = elementForRemove.length,
                    i;
                for (i = 0; i < elementsCount; i++) {
                    controls.content.removeChild(elementForRemove[i].parentNode);
                }
            },
            /**
             * Mutation event
             *
             * @method domSubtreeModified
             */
            domSubtreeModified: function (event) {
                console.log("Event: DOMSubtreeModified");
            },
            /**
             * Mutation event
             *
             * @method domNodeInserted
             */
            domNodeInserted: function () {
                console.log("Event: DOMNodeInserted");
            },
            /**
             * Mutation event
             *
             * @method domNodeRemove
             */
            domNodeRemove: function () {
                console.log("Event: DOMNodeRemove");
            },
            /**
             * Mutation event
             *
             * @method domAttModified
             */
            domAttModified: function () {
                console.log("Event: DOMAttModified");
            },
            /**
             * Mutation event
             *
             * @method domCharacterDataModified
             */
            domCharacterDataModified: function () {
                console.log("Event: DOMCharacterDataModified");
            },
            /**
             * Mutation event
             *
             * @method domNodeRemovedFromDocument
             */
            domNodeRemovedFromDocument: function () {
                console.log("Event: DOMNodeRemovedFromDocument");
            },
            /**
             * Mutation event
             *
             * @method domNodeInsertedIntoDocument
             */
            domNodeInsertedIntoDocument: function () {
                console.log("Event: DOMNodeInsertedIntoDocument");
            },
            /**
             * Mutation observer callback event
             *
             * @method mutationObserverCallback
             */
            mutationObserverCallback: function (mutations) {
                var mutationCount = mutations.length,
                    i,
                    currentMutation,
                    monitorMessage;
                for (i = 0; i < mutationCount; i++) {
                    currentMutation = mutations[i];
                    monitorMessage = document.createElement("div");
                    monitorMessage.innerHTML = "MutationObserver - " + currentMutation.type;
                    controls.monitor.appendChild(monitorMessage);
                    switch (currentMutation.type) {
                        case "childList":
                            if (currentMutation.addedNodes.length) {
                                currentMutation.addedNodes[0].setAttribute("name", "page element");
                                currentMutation.addedNodes[0].setAttribute("data-name", "page element");
                            } else if (currentMutation.removedNodes.length) {
                                console.log("MutationObserver - remove node");
                            }
                            break;
                        case "attributes":
                            currentMutation.target.querySelector("label").innerText =
                                currentMutation.target.getAttribute(currentMutation.attributeName);
                            break;
                        case "characterData":
                            console.log("MutationObserver - " + currentMutation.target.nodeValue);
                            break;
                    }
                }
            }
        };
  // Выбираем наблюдаемый элемент
var target = document.querySelector("#some-id");
// Выполняем инстанцирование наблюдателя
var observer = new MutationObserver(function(mutations) {
  mutations.forEach(function(mutation) {
    console.log(mutation.type);
  });    
});
// Конфигурация наблюдателя
var config = { attributes: true, childList: true, characterData: true };
// Устанавливаем налюдаемый узел и конфигурацию налюдения
observer.observe(target, config);
// Через некоторое время, мы можем остановить наблюдение
observer.disconnect();

Теперь рассмотрим более сложный пример, с добавлением элементов, удалением, изменением атрибутов, а так же накладыванием фильтра на реагирование изменения некоторых атрибутов. Так же для примера можем пронаблюдать работу MutationEvents, как ведут себя события одновременно с работой MutationObserver.

Что бы не нагромождать интерфейс примера, часть информации о событиях и работе наблюдателя выводится в консоль.

<!DOCTYPE html>
<html>
<head>
   <title></title>
    <script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
    <style type="text/css">
        .main-wrap {
            width: 600px;
        }
        .main-panel__add,
        .main-panel__remove {
            color: blue;
            text-decoration: underline;
            cursor: pointer;
        }
        .main-panel__add:hover {
            text-decoration: none;
        }
        .monitor {
            float: right;
            width: 200px;
        }
        .content {
            width: 400px;
        }
    </style>
</head>
<body>
    <!-- main wrap -->
    <div id="main-wrap" class="main-wrap">
        <!-- main panel -->
        <div class="main-panel">
            <span class="main-panel__add">Добавить элемент</span>
            <span class="main-panel__remove">Удалить выбранный элемент</span>
        </div>
        <!--/main panel -->
        <!-- monitor -->
        <aside class="monitor"></aside>
        <!--/monitor -->
        <!-- content -->
        <div class="content"></div>
        <!--/content -->
    </div>
    <!--/main wrap -->
    <script src="js/main.js"></script>
</body>
</html>
/**
 * Mutation observer. Test application
 *
 * @module MutationObserverTest
 */
(function MutationObserverTest() {
    'use strict';
    var /**
         * Contains main objects
         *
         * @property controls
         */
        controls = {},
        /**
         * Contains event handlers
         *
         * @property handlers
         */
        handlers = {
            /**
             * Add new element to document
             *
             * @method addElementClick
             */
            addElementClick: function () {
                var div = document.createElement("div"),
                    newId = parseInt((Math.random() * 1000)).toString();
                div.setAttribute("data-name", "element");
                div.innerHTML =
                    '<label for=" + newId + "> </label>' +
                    '<input id=" + newId + " type="checkbox" name="some-check">' +
                    '<input type="text" name="some-text">';
                controls.content.appendChild(div);
            },
            /**
             * triggered when click by remove button
             *
             * @method removeElementClick
             */
            removeElementClick: function () {
                var elementForRemove = controls.content.querySelectorAll("input:checked"),
                    elementsCount = elementForRemove.length,
                    i;
                for (i = 0; i < elementsCount; i++) {
                    controls.content.removeChild(elementForRemove[i].parentNode);
                }
            },
            /**
             * Mutation event
             *
             * @method domSubtreeModified
             */
            domSubtreeModified: function (event) {
                console.log("Event: DOMSubtreeModified");
            },
            /**
             * Mutation event
             *
             * @method domNodeInserted
             */
            domNodeInserted: function () {
                console.log("Event: DOMNodeInserted");
            },
            /**
             * Mutation event
             *
             * @method domNodeRemove
             */
            domNodeRemove: function () {
                console.log("Event: DOMNodeRemove");
            },
            /**
             * Mutation event
             *
             * @method domAttModified
             */
            domAttModified: function () {
                console.log("Event: DOMAttModified");
            },
            /**
             * Mutation event
             *
             * @method domCharacterDataModified
             */
            domCharacterDataModified: function () {
                console.log("Event: DOMCharacterDataModified");
            },
            /**
             * Mutation event
             *
             * @method domNodeRemovedFromDocument
             */
            domNodeRemovedFromDocument: function () {
                console.log("Event: DOMNodeRemovedFromDocument");
            },
            /**
             * Mutation event
             *
             * @method domNodeInsertedIntoDocument
             */
            domNodeInsertedIntoDocument: function () {
                console.log("Event: DOMNodeInsertedIntoDocument");
            },
            /**
             * Mutation observer callback event
             *
             * @method mutationObserverCallback
             */
            mutationObserverCallback: function (mutations) {
                var mutationCount = mutations.length,
                    i,
                    currentMutation,
                    monitorMessage;
                for (i = 0; i < mutationCount; i++) {
                    currentMutation = mutations[i];
                    monitorMessage = document.createElement("div");
                    monitorMessage.innerHTML = "MutationObserver - " + currentMutation.type;
                    controls.monitor.appendChild(monitorMessage);
                    switch (currentMutation.type) {
                        case "childList":
                            if (currentMutation.addedNodes.length) {
                                currentMutation.addedNodes[0].setAttribute("name", "page element");
                                currentMutation.addedNodes[0].setAttribute("data-name", "page element");
                            } else if (currentMutation.removedNodes.length) {
                                console.log("MutationObserver - remove node");
                            }
                            break;
                        case "attributes":
                            currentMutation.target.querySelector("label").innerText =
                                currentMutation.target.getAttribute(currentMutation.attributeName);
                            break;
                        case "characterData":
                            console.log("MutationObserver - " + currentMutation.target.nodeValue);
                            break;
                    }
                }
            }
        };
    /**
     * get page controls
     *
     * @method getControls
     */
    function getControls() {
        var mainWrap = document.getElementById("main-wrap");
        controls.mainWrap = mainWrap;
        controls.content = mainWrap.querySelector(".content");
        controls.monitor = mainWrap.querySelector(".monitor");
    }
    /**
     * add event listener
     *
     * @method addEventList
     */
    function addEventList() {
        $(controls.mainWrap).on("click", ".main-panel__add", handlers.addElementClick);
        $(controls.mainWrap).on("click", ".main-panel__remove", handlers.removeElementClick);
        controls.content.addEventListener("DOMSubtreeModified", handlers.domSubtreeModified, false);
        controls.content.addEventListener("DOMNodeInserted", handlers.domNodeInserted, false);
        controls.content.addEventListener("DOMNodeRemoved", handlers.domNodeRemove, false);
        controls.content.addEventListener("DOMAttrModified", handlers.domAttModified, false);
        controls.content.addEventListener("DOMCharacterDataModified", handlers.domCharacterDataModified, false);
        controls.content.addEventListener("DOMNodeRemovedFromDocument", handlers.domNodeRemovedFromDocument, false);
        controls.content.addEventListener("DOMNodeInsertedIntoDocument", handlers.domNodeInsertedIntoDocument, false);
    }
    function initMutationObserver() {
        var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
            observer;
        observer = new MutationObserver(handlers.mutationObserverCallback);
        observer.observe(controls.content, {
            childList: true,
            attributes: true,
            characterData: true,
            subtree: true,
            attributeOldValue: true,
            characterDataOldValue: true,
            attributeFilter: ["data-name"]
        });
    }
    /**
     * init function
     *
     * @method init
     */
    function init() {
        getControls();
        addEventList();
        initMutationObserver();
    }
    return init();
}());

Пример в действии

Какими браузерами поддерживается

Для тех браузеров, которые не поддерживают эту технологию, можно использовать MutationEvent или иные приемы, к примеру, выполнять с определенным интервалом проверку на наличие новых элементов в наблюдаемом узле DOM.