Exploring DOM Mutation Observation In Alpine.js 3.13.5
When your document first renders, Alpine.js traverses the DOM (Document Object Model) tree and activates any existing directive bindings. After this initial activation phase, Alpine.js uses the MutationObserver
API to listen for subsequent changes to the DOM structure. And, when it observes changes, it will initialize new directive bindings and teardown old directive bindings as needed. Let's take a closer look at these mechanics in action.
innerHTML
Setting an Element's In this first example, we're going to set the innerHTML
of an existing element. The new HTML will contain a subtree that has its own x-data
binding as well as other Alpine.js directive bindings. To make the code easier to read, I'm storing the "inert HTML" inside a <template>
tag. Alpine.js won't examine the DOM tree inside the template; which means that none of the embedded directives will matter on document render.
<!doctype html>
<html lang="en">
<body>
<button onclick="mutate()">
Set innerHTML
</button>
<article id="target">
<!-- To be overridden by mutation. -->
<p> Default content. </p>
</article>
<!--
NOTE: Since this HTML is inside a TEMPLATE, it will NOT be bound by Alpine.js.
However, once we insert this HTML into the rendered DOM, it will activate.
-->
<template id="source">
<p
x-data="{ message: 'Hello sunshine!' }"
x-init="console.log( 'Inert HTML has been activated!' )">
<span
x-text="message"
:style="{ 'background': 'gold' }">
</span>
</p>
</template>
<script type="text/javascript" src="../vendor/alpine.3.13.5.js" defer></script>
<script type="text/javascript">
function mutate() {
window.target.innerHTML = window.source.innerHTML;
}
</script>
</body>
</html>
Now, if we run this Alpine.js code and set the innerHTML
, we get the following output:
As you can see, when we set the innerHTML
of the target element, the embedded x-data
, x-init
, x-text
, and x-bind:style
directives are all "observed" by Alpine.js; and, are bound to the new DOM tree structure.
Setting An Element's Attribute
The MutationObserver
sees more than macro changes to the DOM - it can also see low-level attribute changes. In this example, we're going to take an existing Alpine.js component (ie, one that's already associated with an x-data
scope binding) and dynamically inject x-text
and x-bind:style
directives:
<!doctype html>
<html lang="en">
<body>
<button onclick="mutate()">
Set Attribute
</button>
<article>
<p x-data="{ message: 'Hello sunshine!' }">
<!-- Going to dynamically insert attributes here. -->
<span id="target"> Default content. </span>
</p>
</article>
<script type="text/javascript" src="../vendor/alpine.3.13.5.js" defer></script>
<script type="text/javascript">
function mutate() {
window.target.setAttribute( "x-text", "message" );
window.target.setAttribute( "x-bind:style", "{ background: 'gold' }" );
}
</script>
</body>
</html>
Now, if we run this Alpine.js code and call .setAttribute()
a few times, we get the following output:
As you can see, when we dynamically inject the x-text
and x-bind:style
directives on the existing element, Alpine.js "observes" the changes and binds the directives to the document.
innerHTML
Replacing an Element's When mutating the existing DOM tree, some changes are additive, as we saw in the previous examples; and, some changes are subtractive. Alpine.js observes these subtractive changes as well. And, will teardown / destroy existing directive bindings as necessary.
In the following example, we're going to completely overwrite the innerHTML
of an element that contains an Alpine.js x-data
directive. The scope associated with this directive contains both init()
and destroy()
life-cycle methods so that we can see when the scope is created; and when it's destroyed.
<!doctype html>
<html lang="en">
<body>
<button onclick="mutate()">
Replace innerHTML
</button>
<article id="target">
<p x-data="{
date: new Date().toTimeString().slice( 0, 8 ),
message: 'Hello sunshine!',
init: () => console.log( 'init() method invoked.' ),
destroy: () => console.log( 'destroy() method invoked.' ),
}">
<strong x-text="date"></strong> →
<span x-text="message"></span>
</p>
</article>
<script type="text/javascript" src="../vendor/alpine.3.13.5.js" defer></script>
<script type="text/javascript">
function mutate() {
window.target.innerHTML = window.target.innerHTML;
}
</script>
</body>
</html>
Now, if we run this Alpine.js code and swap-out the innerHTML
, we get the following output:
As you can see, when we swap-out the innerHTML
of the given element, the destroy()
life-cycle method on the existing binidng is called. Then, when the new HTML is activated by Alpine.js, the init()
life-cycle method on the new binding is called.
DOM Mutations During DOM Mutations
In all of the previous examples, we applied DOM mutations to a "settled" document. These DOM mutations triggered the MutationObserver
handler that Alpine.js maintains internally; and, in response, Alpine.js setup and tore-down directives as necessary.
But, Alpine's MutationObserver
isn't always watching the DOM tree. For logic and performance reasons, Alpine detaches the mutation observer while it's mutating the DOM. If you look at the x-if
directive, for example, you'll see that internally to the effect callback, the x-if
directive performs its DOM manipulation inside a mutateDom()
operator.
The mutateDom()
method detaches the MutationObserver
while the x-if
injects its associated element. The x-if
directive must then call initTree()
explicitly in order to have Alpine.js initialize the directive bindings on the new element.
We can see this in action by creating an Alpine.js directive that appends HTML during the x-if
operation. To make the interplay more obvious, I've patched the Alpine.js library to log the start/stop operations for the MutationObserver
.
<!doctype html>
<html lang="en">
<body>
<button onclick="Alpine.initTree( window.target )">
Call `Alpine.initTree()`
</button>
<article x-data>
<template x-if="true">
<p x-test>
Injected!
</p>
</template>
</article>
<!--
NOTE: Since this HTML is inside a TEMPLATE, it will NOT be bound by Alpine.js.
However, once we insert this HTML into the rendered DOM, it will activate (well,
depending on WHEN this is done).
-->
<template id="source">
<p
id="target"
x-data="{ message: 'Injected by x-test' }"
x-init="console.log( 'Init called on injected element.' )"
x-text="message">
</p>
</template>
<script type="text/javascript" src="../vendor/alpine.3.13.5.js" defer></script>
<script type="text/javascript">
document.addEventListener(
"alpine:init",
function handleInit() {
Alpine.directive( "test", TestDirective );
}
);
function TestDirective( element, metadata, framework ) {
// As part of the constructor logic, we're going to further alter the DOM.
// However, the "x-if" directive turns-off the mutation observer while the
// x-if functionality is performing its DOM operations. As such, Alpine won't
// "see" this new element being injected.
console.warn( "Injecting some new HTML inside x-test." );
element.insertAdjacentHTML( "afterend", window.source.innerHTML );
}
</script>
</body>
</html>
As you can see, the x-test
directive is bound during the x-if
evaluation. The x-test
directive, in turn, calls insertAdjacentHTML()
to alter the DOM. But, the subsequent DOM changes won't be "seen" by Alpine.js until we explicitly call Alpine.initTree()
. And, when we do, we get the following output:
As you can see, the HTML injected by our x-test
directive was not "seen" by Alpine.js since we injected while the MutationObserver
instance was detached (due to the parent x-if
DOM mutation). As such, we had to call initTree()
on the inserted HTML in order for Alpine.js to come through and initialize the directives.
Aside: Alpine provides both
initTree()
anddestroyTree()
methods for manually setting up and tearing down directives, respectively.
Instead of calling Alpine.initTree()
, we could have also performed our DOM mutation inside an Alpine.nextTick()
callback. The .nextTick()
method invokes the given callback after Alpine.js finishes making its reactive DOM updates.
For the most part, it seems that DOM manipulation in Alpine.js "just works". When you add new directives via DOM manipulation, Alpine.js initializes them. And, when you remove existing directives via DOM manipulation, Alpine.js destroys them. It seems it gets a bit more complicated when you perform DOM manipulation as the result of other DOM manipulations. But, it seems that Alpine.js gives us tools for that as well.
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 →