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
Rendererconcept 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
1import * as THREE from "three";2
3const scene = new THREE.Scene();4const camera = new THREE.PerspectiveCamera(5 75,6 window.innerWidth / window.innerHeight,7 0.1,8 1000,9);10
11const renderer = new THREE.WebGLRenderer();12renderer.setSize(window.innerWidth, window.innerHeight);13document.body.appendChild(renderer.domElement);14
15const geometry = new THREE.BoxGeometry(1, 1, 1);16const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });17const cube = new THREE.Mesh(geometry, material);18scene.add(cube);19
20camera.position.z = 5;21
22function 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
31animate();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 aMeshwith aBoxGeometryand 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:
1const geometry = new THREE.BoxGeometry(1, 1, 1);2const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });3const cube = new THREE.Mesh(geometry, material);- 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.
1const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });2// is pretty much the same as3const material = new THREE.MeshBasicMaterial();4material.color.setHex(0x00ff00);
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:
1const mesh = new THREE.Mesh();2scene.add(mesh);3
4const geometry = new THREE.BoxGeometry();5mesh.geometry = geometry;6
7const material = new THREE.MeshBasicMaterial();8mesh.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>1_AppComponent.ɵcmp = /* @__PURE__ */2 i0.ɵɵdefineComponent({3 type: _AppComponent,4 selectors: [["app-root"]],5 standalone: true,6 features: [i0.ɵɵStandaloneFeature],7 decls: 4,8 vars: 0,9 template: function AppComponent_Template(rf, ctx) {10 if (rf & 1) {11 i0.ɵɵelementStart(0, "div")(1, "span");12 i0.ɵɵtext(2, "Child");13 i0.ɵɵelementEnd();14 i0.ɵɵelement(3, "app-child-cmp");15 i0.ɵɵelementEnd();16 }17 },18 dependencies: [ChildCmp],19 encapsulation: 220 });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 & 1check is when the component is inCreatephase. No bindings happen in this phase.rf & 2check is when the component is inUpdatephase. Bindings happen in this phase. Upon Change Detection’s tick, the template function is re-invoked withRenderFlags.Updateas the value forrfwhich satisfies therf & 2check. Consequently, this re-invokes the Template Instructions responsible for the bindings on our template.
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>1 _AppComponent.ɵcmp = /* @__PURE__ */2 i0.ɵɵdefineComponent({3 type: _AppComponent,4 selectors: [["app-root"]],5 standalone: true,6 features: [i0.ɵɵStandaloneFeature],7 decls: 4,8 vars: 1,9 // 👇 new "consts" array to store constants on the template10 consts: [["data-dummy", "chau"], [3, "foo"]],11 template: function AppComponent_Template(rf, ctx) {12 if (rf & 1) {13 // 👇 this call for "span" now accepts a new argument "0"14 i0.ɵɵelementStart(0, "div")(1, "span", 0);15 i0.ɵɵtext(2, "Child");16 i0.ɵɵelementEnd();17 // 👇 notice this "3" index18 i0.ɵɵelement(3, "app-child-cmp", 1);19 i0.ɵɵelementEnd();20 }21 // 👇 Update phase check22 if (rf & 2) {23 // 👇 notice this "3" index24 i0.ɵɵadvance(3);25 // 👇 new "property" instruction26 i0.ɵɵproperty("foo", ctx.text);27 }28 },29 dependencies: [ChildCmp],30 encapsulation: 231 });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
constsarray is generated. Notice how ourdata-dummy=chaubecomes["data-dummy", "chau"]and how it is in the0position of theconstsarray.constsholds more information than just Attribute Bindings but we will not get into that in this blog post. elementStartcall for"span"is invoked with an additional argument0. Notice how this is the same index as thedata-dummy=chauattribute in theconstsarray. This means that Angular is able to set the attributedata-dummyto theHTMLSpanElement, at the time the element is instantiated, during theCreatephase.- New instructions,
advanceandproperty, are generated in theUpdatephase.advancetells Angular to go to theNodewith this index (i.e:3)propertyis responsible for Property Binding. Here,propertyis invoked with"foo"andctx.textwherefoois the name of ourChildCmpInput andctx.textisAppComponent.textfield.
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})4export 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})21export 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
3const material = new THREE.MeshBasicMaterial();4mesh.material = material;5
6// sometimes in the future7material.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: true4})5export class NgtArgs {6 @Input() set args(args: any[]) {7 // let's assume args is always valid for now8 if (this.embeddedViewRef) {9 // if args changes, we will need to reconstruct the THREE.js entities10 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}1<ngt-box-geometry *args="[1, 1, 1]" />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 renderer2export 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.js6 if (threeTarget) {7 // 👇 we need to pass the args, [1, 1, 1], here8 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
1function AppComponent_ngt_box_geometry_0_Template(rf, ctx) {2 if (rf & 1) {3 i0.ɵɵelement(0, "ngt-box-geometry");4 }5}6
7function 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)
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
1createComment(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:
1createComment(value: string) {2 const commentNode = this.platformRenderer.createComment(value);3 // Do something with the commentNode before returning it4 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})5export class NgtArgs {6 @Input() set args(args: any[]) {7 // let's assume args is always valid for now8 if (this.embeddedViewRef) {9 // if args changes, we will need to reconstruct the THREE.js entities10 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
And voila, we found our Injector. The ViewContainerRef also contains the element which is the Comment node that the Renderer created. Great!
ViewContainerRefcontains all the information we need but we can injectElementRefandInjectorexplicitly.
So what do we know so far?
- The
Commentwill be created before theNgtArgsdirective is instantiated, which means thatNgtArgsconstructor runs after theCommentis created ViewContainerRefcontains theInjectoralong with theComment.
Then what can we do?
- We can attach some arbitrary function on the
Commentnode. - We then get the instance of the
Commentnode using theViewContainerRefand 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
1@Injectable()2export 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 Renderers5 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 Renderer9 return new CustomRenderer(platformRenderer, this.comments);10 }11}12export 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}1@Directive({2 selector: '[args]',3 standalone: true4})5export class NgtArgs {6 injectedArgs?: any[];7 @Input() set args(args: any[]) {8 if (this.embeddedViewRef) {9 this.embeddedViewRef.destroy();10 }11 // 👇 we also need to expose the "args" by assigning it to a public field "injectedArgs"12 this.injectedArgs = args;13 this.embeddedViewRef = this.vcr.createEmbeddedView(this.templateRef);14 }15 private vcr = inject(ViewContainerRef);16 private templateRef = inject(TemplateRef);17 private embeddedViewRef?: EmbeddedViewRef;18 constructor() {19 const commentNode = this.vcr.element.nativeElement;20 /*21 * - Get the Comment22 * - Invoke the function23 * - Delete the function24 */25 if (commentNode['__TRACK_FN__']) {26 commentNode['__TRACK_FN__'](this.vcr.injector);27 delete commentNode['__TRACK_FN__'];28 }29 // 👆 This approach also allows us to ignore other directives like ngIf, ngFor etc... that we don't need to track30 }31}Alright, now we have the Injector[] that we’re tracking, we can adjust Renderer#createElement() to make use of the Injector
1export 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.js5 if (threeTarget) {6 let args: any[] = [];7 // we'll loop over the `Injector` array and attempt to inject NgtArgs8 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 injectedArgs15 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