Creating A Template-Outlet Directive In Alpine.js 3.13.5
In my previous post, I looked at cloning templates in Alpine.js. More specifically, I was looking at the mechanics of supplying intermediary data to the cloned element that exists in between the clone's local x-data
binding and the ancestral scope chain. In that post, I was explicitly cloning a template in my controller logic. In this post, I want to explore a more declarative cloning technique using a template outlet in Alpine.js.
Aside: I'm borrowing the idea of "template outlets" from Angular.
Consider an Alpine.js application that has the given template:
<template x-ref="source">
<p x-data="{ thing: cloneType }">
I am a <span x-text="thing"></span>.
</p>
</template>
What I want to do is "stamp out" copies of that template elsewhere in the HTML markup. And, in order to do that, I need to create some sort of "hook"—something that tells Alpine.js, "create a copy of that template here!".
This "hook" is my "template outlet" directive. It serves two purposes:
Define the insertion point for the cloned template.
Map external data onto internal data.
Given the previous template, let's say that I want to create a copy of it and I want to define cloneType
as "widget"
. To do that, I would use my x-template-outlet
directive and give it an x-data
binding that defines cloneType
:
<template
x-template-outlet="$refs.source"
x-data="{ cloneType: 'widget' }">
</template>
Now, when the x-template-outlet
directives clones the template reference, it puts {cloneType:"widget"}
in the scope chain, which allows it to be referenced within the x-data
binding of the clone.
To explore this, I'm going to create a template ($refs.source
) that defines a "Friend" component. Notice that the friend()
controller accepts two constructor arguments: friendID
and friendName
.
<template x-ref="source">
<p x-data="friend( friendID, friendName )">
<strong x-text="id"></strong>:
<span x-text="name"></span>
</p>
</template>
We're going to use our x-template-outlet
directive to then create copies of this Friend component. And, as we do, each outlet's x-data
scope binding will provide the constructor arguments for friendID
and friendName
:
<template
x-template-outlet="$refs.source"
x-data="{
friendID: ++id,
friendName: 'Bobbi'
}">
</template>
<template
x-template-outlet="$refs.source"
x-data="{
friendID: ++id,
friendName: 'Kimmi'
}">
</template>
<template
x-template-outlet="$refs.source"
x-data="{
friendID: ++id,
friendName: 'Sandi'
}">
</template>
If we then run this Alpine.js code and look at the DOM bindings, we can see that all three friends were created and each node's expando property shows us the correct scope chain:
As you can see, all three Friend clones were rendered to the DOM. And, when we look at one of the clones, we can see that it has three scopes in its scope chain:
It's own, local
x-data
scope.The intermediary scope provided by our
x-template-outlet
directive that setup the mappings forfriendID
andfriendName
to be consumed in the constructor injection of thefriend()
component.The application's
x-data
scope.
It's not so easy to see in this screenshot; but, you might notice that there is no <template>
in the DOM where the outlet is. Instead, I've swapped out the x-template-outlet
element with an HTML Comment that acts as the insertion hook.
Here's the code that powers this exploration:
<!doctype html>
<html lang="en">
<body>
<h1>
Creating A Template-Outlet Directive In Alpine.js 3.13.5
</h1>
<div x-data="app">
<!--
This template contains an inert component ("friend") that we're going to
render several times using the "x-template-outlet" directive. The constructor
arguments in this component (friendID, friendName) will be supplied via the
"x-data" scope on the "x-template-outlet".
-->
<template x-ref="source">
<p x-data="friend( friendID, friendName )">
<strong x-text="id"></strong>:
<span x-text="name"></span>
</p>
</template>
<!--
We're using the template-outlet to render the "source" template reference
several times. For each rendering, we'll use the "x-data" binding to supply
constructor arguments for the "friend" controller.
-->
<template
x-template-outlet="$refs.source"
x-data="{
friendID: ++id,
friendName: 'Bobbi'
}">
</template>
<template
x-template-outlet="$refs.source"
x-data="{
friendID: ++id,
friendName: 'Kimmi'
}">
</template>
<template
x-template-outlet="$refs.source"
x-data="{
friendID: ++id,
friendName: 'Sandi'
}">
</template>
</div>
<script type="text/javascript" src="../vendor/alpine.3.13.5.js" defer></script>
<script type="text/javascript">
document.addEventListener(
"alpine:init",
function setupAlpineBindings() {
Alpine.data( "app", AppController );
Alpine.data( "friend", FriendController );
Alpine.directive( "template-outlet", TemplateOutletDirective );
}
);
/**
* I control the app component.
*/
function AppController() {
return {
thing: "App",
id: 0
}
}
/**
* I control the friend component.
*/
function FriendController( friendID, friendName ) {
// NOTE: If we didn't want to use constructor arguments, we could have also
// used "this.$data.friendID" and "this.$data.friendName" magic references
// within our constructor logic.
return {
thing: "Friend",
id: friendID,
name: friendName
};
}
/**
* I clone and render the given source template.
*/
function TemplateOutletDirective( element, metadata, framework ) {
// Get the template reference that we want to clone and render.
var templateRef = framework.evaluate( metadata.expression );
// Clone the template and get the root node - this is the node that we will
// inject into the DOM.
var clone = templateRef.content
.cloneNode( true )
.firstElementChild
;
// CAUTION: The following logic ASSUMES that the template-outlet directive has
// an "x-data" scope binding on it. If it didn't we would have to change the
// logic. But, I don't think Alpine.js has mechanics to solve this use-case
// quite yet.
Alpine.addScopeToNode(
clone,
// Use the "x-data" scope from the template-outlet element as a means to
// supply initializing data to the clone (for constructor injection).
Alpine.closestDataStack( element )[ 0 ],
// use the template-outlet element's parent to define the rest of the
// scope chain.
element.parentElement
);
// Instead of leaving the template in the DOM, we're going to swap the
// template with a comment hook. This isn't necessary; but, I think it leaves
// the DOM more pleasant looking.
var domHook = document.createComment( ` Template outlet hook (${ metadata.expression }) with bindings (${ element.getAttribute( "x-data" ) }). ` );
domHook._template_outlet_ref = templateRef;
domHook._template_outlet_clone = clone;
// Swap the template-outlet element with the hook and clone.
// --
// NOTE: Doing this inside the mutateDom() method will pause Alpine's internal
// MutationObserver, which allows us to perform DOM manipulation without
// triggering actions in the framework. Then, we can call initTree() and
// destroyTree() to have explicitly setup and teardowm DOM node bindings.
Alpine.mutateDom(
function pauseMutationObserver() {
element.after( domHook );
domHook.after( clone );
Alpine.initTree( clone );
element.remove();
Alpine.destroyTree( element );
}
);
}
</script>
</body>
</html>
Note to Self: I'm attaching my own expando properties to the hook comment node (
_template_outlet_ref
and_template_outlet_clone
). I think I might need to remove those in acleanup()
call if the comment is ever removed from the DOM. Leaving those in might create some sort of memory leak?? Not sure.
In my cloning algorithm, my Alpine.addScopeToNode()
call assumes that the x-template-outlet
element has its own x-data
binding. But, this isn't strictly a requirement. If I wanted to make this more robust, I would have to test the element to see if it contained an "x-data"
attribute; and, if not, I would have to use a different call to set the scope bindings.
With this post, I'm getting closer to being able to implement some sort of recursive component rendering!
UPDATE: 2024-03-01
I've updated the DOM manipulation portion of the directive and wrapped it in an Alpine.mutateDom()
call. It seems that all of the structural directives in Alpine.js follow this pattern.
// Swap the template-outlet element with the hook and clone.
// --
// NOTE: Doing this inside the mutateDom() method will pause Alpine's internal
// MutationObserver, which allows us to perform DOM manipulation without
// triggering actions in the framework. Then, we can call initTree() and
// destroyTree() to have explicitly setup and teardowm DOM node bindings.
Alpine.mutateDom(
function pauseMutationObserver() {
element.after( domHook );
domHook.after( clone );
Alpine.initTree( clone );
element.remove();
Alpine.destroyTree( element );
}
);
Once I insert the clone
, I ask Alpine to initialize it. And, once I remove the element
(the template hook), I ask Alpine to tear it down. I'm hoping that this will prevent any memory leaks. I can also confirm that this does call the cleanup()
callback (though I don't have one defined in this demo).
Want to use code from this post? Check out the license.
Reader Comments
So, it seems that when the template is cloned and appended to the current parent which is being evaluated, this works fine. However, I'm finding that if I use this in other parts of the DOM tree (that has already been initialized), the changes don't always take effect. The behind-the-scenes data is there, but the DOM doesn't "react".
I'm finding that if I try to do what
x-if
andx-for
are doing, and wrap it in amutateDom()
call and then explicitly callinitTree()
then it works more consistently. So, instead of just calling this:I am calling this:
But, I'm not sure if this is "proper". Among the possible issues, I am not sure that Alpine.js will know to "clean up" the
element
after I callelement.remove()
?I'm trying to dig in and understand the life-cycle stuff a bit better.
I've updated the code in the demo to use both the
Alpine.initTree()
method and theAlpine.destroyTree()
method. I didn't realize there was adestroyTree()
method. But, it came up in this GitHub discussion:https://github.com/alpinejs/alpine/discussions/4062
Now that I have this template-outlet directive, I can finally do some recursive data rendering:
www.bennadel.com/blog/4603-recursive-template-rendering-in-alpine-js-3-13-5.htm
In the code in the post, I am using
Alpine.addScopeToNode()
to copy the data-stack from the<template>
element over to the clone. And, that code assumes that the template has anx-data
attribute. To simplify, I think I can just copy the internal expando property directly:This somewhat abuses the private variables. But, it seems like that actually happens quite a bit in the directives that I've seen.
This
x-template-outlet
was instrumental in being able to create my JSON Explorer in Alpine.js:www.bennadel.com/blog/4611-recursive-json-explorer-in-alpine-js-3-13-5.htm
This is a great problem for exploring language functionality.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →