Playing With MutationObserver In JavaScript
It easy to lose track of just how far the web has come. Especially when you're working on a long-running piece of software. In my mind, the MutationObserver
is still a "new" technology. However, when you look at CanIUse.com, it's been broadly available for over a decade. Even IE11 had support for it. If anything, the MutationObserver
API is an old technology—it's only new to me personally. As such, I wanted to do a little experimentation with it in order to remove some of that mystery and move it into the realm of the mundane.
Note: There's nothing of particular curiosity in this post. This is just me doing some exploration for my own mental model. If you already know how
MutationObserver
works, you won't learn anything new here.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
The MutationObserver
API provides a mechanism for observing changes in the Document Object Model (DOM) tree. This can include adding and removing nodes (element, text, comment); adding and removing attributes; and, adding and removing character data.
Most of the time, knowing about DOM mutations isn't all that meaningful because you're the one mutating the DOM structure. As such, the DOM changes are merely a predictable result of a preceding state change, not a potential trigger for new state changes.
Some front-end JavaScript frameworks seek to flip this mindset. Frameworks like Stimulus.js and Alpine.js push more of the "source of truth" into the DOM tree, allowing changes in the DOM to beget changes in the state. They do this by observing the DOM via the MutationObserver
API; and then, translating DOM changes into state changes (usually by instantiating constructors and binding new instances to the new DOM structures).
To explore this concept, I wanted to see how little code it would take to enable two DOM-based constructs via the MutationObserver
API:
x-controller
- This attribute defines the path to a Constructor function that will be instantiated (ie,new
'ed) and bound to the host element.x-ref
- This attribute defines a reference to be injected into the parent controller by name.
There are many ways in which the DOM structure can be mutated. For example, you can add the x-controller
attribute to an existing element. Or, you can remove an element that has an x-ref
attribute. I don't worry about all these cases. This exploration isn't intended to be robust—it's intending to be minimal. As such, I'm only going to monitor the adding of new element nodes; and, when nodes are removed, I'm only going to cleanup controllers (ie, I'm just going to assume that all relevant x-refs
are being removed at the same time).
To provide a context in which to test the MutationObserver
API, I wanted to get a simple button-based counter to work:
<p x-controller="controllers.HelloWorld" x-scope="ctrl">
<button x-ref="ctrl.button">
Count:
<span x-ref="ctrl.counter">0</span>
</button>
</p>
This paragraph (host element) is managed by the HelloWorld
constructor. And, once instantiated, it will receive two references: the button and the counter. Since the DOM tree may be annotated with many, potentially nested, controllers, each controller can be "scoped" via x-scope
; and, each x-ref
attribute value is assumed to be in the form of:
x-ref="{ scope name }.{ reference name }"
Here's the code for my test page. I'm adding and removing HTML by setting the innerHTML
of a given target element. Notice that the source <template>
has two such counters to make sure that I'm actually getting two different instances of the controller:
<!doctype html>
<html>
<body>
<h1>
Playing With MutationObserver In JavaScript
</h1>
<p>
<button onclick="( window.playground.innerHTML = window.domTemplate.innerHTML )">
Setup
</button>
<button onclick="( window.playground.innerHTML = '' )">
Teardown
</button>
</p>
<section id="playground">
<!-- Nodes to be added / removed here. -->
</section>
<!-- Template to be cloned into above playground. -->
<template id="domTemplate">
<!-- Counter ONE instance. -->
<p x-controller="controllers.HelloWorld" x-scope="ctrl">
<button x-ref="ctrl.button">
Count:
<span x-ref="ctrl.counter">0</span>
</button>
</p>
<!-- Counter TWO instance (note: SAME controller). -->
<p x-controller="controllers.HelloWorld" x-scope="ctrl">
<button x-ref="ctrl.button">
Count:
<span x-ref="ctrl.counter">100</span>
</button>
</p>
</template>
<script type="text/javascript" src="./watcher.js" defer></script>
<script type="text/javascript">
var controllers = {
HelloWorld: HelloWorld
};
var instanceID = 0;
function HelloWorld( element ) {
var refs = Object.create( null );
var id = ++instanceID;
// Return the public API for this controller.
return {
$onDestroy: $onDestroy,
$onInit: $onInit,
refs: refs
};
// ---
// PUBLIC METHODS.
// ---
/**
* I get called when the controller is being unbound from the document.
*/
function $onDestroy() {
console.log( `Destroying instance ${ id }.` );
refs.button.removeEventListener( "click", handleButtonClick );
}
/**
* I get called when the controller is being bound to the document. At this
* point, any visible refs have been injected.
*/
function $onInit() {
console.log( `Initializing instance ${ id }.` );
refs.button.addEventListener( "click", handleButtonClick );
}
// ---
// PRIVATE METHODS.
// ---
/**
* I increment the value of the button counter.
*/
function handleButtonClick( event ) {
console.log( `Clicking instance ${ id }.` );
refs.counter.innerText = ( Number( refs.counter.innerText ) + 1 );
}
}
</script>
</body>
</html>
Each controller instance exposes two methods: $onInit()
and $onDestroy()
. These are "framework methods" that my MutationObserver
code will invoke when setting up and tearing down each instance, respectively. The difference between the constructor
method and the $onInit()
method is that all of the x-ref
injection will be done after class instantiation but before calling $onInit()
. In this exploration, I'm using these two methods to bind and unbind a click
event handler that increments the given counter value.
If we run this JavaScript demo, add the <template>
HTML, and the click the buttons, we get the following output:
As you can see, two different counter buttons are added to the DOM. Upon doing so, each host element is bound to a new, independent instance of the HelloWorld
constructor; and, each counter is incremented independently by each HelloWorld
instance.
The watcher.js
JavaScript file that makes this dynamic DOM-based binding / unbinding possible isn't that long (less than 200 lines of code). The two methods worth looking at are handleNodesAdded()
and handleNodesRemoved()
. These are the methods that get called in response to the MutationObserver
callback.
I won't step through this code because this post is really just for my own benefit. But, I'll underscore that this code is not intended to be robust. This was just the least amount of code that it took to get something meaningful to work.
(() => {
var observer = new MutationObserver( handleMutations );
var root = document.body;
// Start watching for changes on the DOM tree.
observer.observe(
root,
{
// Watch for nodes added and removed.
childList: true,
// Watch for descendant changes deep in the observed root.
subtree: true
}
);
// Bind controllers within the initial DOM structure.
handleNodesAdded([ root ]);
// ---
// PRIVATE METHODS.
// ---
/**
* When the DOM is mutated, the Observer only sees the local "roots" that were changed.
* This method expands those local roots to include any nested nodes of interest (ie,
* nodes those that represent x-controllers and x-refs).
*/
function expandNodesOfInterest( nodes ) {
var nodesOfInterest = [];
for ( var node of nodes ) {
// MutationObserver reports TEXT node changes and COMMENT node changes. But,
// we only care about ELEMENT changes.
if ( node.nodeType !== Node.ELEMENT_NODE ) {
continue;
}
// Collect "self" nodes of interest.
if (
node.hasAttribute( "x-controller" ) ||
node.hasAttribute( "x-ref" )
) {
nodesOfInterest.push( node );
}
// Collect nested nodes of interest.
nodesOfInterest.push( ...node.querySelectorAll( "[x-controller], [x-ref]" ) );
}
return nodesOfInterest;
}
/**
* I handle DOM mutations and bind and unbind controllers as necessary. Note that only
* element-level changes are being observed in this exploration. Dynamically mutated
* attributes will not be noticed (ie, if you dynamically add "x-controller" to an
* existing element, nothing will happen).
*/
function handleMutations( mutationList ) {
for ( var mutation of mutationList ) {
switch ( mutation.type ) {
case "childList":
handleNodesRemoved( mutation.removedNodes );
handleNodesAdded( mutation.addedNodes );
break;
// Other [type] values are "attributes", "characterData".
}
}
}
/**
* I handle the new nodes, instantiating controllers and injecting refs.
*/
function handleNodesAdded( nodes ) {
var controllers = [];
// MutationObserver only sees the "local root" of a newly added tree branch. But,
// we need to know about all of the relevant nodes within the new tree branch. As
// such, we must expand our view of the new nodes.
for ( var node of expandNodesOfInterest( nodes ) ) {
if ( node.hasAttribute( "x-controller" ) ) {
// All controllers are defined as a dot-delimited object path.
var controllerPath = node.getAttribute( "x-controller" );
var constructor = reduceControllerPath( controllerPath );
var controller = node._x_controller = new constructor( node );
controller.refs = ( controller.refs || Object.create( null ) );
controllers.push( controller );
}
if ( node.hasAttribute( "x-ref" ) ) {
// All references are defined as a "scope.name" two-segment path.
var refPath = node.getAttribute( "x-ref" );
var parts = refPath.split( "." );
var scopeName = parts[ 0 ];
var refName = parts[ 1 ];
// Find the closest controller with the given scope name. This may be a
// controller that was just added; or, it may be one that was previously
// created in a different DOM mutation.
var controller = node.closest( `[x-scope=${ scopeName }]` )._x_controller;
controller.refs[ refName ] = node;
}
}
// Once we have all of our new controllers and new refs in place, call the init
// life-cycle method on any new controllers.
for ( var controller of controllers ) {
controller?.$onInit( node );
}
}
/**
* I unbind all controllers from the given removed DOM nodes.
*/
function handleNodesRemoved( nodes ) {
// MutationObserver only sees the "local root" of a recently removed tree branch.
// But, we need to know about all of the relevant nodes within the old tree
// branch. As such, we must expand our view of the old nodes.
for ( var node of expandNodesOfInterest( nodes ) ) {
var controller = node._x_controller;
// Teardown any controller bound to the given node.
if ( controller ) {
delete node._x_controller;
controller?.$onDestroy( node );
}
}
}
/**
* I reduce the given dot-delimited controller path into a constructor reference (which
* is assumed to be the last segment in the given path).
*/
function reduceControllerPath( path ) {
return path.split( "." ).reduce(
( context, segment ) => {
return context[ segment ];
},
window // Start reducing at the global context.
);
}
})();
It's great to try stuff like this out for yourself because it removes a lot of the mystery. Without seeing the MutationObserver
API in action, some JavaScript frameworks can feel too magical. But, once you see that it's not magic—that it's just some callbacks and some DOM tree iteration—building applications can feel a bit more tractable.
Want to use code from this post? Check out the license.
Reader Comments
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →