Sometimes you may run into a challenging situation: You want some JavaScript to execute, but only if some other element has changed. At work, we’ve recently run into a situation with this kind of challenge. We’re using a library on videos, but how can we make sure that our JavaScript only runs after the video library runs? Well, we do it by mixing two really cool technologies: MutationObserver
and Promise
.
Learning the MutationObserver
So what is a MutationObserver
?
It’s an interface that allows you to observe a mutation in the DOM1. It’s a way to find out if elements have changed. We can discover if the element has gotten new attributes, or if it’s had child elements added or removed. So that makes this super handy for those situations when things out of our control are messing with our DOM.
First, make an observer
let’s suppose you have a video, and you want to know if that video gets any new attributes added to it. First, we make a new observer.
const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { console.log(mutation); // do something! }); });
A few things to note here:
- The argument passed in to your new
MutationObserver
is a callback - You will need to iterate over the
mutations
to find the mutations you want
Second, make a configuration
Before we start observing anything, we need to give some instructions on what to observe. You have a pretty good selection on how to configure your observer. With the exception of attributeFilter
, all of these are boolean values, useful for answering questions you may have about the element:
childList
- Have elements been added/removed directly in this element?
attributes
- Has the element had any changes to attributes?
characterData
- Has any text inside of the element changed?
subtree
- Have elements more than one level deep changed?
attributeOldValue
- Do you want the original value of the attribute?
characterDataOldValue
- Do you want the original text of the element?
attributeFilter
- What specific attributes should be watched?
All we want to know is if a class name is changing. Easy enough!
const observerConfig = { attributes: true, attributeOldValue: true };
Next step: start observing
We’ve created the observer. We’ve created a configuration for it. Now, we just want to put it to use
observer.observe( document.querySelector('.myVideo'), observerConfig );
We use the observe
method, and pass in two arguments:
- Element to observe
- Configuration to use
But it feels kinda weird
If you’re thinking, “neat, but it feels weird,” you’re not alone. My long experience with JavaScript in the DOM tells me I should be able to do something like this:
document .querySelector('.video') .addEventListener('mutation',mutationCallback)
But, how would we be able to decide which mutations to watch (i.e. pass in a configuration)?
¯_(ツ)_/¯
Maybe that’s why it’s not an event to listen to on an element.
And then the promise
Simply put, a promise is an object that may produce a single value at some time in the future2. I like to think of promises as an eventuality. Eventualities are events that will happen, but you don’t have the details of what happens. So a promise is a way to make sure that something always happens. This makes promises exceptionally helpful for AJAX. But it also makes them useful when you are expecting a DOM change.
The promise basics
At the most basic form, a promise needs to know when something is resolved, and when something is rejected. What you’re looking for is the means to a .then()
or .catch()
method. Those two little guys are your eventualities.
Start with your end goal in mind
Part of my own personal struggle with understanding promises is that I think I start in the wrong direction. I start from the promise instead of what I want to do. So, if you’re new to promises, and/or curious how we can use them with mutation observers, let’s go ahead and say that this is our end goal:
attributePromise(document.querySelector('video'), 'class').then((element)=> { // do something because the video's classname attribute changed }).catch((element)=> { // do something because the video never got a class name change });
Begin with the wrapper function
Starting from the back, we know we want to make a function that takes an element and an attribute name as two separate arguments. So we’re looking for something like this:
function attributePromise(element,attributeName) { return new Promise((resolve,reject) => { }); }
Add your MutationObserver
We’ll throw in the observer we’ve already learned about, and its configuration. It just doesn’t quite seem like we’ve reached the finish line yet, though. For one thing, we need to actually loop through the mutation
, and see if it’s…mutated.
For another, what if it never changes? How do we account for the scenario where the element never changes at all?
function attrPromise(element, attributeName) { return new Promise((resolve,reject) => { const observerConfig = {attributes: true,attributeOldValue:true}; const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { // uhhh... stuff will go here? }); }); observer.observe(element, observerConfig); }); }
Add some timed rejection
What we’ll do is make a variable called hasChanged
, and we’ll set it to false. Then, we’ll throw a timer that will check every 500ms to see if that hasChanged
is true
.
function attributePromise(element, attributeName) { const rejectTime = 500; return new Promise((resolve,reject) => { let hasChanged = false; const observerConfig = {attributes: true,attributeOldValue:true}; const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { }); }); window.setTimeout(()=>;{ if (!hasChanged) { reject(element); } },rejectTime); observer.observe(element, observerConfig); }); }
Then, check your mutants
Now we get into into the forEach()
loop. What we want to do is compare mutation.attributeName
to the attributeName
that’s been provided as an argument.
If the two match, we’ll do three things:
- Set
hasChanged
to true so that our timer can take a breather - Disconnect the observer because we don’t need it any more
- Resolve the promise
function attrPromise(element, attributeName) { const rejectTime = 500; return new Promise((resolve,reject) => { let hasChanged = false; const observerConfig = {attributes: true,attributeOldValue:true}; const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.attributeName == attributeName) { hasChanged = true; observer.disconnect(); resolve(element, element.getAttribute(attributeName)); } }); }); window.setTimeout(()=>{ if (!hasChanged) { reject(element); } },rejectTime); observer.observe(element, observerConfig); }); }
Get off the Happy Path
So far, we’ve written a “happy path” Mutation Promise. Meaning that attributeName
will always be an argument that’s been provided, and you’ll always want it to eventually reject. But, let’s go off the happy path by adding some flexibility to our function.
We’ll set a rejectTime
with a default value of zero, and then add a setTimeout
any time it’s greater than zero.
We’ll also modify our conditions inside of the forEach()
. One condition will account for us having an attributeName
AND that attributeName
changing. Another will account for the scenario where we didn’t send in an attributeName
at all.
function attrPromise(element, attributeName, rejectTime = 0) { return new Promise((resolve,reject) => { let hasChanged = false; const observerConfig = {attributes: true,attributeOldValue:true}; const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (attributeName && mutation.attributeName == attributeName) { hasChanged = true; observer.disconnect(); resolve(element, element.getAttribute(attributeName)); } if (!attributeName) { hasChanged = true; observer.disconnect(); resolve(element); } }); }); if (rejectTime > 0) { window.setTimeout(()=>{ if (!hasChanged) { reject(element); } },rejectTime * 100); } if (attributeName) observerConfig.attributeFilter = [attributeName]; observer.observe(element, observerConfig); }); }
Link(s) or it Didn’t Happen
The raw code is in a gist for your convenience.
But if you want an example of this in action, there’s a codepen for that. Click the button, or don’t.
See the Pen Mutation Promise by Paceaux (@paceaux) on CodePen.
Permalink
querySelector(‘.myVideo’,
–missing ending )