All Blogs

Structural Directive - A different perspective

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:

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

1
import * as THREE from "three";
2
3
const scene = new THREE.Scene();
4
const camera = new THREE.PerspectiveCamera(
5
75,
6
window.innerWidth / window.innerHeight,
7
0.1,
8
1000,
9
);
10
11
const renderer = new THREE.WebGLRenderer();
12
renderer.setSize(window.innerWidth, window.innerHeight);
13
document.body.appendChild(renderer.domElement);
14
15
const geometry = new THREE.BoxGeometry(1, 1, 1);
16
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
17
const cube = new THREE.Mesh(geometry, material);
18
scene.add(cube);
19
20
camera.position.z = 5;
21
22
function animate() {
23
requestAnimationFrame(animate);
24
25
cube.rotation.x += 0.01;
26
cube.rotation.y += 0.01;
27
28
renderer.render(scene, camera);
29
}
30
31
animate();

Here, we can see THREE.js needs to have:

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:

1
const geometry = new THREE.BoxGeometry(1, 1, 1);
2
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
3
const cube = new THREE.Mesh(geometry, material);

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

1
<ngt-mesh>
2
<!-- notice no construction arguments for now -->
3
<ngt-box-geometry />
4
<!-- notice no parameters for now -->
5
<ngt-mesh-basic-material />
6
</ngt-mesh>

This translates into the following code in THREE.js:

1
const mesh = new THREE.Mesh();
2
scene.add(mesh);
3
4
const geometry = new THREE.BoxGeometry();
5
mesh.geometry = geometry;
6
7
const material = new THREE.MeshBasicMaterial();
8
mesh.material = material;

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

1
<div>
2
<span>Child</span>
3
<app-child-cmp />
4
</div>

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.

Additionally, let’s add some bindings to our template.

1
<div>
2
<!-- 👇 attribute binding -->
3
<span data-dummy="chau">Child</span>
4
<!-- 👇 property binding -->
5
<app-child-cmp [foo]="text" />
6
</div>

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.

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?

1
@Component({
2
selector: "my-cmp",
3
})
4
export class MyCmp {
5
@Input() foo = "initial";
6
7
constructor() {
8
console.log(this.foo); // logs "initial"
9
}
10
11
ngOnInit() {
12
console.log(this.foo); // logs "from app component"
13
}
14
}
15
16
@Component({
17
template: `
18
<my-cmp [foo]="'from app component'" />
19
`,
20
})
21
export class AppCmp {}

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

1
<ngt-mesh>
2
<ngt-box-geometry />
3
<ngt-mesh-basic-material />
4
</ngt-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.

1
<ngt-mesh>
2
<ngt-box-geometry />
3
<ngt-mesh-basic-material [color]="materialColor" />
4
</ngt-mesh>

This translates roughly to the following THREE.js code

1
/* removed for brevity */
2
3
const material = new THREE.MeshBasicMaterial();
4
mesh.material = material;
5
6
// sometimes in the future
7
material.color.setHex(materialColor);

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

1
<!-- new THREE.BoxGeometry(1, 1, 1) -->
2
<ngt-box-geometry *args="[1, 1, 1]" />
3
<!-- new OrbitControls(camera, domElement) -->
4
<ngt-orbit-controls *args="[camera, domElement]" />

Why don’t we re-build NgtArgs to see how it works?

1
@Directive({
2
selector: '[args]',
3
standalone: true
4
})
5
export class NgtArgs {
6
@Input() set args(args: any[]) {
7
// let's assume args is always valid for now
8
if (this.embeddedViewRef) {
9
// if args changes, we will need to reconstruct the THREE.js entities
10
this.embeddedViewRef.destroy();
11
}
12
// TODO: ??
13
this.embeddedViewRef = this.vcr.createEmbeddedView(this.templateRef);
14
}
15
private vcr = inject(ViewContainerRef);
16
private templateRef = inject(TemplateRef);
17
private embeddedViewRef?: EmbeddedViewRef;
18
}

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()

1
// Our custom renderer
2
export class CustomRenderer implements Renderer2 {
3
createElement(name: string, namespace?: string | null | undefined) {
4
// name will be `ngt-box-geometry` when Angular tries rendering `ngt-box-geometry`
5
const threeTarget = getThreeTargetFromName(name); // this will return the actual BoxGeometry class from THREE.js
6
if (threeTarget) {
7
// 👇 we need to pass the args, [1, 1, 1], here
8
const node = new threeTarget(); // this is where we instantiate the BoxGeometry.
9
}
10
}
11
}

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

1
<ngt-box-geometry *args="[1, 1, 1]" />

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

1
<ng-template [args]="[1, 1, 1]">
2
<ngt-box-geometry />
3
</ng-template>

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

1
function AppComponent_ngt_box_geometry_0_Template(rf, ctx) {
2
if (rf & 1) {
3
i0.ɵɵelement(0, "ngt-box-geometry");
4
}
5
}
6
7
function AppComponent_Template(rf, ctx) {
8
if (rf & 1) {
9
i0.ɵɵtemplate(
10
0,
11
AppComponent_ngt_box_geometry_0_Template,
12
1,
13
0,
14
"ngt-box-geometry",
15
0,
16
);
17
}
18
if (rf & 2) {
19
i0.ɵɵproperty("args", [1, 1, 1]);
20
}
21
}

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) 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

1
createComment(value: string) {
2
// 👇 the platform Renderer; in our case, this would be the default DomRenderer from `platform-browser`
3
return this.platformRenderer.createComment(value);
4
}

We can modify createComment like this:

1
createComment(value: string) {
2
const commentNode = this.platformRenderer.createComment(value);
3
// Do something with the commentNode before returning it
4
return commentNode;
5
}

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

1
@Directive({
2
selector: "[args]",
3
standalone: true,
4
})
5
export class NgtArgs {
6
@Input() set args(args: any[]) {
7
// let's assume args is always valid for now
8
if (this.embeddedViewRef) {
9
// if args changes, we will need to reconstruct the THREE.js entities
10
this.embeddedViewRef.destroy();
11
}
12
this.embeddedViewRef = this.vcr.createEmbeddedView(this.templateRef);
13
}
14
private vcr = inject(ViewContainerRef);
15
private templateRef = inject(TemplateRef);
16
private embeddedViewRef?: EmbeddedViewRef;
17
18
constructor() {
19
// let's log the ViewContainerRef to see what we have on here (you can log other stuffs too)
20
console.log(this.vcr);
21
}
22
}

A screenshot of the ViewContainerRef on the directive 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 inject ElementRef and Injector explicitly.

So what do we know so far?

Then what can we do?

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

1
@Injectable()
2
export class CustomRendererFactory implements RendererFactory2 {
3
private platformRendererFactory = inject(RendererFactory2, { skipSelf: true });
4
// 👇 we create an Injector[] in the Factory because we want to keep a single array for all Renderers
5
private comments: Injector[] = [];
6
createRenderer(hostElement: any, type: RendererType2 | null) {
7
const platformRenderer = this.platformRendererFactory.createRenderer(hostElement, type);
8
// 👇 pass the Injector[] down to the Renderer
9
return new CustomRenderer(platformRenderer, this.comments);
10
}
11
}
12
export class CustomRenderer implements Renderer2 {
13
constructor(private platformRenderer: Renderer2, private comments: Injector[]) {}
14
createComment(value: string) {
15
const commentNode = this.platformRenderer.createComment(value);
16
// 👇 We attach a function that accepts an Injector and push that injector to our tracked array.
17
commentNode['__TRACK_FN__'] = (injector: Injector) => {
18
this.comments.push(injector);
19
}
20
return commentNode;
21
}
22
}

Alright, now we have the Injector[] that we’re tracking, we can adjust Renderer#createElement() to make use of the Injector

1
export class CustomRenderer implements Renderer2 {
2
createElement(name: string, namespace?: string | null | undefined) {
3
// name will be `ngt-box-geometry` when Angular tries rendering `ngt-box-geometry`
4
const threeTarget = getThreeTargetFromName(name); // this will return the actual BoxGeometry class from THREE.js
5
if (threeTarget) {
6
let args: any[] = [];
7
// we'll loop over the `Injector` array and attempt to inject NgtArgs
8
for (const commentInjector of this.comments) {
9
const ngtArgs = commentInjector.get(NgtArgs, null);
10
if (!ngtArgs) continue;
11
const injectedArgs = ngtArgs.injectedArgs;
12
if (!injectedArgs) continue;
13
args = injectedArgs;
14
// break as soon as we find injectedArgs
15
break;
16
}
17
const node = new threeTarget(...args); // this is where we instantiate the BoxGeometry.
18
}
19
}
20
}

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

Published on Tue Dec 05 2023


Angular