Imagine we're building the Lastpass Chrome extension. This extension overlays a mini icon over the right end of every username and password input rendered on your screen.
How would you go about building this? One naïve approach would be to frequently check the page manually to see if there are username and password inputs.
setInterval(() =\> {
const inputs = document.getElementsByTagName('input');
inputs.forEach(input =\> {
if (input.name === 'Username' || input.name === 'Password') {
input.appendChild(lastpassIcon);
}
});
}, 1000)
The above code is setting a function to be called every second. That function is grabbing every input on the page, checking if any is a username or password input, and appending the Lastpass Icon to those inputs. There are a few notable downsides to this approach:
There's one final downside that is more philosophical than technical. This approach is saying, "Keep checking if there's a username or password input, and when there is one, append an icon." Intuitively we instead want to say, "when the username or password input appears, add icon."
Web browsers support an API that allows developers to express that exact intention through Mutation Observer.
What is Mutation Observer?
Mutation Observer is an interface provided by all modern web browsers that allow you to respond to DOM changes. When the browser detects the change, it will trigger the callback you specified with the relevant metadata about said change.
Let's continue our Lastpass example to see it in action:
const observer = new MutationObserver((mutationRecords) =\> {
mutationRecords
.flatMap(r =\> r.addedNodes)
.filter(n =\> n.name === 'Username' || n.name === 'Password');
.forEach(input =\> input.appendChild(lastpassIcon));
})
observer.observe(document.body, {childList: true, subtree: true});
First, we are initializing an instance of the Mutation Observer with a callback that will look at all the nodes added to the DOM, filter for just the username and password inputs, and then add the Lastpass icon to each of them. Then, we call observe
, which registers the observer to watch for changes. In this example, we've targeted document.body
so that the callback fires for any changes to the document body. We've set childlist
to true
, configuring our observer to fire precisely when child nodes are added to or removed from the document body. Finally, we've set subtree
to true
, which applies this watching behavior to all nodes within the document body subtree.
There are a few notable benefits that we gain with this approach:
We also gain the philosophical benefit of running our desired behavior "when we want it" instead of "always, in case we want it."
Tips for Using Mutation Observer
The ability to trigger logic based on some external change is powerful. But, it could also lead to severe performance degradation if not used appropriately. Here are some tips to consider as you start using Mutation Observer.
Scope Down the Target
The observe
method's first argument is called the target
. It's the primary DOM node the observer watches for changes in the DOM. When combined with subtree
, the observer callback will fire more frequently, the higher we define our target in the DOM tree.
In our Lastpass example above, the observer looks at all the DOM nodes in body, firing whenever one adds a child. We could instead only look at the first form element if we know that element will delay loading its inputs:
const target = document.getElementsByTagName('form')[0];
observer.observe(target, {childList: true, subtree: true});
Scoping down the target won't always be possible. It will depend on what nodes in the app persist across mutations and what behavior you're looking to trigger as a response. The benefit of scoping down is improving performance by only running the observer callback for the most relevant nodes.
Limit Logic
Mutations that trigger observer callbacks are usually a result of a user action. We want these interactions to be as fast and responsive as possible; otherwise, users will move on to other solutions. Google recommends a First Input Delay of at most 100ms, that is the time it takes between a user first interacts with your site to the time the browser responds.
To achieve this, we want to limit the amount of logic that gets run in our observer callback as much as possible. In our example above, we are instantiating three functions every time a node gets added to the DOM. We could instead rewrite the logic to look like this:
const mapper = r =\> r.addedNodes;
const inputFilter = n =\> n.name === 'Username' || n.name === 'Password';
const inputAppend = input =\> input.appendChild(lastpassIcon);
const observer = new MutationObserver((mutationRecords) =\> {
mutationRecords
.flatMap(mapper)
.filter(inputFilter);
.forEach(inputAppend);
})
observer.observe(document.body, {childList: true, subtree: true});
Each callback invocation now calls three preexisting methods instead of generating new ones on every call. While seemingly trivial, these are the types of optimizations you will need to make, especially if you're working with a frequently mutating app. Front-load as much of the logic as possible, especially if you make asynchronous calls to an external API.
Beware of Recursion
JavaScript is single-threaded, and browsers run your applications off of the main thread. A single uncaught while loop could crash the browser.
This endless loop could also appear through recursion. Imagine if the Lastpass icon we referenced above had a name
attribute with the value of 'Username'
. We would then fall victim to the following recursive cycle:
This recursion makes for a frustrating developer experience, and your application becomes unusable.
To avoid this pitfall, always ensure to add strict filters when choosing which nodes to manipulate further. Maybe in that filter, we add another check that says n.tagName === 'INPUT'
. A common strategy I like to implement are data-
boolean attributes that I check before appending, then set before I append:
if (!node.hasAttribute("data-has-been-mutated")){
node.setAttribute("data-has-been-mutated", "true");
// rest of callback
}
In summary, Mutation Observers are a powerful API that browsers provide in allowing developers to build on top of other applications. Use them with caution. There are different ways that the observers could degrade the browser performance, which could be avoided by restricting the target, the callback logic, and nodes to be further mutated. But when used effectively, they improve your app's responsiveness while making your application logic more intentional.