Cloning Templates In Alpine.js 3.13.5
Over my last few blog posts, I've developed a better understanding of how scopes are applied in an Alpine.js application. Each x-data
node receives an "expando" property (_x_dataStack
) which contains an array of reactives scopes that represent the "scope chain" for the given component. This understanding filled-in the missing pieces that I needed in order to understand <template>
cloning. Specifically, I now understand how and why an intermediary scope can be applied during the cloning process.
Consider a simple Alpine.js application that has an x-for
range iteration:
<div x-data="app">
<template x-for="i in 10">
<span x-data="item" x-text="i"> ... </span>
</template>
</div>
In this Alpine.js app, the <template>
element is cloned 10 times; and, for each clone - each x-for
iteration - the i
variable is set. But, where does i
live? It's clearly not in the app
controller, since it has to be unique for each cloned template. And, it can't be in the item
controller, since the item
controller defines its own scope internally.
If you inspect the generated DOM elements, and you look at the expando property, what you'll see is that the i
variable is defined in a hidden, intermediary scope that lives in between the item
and the app
controller scopes:
[ itemScope, { i: 1 }, appScope ]
Since Alpine.js will traverse the scope chain looking for values, any reference to i
from within item
will end up finding and consuming this intermediary scope value.
Using this information, we can now create our own <template>
cloning algorithms that apply an intermediary scope in order to supply clone-specific data. In the following Alpine.js demo, we have a button and a template. Every time we click the button, the template will be cloned; and, a unique, monotonic ID will be supplied in this hidden, intermediary scope. This hidden scope is applied via the Alpine.addScopeToNode()
method.
<!doctype html>
<html lang="en">
<body>
<h1>
Cloning Templates In Alpine.js 3.13.5
</h1>
<div x-data="app">
<button @click="doCloning()">
Make Clone
</button>
<!--
This Template node will be cloned and appended to the app. In doing so, the
app will provide it with its own unique "cloneID" scope variable.
-->
<template x-ref="source">
<p x-data="clone">
<span x-text="thing"></span> -
<span x-text="cloneID"></span> -
<button @click="incrementID()">
Increment ID
</button>
</p>
</template>
</div>
<script type="text/javascript" src="../vendor/alpine.3.13.5.min.js" defer></script>
<script type="text/javascript">
document.addEventListener(
"alpine:init",
function setupAlpineBindings() {
Alpine.data( "app", AppController );
Alpine.data( "clone", CloneController );
}
);
/**
* I control the app component.
*/
function AppController() {
var host = this.$el;
var cloneID = 100;
return {
thing: "App",
doCloning: doCloning
};
/**
* I clone the <template> and append it to the end of the host container.
*/
function doCloning() {
var clone = this.$refs.source.content
.cloneNode( true )
.firstElementChild
;
// Before we append the clone to the DOM, which will inherently apply a
// new scope due to the use of "x-data", we want to explicitly apply an
// intermediary scope that represents this cloning operation. This scope
// will sit in between the host scope and the clone scope. Think of this
// as being akin to the "x-for" iteration scope.
var hiddenScope = Alpine.reactive({
thing: "metaClone",
cloneID: ++cloneID
});
Alpine.addScopeToNode(
clone, // The node receiving the intermediary scope.
hiddenScope, // The reactive scope being applied.
host // The node representing the "parent scope" provider.
);
// Now that the intermediary scope has been applied, we can append the
// node to the DOM. At this point, the MutationObserver will take over and
// Alpine.js will work its normal magic.
host.append( clone );
}
}
/**
* I control the clone component.
*/
function CloneController() {
return {
thing: "Clone",
incrementID: incrementID
};
/**
* I increment the cloneID, which is defined and mutated within the
* intermediary scope that we applied via the "Alpine.addScopeToNode()" during
* the cloning process.
*/
function incrementID() {
this.cloneID++ ;
}
}
</script>
</body>
</html>
As you can see, when the button is clicked and the doCloning()
method is called, we perform a 3-part process:
- Clone the template.
- Apply an intermediary scope (with
cloneID
). - Append the clone to the active DOM.
When step 3 is performed, and the clone is appended to the DOM, Alpine.js' MutationObserver
will kick in, it'll see the new node and it'll apply the x-data
attribute mechanics. The x-data
directive will then create its own reactive scope and "unshift" it onto the head of the scope chain.
If we run this Alpine.js demo and look at the one of the clone elements, we can see that its expando property contains three scopes: the clone scope, the intermediary scope (with the unique cloneID
), and the app scope:
As you can see in the expando property, each clone has three scopes; the middle of which is the hidden, intermediary scope that we applied using Alpine.addScopeToNode()
.
Now that I have a better understanding of how scopes fit together and how they are applied to the DOM, it opens up some interesting opportunities in Alpine.js. For example, this now makes recursive components possible (I think).
Want to use code from this post? Check out the license.
Reader Comments
As a fast-follow post, instead of cloning the template imperatively in the controller logic, I created an
x-template-outlet
directive that allows the template to be cloned declaratively at any point within the DOM:www.bennadel.com/blog/4602-creating-a-template-outlet-directive-in-alpine-js-3-13-5.htm
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →