Getting To Know The MutationObserver API
Getting To Know The MutationObserver API
Louis Lazaris2019-04-26T13:30:16+02:002019-04-28T16:06:00+00:00
In complex web apps, DOM changes can be frequent. As a result, there are instances where your app might need to respond to a specific change to the DOM.
For some time, the accepted way to look for changes to the DOM was by means of a feature called Mutation Events, which is now deprecated. The W3C-approved replacement for Mutation Events is the MutationObserver API, which is what I’ll be discussing in detail in this article.
A number of older articles and references discuss why the old feature was replaced, so I won’t go into detail on that here (besides the fact that I wouldn’t be able to do it justice). The MutationObserver
API has near complete browser support, so we can use it safely in most — if not all — projects, should the need arise.
Basic Syntax For A MutationObserver
A MutationObserver
can be used in a number of different ways, which I’ll cover in detail in the rest of this article, but the basic syntax for a MutationObserver
looks like this:
let observer = new MutationObserver(callback);
function callback (mutations) {
// do something here
}
observer.observe(targetNode, observerOptions);
The first line creates a new MutationObserver
using the MutationObserver()
constructor. The argument passed into the constructor is a callback function that will be called on each DOM change that qualifies.
The way to determine what qualifies for a particular observer is by means of the final line in the above code. On that line, I’m using the observe()
method of the MutationObserver
to begin observing. You can compare this to something like addEventListener()
. As soon as you attach a listener, the page will ‘listen’ for the specified event. Similarly, when you start observing, the page will begin ‘observing’ for the specified MutationObserver
.
The observe()
method takes two arguments: The target, which should be the node or node tree on which to observe for changes; and an options object, which is a MutationObserverInit
object that allows you to define the configuration for the observer.
The final key basic feature of a MutationObserver
is the disconnect()
method. This allows you to stop observing for the specified changes, and it looks like this:
observer.disconnect();
Options To Configure A MutationObserver
As mentioned, the observe()
method of a MutationObserver
requires a second argument that specifies the options to describe the MutationObserver
. Here’s how the options object would look with all possible property/value pairs included:
let options = {
childList: true,
attributes: true,
characterData: false,
subtree: false,
attributeFilter: ['one', 'two'],
attributeOldValue: false,
characterDataOldValue: false
};
When setting up the MutationObserver
options, it’s not necessary to include all these lines. I’m including these simply for reference purposes, so you can see what options are available and what types of values they can take. As you can see, all except one are Boolean.
In order for a MutationObserver
to work, at least one of childList
, attributes
, or characterData
needs to be set to true
, otherwise an error will be thrown. The other four properties work in conjunction with one of those three (more on this later).
So far I’ve merely glossed over the syntax to give you an overview. The best way to consider how each of these features works is by providing code examples and live demos that incorporate the different options. So that’s what I’ll do for the rest of this article.
Observing Changes To Child Elements Using childList
The first and simplest MutationObserver
you can initiate is one that looks for child nodes of a specified node (usually an element) to be added or removed. For my example, I’m going to create an unordered list in my HTML, and I want to know whenever a child node is added or removed from this list element.
The HTML for the list looks like this:
<ul id="myList" class="list">
<li>Apples</li>
<li>Oranges</li>
<li>Bananas</li>
<li class="child">Peaches</li>
</ul>
The JavaScript for my MutationObserver
includes the following:
let mList = document.getElementById('myList'),
options = {
childList: true
},
observer = new MutationObserver(mCallback);
function mCallback(mutations) {
for (let mutation of mutations) {
if (mutation.type === 'childList') {
console.log('Mutation Detected: A child node has been added or removed.');
}
}
}
observer.observe(mList, options);
This is only part of the code. For brevity, I’m showing the most important sections that deal with the MutationObserver
API itself.
Notice how I’m looping through the mutations
argument, which is a MutationRecord
object that has a number of different properties. In this case, I’m reading the type
property and logging a message indicating that the browser has detected a mutation that qualifies. Also, notice how I’m passing the mList
element (a reference to my HTML list) as the targeted element (i.e. the element on which I want to observe for changes).
Use the buttons to start and stop the MutationObserver
. The log messages help clarify what’s happening. Comments in the code also provide some explanation.
Note a few important points here:
- The callback function (which I’ve named
mCallback
, to illustrate that you can name it whatever you want) will fire each time a successful mutation is detected and after theobserve()
method is executed. - In my example, the only ‘type’ of mutation that qualifies is
childList
, so it makes sense to look for this one when looping through the MutationRecord. Looking for any other type in this instance would do nothing (the other types will be used in subsequent demos). - Using
childList
, I can add or remove a text node from the targeted element and this too would qualify. So it doesn’t have to be an element that’s added or removed. - In this example, only immediate child nodes will qualify. Later in the article, I’ll show you how this can apply to all child nodes, grandchildren, and so on.
Observing For Changes To An Element’s Attributes
Another common type of mutation that you might want to track is when an attribute on a specified element changes. In the next interactive demo, I’m going to observe for changes to attributes on a paragraph element.
let mPar = document.getElementById('myParagraph'),
options = {
attributes: true
},
observer = new MutationObserver(mCallback);
function mCallback (mutations) {
for (let mutation of mutations) {
if (mutation.type === 'attributes') {
// Do something here...
}
}
}
observer.observe(mPar, options);
Again, I’ve abbreviated the code for clarity, but the important parts are:
- The
options
object is using theattributes
property, set totrue
to tell theMutationObserver
that I want to look for changes to the targeted element’s attributes. - The mutation type I’m testing for in my loop is
attributes
, the only one that qualifies in this case. - I’m also using the
attributeName
property of themutation
object, which allows me to find out which attribute was changed. - When I trigger the observer, I’m passing in the paragraph element by reference, along with the options.
In this example, a button is used to toggle a class name on the targeted HTML element. The callback function in the mutation observer is triggered every time the class is added or removed.
Observing For Character Data Changes
Another change you might want to look for in your app is mutations to character data; that is, changes to a specific text node. This is done by setting the characterData
property to true
in the options
object. Here’s the code:
let options = {
characterData: true
},
observer = new MutationObserver(mCallback);
function mCallback(mutations) {
for (let mutation of mutations) {
if (mutation.type === 'characterData') {
// Do something here...
}
}
}
Notice again the type
being looked for in the callback function is characterData
.
In this example, I’m looking for changes to a specific text node, which I target via element.childNodes[0]
. This is a little hacky but it will do for this example. The text is user-editable via the contenteditable
attribute on a paragraph element.
Challenges When Observing For Character Data Changes
If you’ve fiddled around with contenteditable
, then you might be aware that there are keyboard shortcuts that allow for rich text editing. For example, CTRL-B makes text bold, CTRL-I makes text italic, and so forth. This will break up the text node into multiple text nodes, so you’ll notice the MutationObserver
will stop responding unless you edit the text that’s still considered part of the original node.
I should also point out that if you delete all the text, the MutationObserver
will no longer trigger the callback. I’m assuming this happens because once the text node disappears, the target element is no longer in existence. To combat this, my demo stops observing when the text is removed, although things do get a little sticky when you use rich text shortcuts.
But don’t worry, later in this article, I’ll discuss a better way to use the characterData
option without having to deal with as many of these quirks.
Observing For Changes To Specified Attributes
Earlier I showed you how to observe for changes to attributes on a specified element. In that case, although the demo triggers a class name change, I could have changed any attribute on the specified element. But what if I want to observe changes to one or more specific attributes while ignoring the others?
I can do that using the optional attributeFilter
property in the option
object. Here’s an example:
let options = {
attributes: true,
attributeFilter: ['hidden', 'contenteditable', 'data-par']
},
observer = new MutationObserver(mCallback);
function mCallback (mutations) {
for (let mutation of mutations) {
if (mutation.type === 'attributes') {
// Do something here...
}
}
}
As shown above, the attributeFilter
property accepts an array of specific attributes that I want to monitor. In this example, the MutationObserver
will trigger the callback each time one or more of the hidden
, contenteditable
, or data-par
attributes is modified.
Again I’m targeting a specific paragraph element. Notice the select drop down that chooses which attribute will be changed. The draggable
attribute is the only one that won’t qualify since I didn’t specify that one in my options.
Notice in the code that I’m again using the attributeName
property of the MutationRecord
object to log which attribute was changed. And of course, as with the other demos, the MutationObserver
won’t start monitoring for changes until the “start” button is clicked.
One other thing I should point out here is that I don’t need to set the attributes
value to true
in this case; it’s implied due to attributesFilter
being set to true. That’s why my options object could look as follows, and it would work the same:
let options = {
attributeFilter: ['hidden', 'contenteditable', 'data-par']
}
On the other hand, if I explicitly set attributes
to false
along with an attributeFilter
array, it wouldn’t work because the false
value would take precedence and the filter option would be ignored.
Observing For Changes To Nodes And Their Sub-Tree
So far when setting up each MutationObserver
, I’ve only been dealing with the targeted element itself and, in the case of childList
, the element’s immediate children. But there certainly could be a case where I might want to observe for changes to one of the following:
- An element and all its child elements;
- One or more attributes on an element and on its child elements;
- All text nodes inside an element.
All of the above can be achieved using the subtree
property of the options object.
childList With subtree
First, let’s look for changes to an element’s child nodes, even if they’re not immediate children. I can alter my options object to look like this:
options = {
childList: true,
subtree: true
}
Everything else in the code is more or less the same as the previous childList
example, along with some extra markup and buttons.
Here there are two lists, one nested inside the other. When the MutationObserver
is started, the callback will trigger for changes to either list. But if I were to change the subtree
property back to false
(the default when it’s not present), the callback would not execute when the nested list is modified.
Attributes With subtree
Here’s another example, this time using subtree
with attributes
and attributeFilter
. This allows me to observe for changes to attributes not only on the target element but also on the attributes of any child elements of the target element:
options = {
attributes: true,
attributeFilter: ['hidden', 'contenteditable', 'data-par'],
subtree: true
}
This is similar to the previous attributes demo, but this time I’ve set up two different select elements. The first one modifies attributes on the targeted paragraph element while the other one modifies attributes on a child element inside the paragraph.
Again, if you were to set the subtree
option back to false
(or remove it), the second toggle button would not trigger the MutationObserver
callback. And, of course, I could omit attributeFilter
altogether, and the MutationObserver
would look for changes to any attributes in the subtree rather than the specified ones.
characterData With subtree
Remember in the earlier characterData
demo, there were some problems with the targeted node disappearing and then the MutationObserver
no longer working. While there are ways to get around that, it’s easier to target an element directly rather than a text node, then use the subtree
property to specify that I want all the character data inside that element, no matter how deeply nested it is, to trigger the MutationObserver
callback.
My options in this case would look like this:
options = {
characterData: true,
subtree: true
}
After you start the observer, try using CTRL-B and CTRL-I to format the editable text. You’ll notice this works much more effectively than the previous characterData
example. In this case, the broken up child nodes don’t affect the observer because we’re observing all nodes inside the targeted node, instead of a single text node.
Recording Old Values
Often when observing for changes to the DOM, you’ll want to take note of the old values and possibly store them or use them elsewhere. This can be done using a few different properties in the options
object.
attributeOldValue
First, let’s try logging out the old attribute value after it’s changed. Here’s how my options will look along with my callback:
options = {
attributes: true,
attributeOldValue: true
}
function mCallback (mutations) {
for (let mutation of mutations) {
if (mutation.type === 'attributes') {
// Do something here...
}
}
}
Notice the use of the attributeName
and oldValue
properties of the MutationRecord
object. Try the demo by entering different values in the text field. Notice how the log updates to reflect the previous value that was stored.
characterDataOldValue
Similarly, here’s how my options would look if I want to log old character data:
options = {
characterData: true,
subtree: true,
characterDataOldValue: true
}
Notice the log messages indicate the previous value. Things do get a little wonky when you add HTML via rich text commands to the mix. I’m not sure what the correct behavior is supposed to be in that case but it is more straightforward if the only thing inside the element is a single text node.
Intercepting Mutations Using takeRecords()
Another method of the MutationObserver
object that I haven’t mentioned yet is takeRecords()
. This method allows you to more or less intercept the mutations that are detected before they are processed by the callback function.
I can use this feature using a line like this:
let myRecords = observer.takeRecords();
This stores a list of the DOM changes in the specified variable. In my demo, I’m executing this command as soon as the button that modifies the DOM is clicked. Notice that the start and add/remove buttons don’t log anything. This is because, as mentioned, I’m intercepting the DOM changes before they are processed by the callback.
Notice, however, what I’m doing in the event listener that stops the observer:
btnStop.addEventListener('click', function () {
observer.disconnect();
if (myRecords) {
console.log(`${myRecords[0].target} was changed using the ${myRecords[0].type} option.`);
}
}, false);
As you can see, after stopping the observer using observer.disconnect()
, I’m accessing the mutation record that was intercepted and I’m logging the target element as well as the type of mutation that was recorded. If I had been observing for multiple types of changes then the stored record would have more than one item in it, each with its own type.
When a mutation record is intercepted in this way by calling takeRecords()
, the queue of mutations that would normally be sent to the callback function is emptied. So if for some reason you need to intercept these records before they’re processed, takeRecords()
would come in handy.
Observing For Multiple Changes Using A Single Observer
Note that if I’m looking for mutations on two different nodes on the page, I can do so using the same observer. This means after I call the constructor, I can execute the observe()
method for as many elements as I want.
Thus, after this line:
observer = new MutationObserver(mCallback);
I can then have multiple observe()
calls with different elements as the first argument:
observer.observe(mList, options);
observer.observe(mList2, options);
Start the observer, then try the add/remove buttons for both lists. The only catch here is that if you hit one of the “stop” buttons, the observer will stop observing for both lists, not just the one it’s targeting.
Moving A Node Tree That’s Being Observed
One last thing I’ll point out is that a MutationObserver
will continue to observe for changes to a specified node even after that node has been removed from its parent element.
For example, try out the following demo:
This is another example that uses childList
to monitor for changes to the child elements of a target element. Notice the button that disconnects the sub-list, which is the one being observed. Click the “Start…” button, then click the “Move…” button to move the nested list. Even after the list is removed from its parent, the MutationObserver
continues to observe for the specified changes. Not a major surprise that this happens, but it’s something to keep in mind.
Conclusion
That covers just about all the primary features of the MutationObserver
API. I hope this deep dive has been useful for you to get familiar with this standard. As mentioned, browser support is strong and you can read more about this API on MDN’s pages.
I’ve put all the demos for this article into
a CodePen collection, should you want to have an easy place to mess around with the demos.