In Angular, Structural Directives are directives that can alter the DOM layout by dynamically adding or removing elements. Up until Angular 17, we are familiar with the common directives like *ngIf
and *ngFor
. However, these directives are replaced by built-in control flows blocks, but structural directive concept itself is here-to-stay. With that being said, this blog post aims to take your understanding of Structural Directives to the next level and show how they are more than just control flows.
Context
Before we dive in, I should layout some context
First of all, I’m the maintainer of angular-three, a THREE.js integration for Angular. The example in this blog post is taken from a feature in angular-three
that enables one of the most important building blocks of THREE.js
Secondly, angular-three
has gone through several iterations with different implementations:
- Wrappers with Components and Directives
- Proxied constructors with Directives
- Custom Renderer.
Although a bit old, you can read more about the
Renderer
concept in this blog post by Victor Savkin
At the moment, angular-three
is a custom Renderer which allows me to own the Angular Template where the angular-three
renderer is in control.
Next, the blog post is indeed about Structural Directives but to get to the point, we’d need to make several round trips to know how Angular works under the hood. So, stay with me, it’ll be worth it 🤞
Last but not least, this blog post assumes some basic understanding of Angular in general, and specifically Structural Directives. If the summary at the beginning of the blog post doesn’t make you start envisioning some <div *ngIf=""></div>
in your head, I’d highly recommend you checking out the official documentation on the matter before continue on.
Some basic understanding of WebGL or THREE.js would allow the example to stick with you easier but it is definitely not required.
The important building blocks of THREE.js
THREE.js is an abstraction over WebGL and is mainly used to build 3D applications on the web. Here’s a complete snippet of a simple THREE.js application
Here, we can see THREE.js needs to have:
- A
Scene
, aCamera
, and aRenderer
: these are used to actually render our Scene Graph in an animation loop. - Objects that can go onto the
Scene
: in this case, it is aMesh
with aBoxGeometry
and aMeshBasicMaterial
We will not dive too deep into THREE.js in this blog post but we need at least this basic understanding to digest the next sections. From the above snippet, let’s take a closer look at the following:
- Geometries in THREE.js accept positional parameters (i.e: construction arguments). These parameters are sent to the GPU to build up the vertices that determine the shape of this object.
new THREE.BoxGeometry(1, 1, 1)
tells the GPU to build a cube with[width: 1, height: 1, depth: 1]
. - Materials in THREE.js accept object paramters. These parameters are sent to the shader on every frame so they are safe to be dynamically updated anytime.
Next, let’s talk about angular-three
How would this look like in Angular?
In angular-three
, the Scene
, Camera
, and Renderer
are taken care of by a single top-level component called ngt-canvas
. The objects, on the other hand, are the developers’ responsibility (and that’s where the fun is).
For the above THREE.js example, here’s how it’d partially look like in angular-three
This translates into the following code in THREE.js:
We’re close! The BoxGeometry
and MeshBasicMaterial
need some parameters though. Before we dive into that, let’s take a side-track and look at how Angular Renderer works roughly.
How does Angular Renderer work (roughly)?
Angular Renderer is responsible for understanding the elements that the developers put on the template. Considering the following template and its compiled version
The Compiled tab shows how Angular compiles our AppComponent
. The HTML template has been compiled into a AppComponent_Template
function. In the compiled template function, we see the Template Instructions that Angular Core generates do not have any knowledge that a div
is an HTMLDivElement
, a span
is an HTMLSpanElement
, and so on. That is the job of the underlying platform’s renderer (that implements Renderer2
).
By default, an Angular CLI application is bootstraped by @angular/platform-browser
with DefaultDomRenderer
being the default Renderer2
. With this in mind, the elementStart
instruction will eventually invoke Renderer.createElement()
method and this is the earliest point in time where an HTMLDivElement
is instantiated.
With that out of the way, let’s take a closer look at the first parameter of the compiled template function, rf
. rf
stands for RenderFlags
, which has 2 values Create
and Update
.
rf & 1
check is when the component is inCreate
phase. No bindings happen in this phase.rf & 2
check is when the component is inUpdate
phase. Bindings happen in this phase. Upon Change Detection’s tick, the template function is re-invoked withRenderFlags.Update
as the value forrf
which satisfies therf & 2
check. Consequently, this re-invokes the Template Instructions responsible for the bindings on our template.
Additionally, let’s add some bindings to our template.
By adding an Attribute Binding and a Property Binding to our template, the compiled template function has changed quite a bit. Let’s parse through the changes.
Attribute Binding isn’t technically a correct term because there’s no “binding” with Attributes since they are static.
- A new
consts
array is generated. Notice how ourdata-dummy=chau
becomes["data-dummy", "chau"]
and how it is in the0
position of theconsts
array.consts
holds more information than just Attribute Bindings but we will not get into that in this blog post. elementStart
call for"span"
is invoked with an additional argument0
. Notice how this is the same index as thedata-dummy=chau
attribute in theconsts
array. This means that Angular is able to set the attributedata-dummy
to theHTMLSpanElement
, at the time the element is instantiated, during theCreate
phase.- New instructions,
advance
andproperty
, are generated in theUpdate
phase.advance
tells Angular to go to theNode
with this index (i.e:3
)property
is responsible for Property Binding. Here,property
is invoked with"foo"
andctx.text
wherefoo
is the name of ourChildCmp
Input andctx.text
isAppComponent.text
field.
An important point to note is that property
instruction is part of the Update
phase which means that Property Binding does not happen until the Update
phase. To many Angular developers, this is commonly known as “when the Change Detection runs and updates the template’s bindings”. As a side note, we can connect this knowledge back to “When is an Input resolved in Angular component?”, it actually makes more sense now, doesn’t it?
So what is the take away here?
Angular creates the elements before Property Bindings has the chance to be set on the elements. And Attribute Bindings
only work with string
values so they are limited.
Apply what we know to angular-three
Let’s bring back our angular-three
template that creates a Mesh
As we learned above, MeshBasicMaterial
parameters can be safely set after the material is created. Hence,
we can safely rely on Property Bindings to set parameters for MeshBasicMaterial
.
This translates roughly to the following THREE.js code
Now, this leaves us new THREE.BoxGeometry(1, 1, 1)
and this is the problem considering how THREE.js expects Geometries to behave
How do we pass an expression (yes, it can be dynamic and cannot be a static string
value) at the time that the element is created?
In other words, what we want to achieve is to defer the instantiation of <ngt-box-geometry />
declaratively on the template. Anything comes to mind?
Not yet…OK, let’s rephrase that a little differently this time. How can we wait for some expression to be evaluated before we start rendering <ngt-box-geometry />
? (keywords: wait, expression, evaluated, start rendering)
Yes, this phrasing reminds us of Structural Directives. These directives evaluate some expressions and then createEmbeddedView
dynamically afterwards. At this point, we know that we want to use Structural Directive to solve the problem, but how though?
Whew, we are finally talking about Structural Directives. I truly hope I haven’t bored you yet 🤞
Structural Directive for Construction Arguments
To model passing in construction arguments to THREE.js entities, angular-three
provides a directive called NgtArgs
and it is used like this
Why don’t we re-build NgtArgs
to see how it works?
Notice that TODO
? Yes, we are able to defer the rendering of ngt-box-geometry
until we have [1, 1, 1]
available in NgtArgs
but we haven’t
done anything with [1, 1, 1]
yet. This brings up another problem: How can we access this args
in our element instantiation logic, aka the renderer?
In order to understand the next part, let’s look at a simplified version of Renderer.createElement()
This is a very tricky problem because we can see that createElement
does not give us a whole lot of information surrounding the element being created beside its name
. To solve this, it’s important to understand how Angular treats Structural Directives, and to comprehend the sequence in which the Angular Renderer operates in relation to the Structural Directives. It is also important to understand this process in conjunction with the dynamically generated EmbeddedViewRef
.
Structural Directive and Comment node
Once again, here’s our template with *args
structural directive
For folks that are not aware, *args
is the short-hand syntax for Structural Directive. The *
syntax is expanded in to the following long form
Well well, what have we here? Remember how we inject(TemplateRef)
in our NgtArgs
directive? In the long-form, we can easily see where the TemplateRef
comes from. Our NgtArgs
directive is attached on an ng-template
element making the instance of TemplateRef
available to NgtArgs
directive.
Next, let’s check the compiled code
Woohoo, new instruction! The template
instruction is responsible for handling ng-template
. Internally, Angular creates a Comment
in place of the ng-template
. Additionally, Angular also creates an ElementInjector
associated with the Comment
node IF there is at least one directive attached on the ng-template
, and in our case, that directive is NgtArgs
. This also means that
if we can get a hold of that Comment
node, we technically can access the NgtArgs
directive instance that is attached on that Comment
node.
A screenshot of the Comment node and its Injector (NgtArgs instance is visible at the bottom)
Another important point is Angular creates the Comment
node first, then the NgtArgs
directive will be instantiated with the property
instruction, in the Update
phase. Now, how do we track the created Comment
? Our Custom Renderer will do that.
Tracking the Comment
nodes
To create a Comment
node, Angular Core invokes Renderer#createComment
method and since we’re using a Custom Renderer, we are able to intercept this call to track the created Comment
. Here’s the snippet of createComment
We can modify createComment
like this:
Access the Injector
on the Comment
Tracking the Comment
alone will not be enough, we need the Injector
which allows us to access the NgtArgs
instance. Let’s go back to NgtArgs
and add some code
A screenshot of the ViewContainerRef on the directive
And voila, we found our Injector
. The ViewContainerRef
also contains the element
which is the Comment
node that the Renderer created. Great!
ViewContainerRef
contains all the information we need but we can injectElementRef
andInjector
explicitly.
So what do we know so far?
- The
Comment
will be created before theNgtArgs
directive is instantiated, which means thatNgtArgs
constructor runs after theComment
is created ViewContainerRef
contains theInjector
along with theComment
.
Then what can we do?
- We can attach some arbitrary function on the
Comment
node. - We then get the instance of the
Comment
node using theViewContainerRef
and invoke this function in our directive. At this point, we can pass anything we want to our Renderer.
There are different approaches to do this but this is what Angular Three is doing at the moment.
Let’s adjust createComment
as well as NgtArgs
Alright, now we have the Injector[]
that we’re tracking, we can adjust Renderer#createElement()
to make use of the Injector
That is how we inject data (or constructor arguments) to createElement
, using Structural Directive.
Angular Three implementation is a lot more involved with performance as well as validation. The implementation in this blog post is just to give the readers the idea of manipulating Structural Directives
Conclusion
In this blog post, we’ve explored a niche, yet technical, use-case of using Structural Directive to defer (not @defer
😛) the instantiation
of some element on the template. We’ve learned that Structural Directives are much more than show and hide elements. Additionally, we’ve also learned
about different RenderFlags
that Angular Core implements as well as some Template Instructions and their purposes. Last but not least, we’ve also learned
about ng-template
and the Comment
node along with all the surrounding technical details.
I hope I was able to provide a different perspective for Structural Directive, and an in-depth guide as to how Structural Directive works. After all, I hope you were able to learn something new and have fun learning it. See y’all in the next blog post.
Acknowledgement
I humbly thank all these beautiful wonderful people for reviewing this blog post