Sometimes you need to be notified when nodes change in a DOM tree. In the recent articles we had a look at everything from constraint validation to iterating through nodes. We always assumed that the information about the specific node structure has only to be known at the time of execution. This assumption is mostly correct, but may be flawed when it comes to view managers.
A view manager tries to keep the view consistent with some underlying data, and may also want to keep the view consistent in the other direction. If something in the view changes, these changes need to be reflected on the original dataset.
In this tutorial we will have a look at the MutationObserver
, which provides us with a way to observe changes in a DOM tree. It is designed as a replacement for the original Mutation Events defined in the DOM L3 Events specification.
Listening for Change
The Mutation Events API was specified around the year 2000. It should provide an easy way to observe and react to changes in a DOM tree. It consisted of several different events, such as DOMNodeRemoved
and DOMAttrModified
. Events were fired directly, i.e. synchronously, after the node changed.
Even though this feature did not gain much popularity, it presented a very convenient method of observing changes. Among the most popular use cases have been extensions for browsers. If something in the page changes, installed extensions can be notified. At this point it is possible to perform some work on the page.
So let’s see a classic solution for being notified about potential changes in the DOM tree. Setting up the right listeners is as easy as the following code snippet.
["DOMNodeInserted", "DOMAttrModified", "DOMNodeRemoved"].forEach(function (eventName) { document.documentElement.addEventListener(eventName, callback, true); });
The listener is defined as follows. We switch on the attrChange
property of the event arguments.
var callback = function (ev) { var nodeOrAttr = ev.relatedNode; switch (ev.attrChange) { case MutationEvent.MODIFICATION: console.log('changed attribute', ev.attrName, ev.prevValue, ev.newValue, nodeOrAttr); break; case MutationEvent.ADDITION: console.log('added attribute', ev.attrName, ev.prevValue, ev.newValue, nodeOrAttr); break; case MutationEvent.REMOVAL: console.log('removed attribute', ev.attrName, ev.prevValue, ev.newValue, nodeOrAttr); break; default: console.log(ev.type, nodeOrAttr); break; } };
Testing the code above can be performed with any HTML page. We use the following code to see how our listeners are being called.
var newNode = document.createElement('div'); newNode.setAttribute('id', 'initial'); document.body.appendChild(newNode); newNode.setAttribute('class', 'foo'); newNode.setAttribute('id', 'bar'); newNode.removeAttribute('id'); document.body.removeChild(newNode);
In Firefox we will see output similar to the following picture.
The observed behavior is, unfortunately, not the same across all browsers. There are many subtle issues with the approach we just sketched. One of the biggest concerns is that Mutation events have not been implemented in a complete and interoperable way across most popular browsers.
Another reason is that even though Mutation events are quite useful, they have always been responsible for performance issues. They are slow. This is partially because they are fired too frequently in a synchronous way. Also we may end up in a recursive loop, filling up the stack. In the end they might be the origin for some undesired bugs in the browser.
Can we do better? Yes we can! We now introduce the MutationObserver
interface.
Mutation Observer
Introduced in the DOM L4 specification, mutation observers will replace the former Mutation Events. There are many key differences. For us the most important one is the asynchronous execution. A MutationObserver
instance will never fire in the current event loop cycle, but rather be enqueued to run in the next one. As a consequence, mutation observers aggregate changes. Instead of receiving many events for many changes, we may only receive a single event containing all the changes.
The asynchronous batch processing is useful because our observer method isn’t called every time a change is made to the DOM. Instead the callback is called (soon) after all the changes have been completed. This avoids the loss of simultaneous execution as we don’t need to be concerned with a flash of unstyled content before we have a chance to react.
Here’s how the callback looks for a mutation observer:
var callback = function (mutations) { mutations.forEach(function (mutation) { var target = mutation.target; switch (mutation.type) { case 'attributes': var attribute = mutation.attributeName; var oldValue = mutation.oldValue; var newValue = target.getAttribute(attribute); if (mutation.oldValue === null) console.log('added attribute', attribute, '', newValue, target); else console.log('changed attribute', attribute, oldValue, newValue, target); break; case 'childList': if (mutation.addedNodes.length > 0) console.log('added nodes', mutation.addedNodes, target); else if (mutation.removedNodes.length > 0) console.log('removed nodes', mutation.removedNodes, target); break; } }); };
The callback is passed to the constructor to create a new MutationObserver
instance. The following snippet uses all possible options for starting the observation. In reality we can leave out the disabled options.
var mo = new MutationObserver(callback); mo.observe(document.documentElement, { childList: true, attributes: true, characterData: false, subtree: true, attributeOldValue: true, characterDataOldValue: false, });
Obviously there’s a lot to using the MutationObserver
, but essentially it boils down to:
- Create a new
MutationObserver
object with a callback to handle any event thrown its way. - Tell the
MutationObserver
object to observe a certain node with the desired options. - Stop observing events by disconnecting the
MutationObserver
object, i.e.
mo.disconnect();
The example above reacts to the same mutations as the previous one using Mutation events. The major difference is that we use a MutationObserver
, which gives us better performance, support and flexibility. In Firefox we now get the following using a MutationObserver
.
Even though the result looks very similar, a crucial difference is already apparent: The target node has already been mutated in all examples. Let’s look at the line with the added attribute
event. Here we add a new attribute with class=foo
. However, the attribute is already present. Even more obvious is the next line. This one has been generated by changing the value of the id
attribute from initial
to bar
. Yet we have no chance of seeing the new attribute, nor is any id
attribute attached to the node.
The reason is that we already (remember, our test function was executed in a single step!) removed the node. The asynchronous execution of the MutationObserver
dispatch did not interfere with our code at all. The result may therefore lack some of the interaction possibilities of the former approach, but it adds performance and batch execution to it. These properties are worth much more.
Real-World Examples
We already mentioned that browser extensions are good examples of applications that need to utilize mutation observers. But also some frameworks could not live without them. We now want to look at two popular MVC frameworks, Aurelia and Polymer.
Aurelia is one of the new kids on the block. It is a state-of-the-art client framework for multiple platforms. One of its features is that it prefers convention over configuration. One of the challenges for the Aurelia team was to support browsers such as IE9. They identified the missing MutationObserver
to be the root cause for their compatibility issues. Finally they decided to fill that hole with a polyfill.
Internally Aurelia observes DOM tree modifications in their templating module. The core framework does not care about such changes. In the templating module we find a class called ChildObserver
, which uses a MutationObserver
in instances of ChildObserverBinder
. These binders are used by the child observer for each target or behavior observation. Within the binder we see code similar to the following code snippet.
function bind (source) { this.observer.observe(this.target, { childList:true, subtree: true }); var results = this.target.querySelectorAll(this.selector); for (var i = 0; i < results.length; ++i) this.behavior[this.property].push(results[i]); } function unbind () { this.observer.disconnect(); }
After creating a new MutationObserver
instance in the constructor, we can use the bind
method to actually observe changes on the base target
from the source. While the different sources currently have no effect, the observed behavior is already recorded. The main mechanism can be found in the code above. We get all nodes that satisfy a certain selector and add them to a list of behaviors with a specified property name. This list will then be used in case of mutation notifications to determine if these mutations are interesting or not.
Polymer is the other framework that uses MutationObserver
extensively. Actually the polyfill that is used by Aurelia has been developed by the Polymer team. Most polyfills from Polymer can be now found in the webcomponents.js project.
One of the many usages of MutationObserver
in Polymer is to monitor changes to containers. If, for instance, style scoping should be applied to a container and all its descendants, a mutation observer watches for changes and applies the same styling to potentially new elements.
scopeSubtree: function(container, shouldObserve) { var scopify = function(node) { // ... }; scopify(container); if (shouldObserve) { var mo = new MutationObserver(function (mxns) { mxns.forEach(function (m) { if (m.addedNodes) { for (var i = 0; i < m.addedNodes.length; i++) scopify(m.addedNodes[i]); } }); }); mo.observe(container, { childList: true, subtree: true }); return mo; } }
The example above ensures proper local style scoping for elements, which are created in this local scope, but out of the control of the container. This is especially useful in conjunction with third-party libraries. A native shadow DOM implementation would of course already handle these scenarios without requiring further actions.
Conclusion
Mutation observers are a neat way to keep track of changes within the DOM. They provide a robust and fast way to be notified in case of change. Even though they make most sense for authors of browser extensions and MV* frameworks, it is good to know about them in detail. After all, they may be the reason that something in our favorite library works the way it does.
Here we conclude the HTML5 mastery series. I hope you learned something along the way. Applying some of these techniques is certainly not possible for all projects, but may come in handy in one or the other. The HTML5 specification is vast on its own. The various extensions and supplemental material are even larger. The whole DOM specification is another beast.
Comments