Reusing views
Sometimes you may need to implement a show-then-hide UI workflow that repeatedly re-renders the same component or a view template.
In the example below, the button a user click on determines the component that will be rendered on the screen,
while the other component will be hidden:
A straightforward implementation of the above logic would be to use ngIf
or ngSwitch
directive to render
the required component and hide the others:
@Component({
selector: 'host-rv-alt',
template: `
<button (click)="type = 'A'">Show A</button>
<button (click)="type = 'B'">Show B</button>
<div class="section">
<ng-container [ngSwitch]="type">
<a-rv *ngSwitchCase="'A'"></a-rv>
<b-rv *ngSwitchCase="'B'"></b-rv>
</ng-container>
</div>
`,
})
export class HostRvAltComponent {
type = null;
}
@Component({ selector: 'a-rv', ... })
export class ARvComponent {}
@Component({ selector: 'b-rv', ... })
export class BRvComponent {}
The problem with such approach is that we might have tens or even hundreds of different components that we need to alternate between.
Using the ngIf
or ngSwitch
directive will make a template unwieldy, hard to understand and non performant.
It's possible that we may not be able to use these directives if the component to render is determined dynamically in runtime
and loaded lazily.
A better alternative to using those directives would be
ViewContainerRef.
This service allows to attach and render components dynamically.
To render a component, we'll need to define a view container in the template, e.g. on the ng-container
element, obtain its reference with @ViewChild
and add the component using the
createComponent
method:
@Component({
selector: "host-rv",
template: `
<button (click)="show('A')">Show A</button>
<button (click)="show('B')">Show B</button>
<div class="section" style="margin: 20px">
<ng-container #vc></ng-container>
</div>
`,
})
export class HostRvAltComponent {
@ViewChild("vc", { read: ViewContainerRef, static: true }) vc;
show(type) {
this.vc.clear();
this.vc.createComponent(type === "A" ? ARvComponent : BRvComponent);
}
}
Notice how clean the template looks like compared to the one where we used ngSwitch
directive.
However, even with the view container API, we still have one big problem remaining –
an undesirable consequence of destroying and re-creating views each time a button is clicked.
Here’s the recording that shows it:
A debugger is paused inside the
createLView
function that’s used by Angular to create a new instance of a component view.
Everytime we click on a button, the function gets executed, which shows that Angular repeatedly destroys and re-creates the view.
What's interesting is that the program execution enters the function twice.
First execution creates an LView
for the component that renders text on the screen – either ARvComponent
or BRvComponent
.
When the code gets inside the function for the second time, Angular creates a view to host LView
created during the first pass.
Angular implements optimization techniques to speed up LView
creation through the
blueprint
LView
:
export interface TView {
blueprint: LView;
...
}
This LView
instance is pretty shallow as we can see on the screenshot below:
So Angular still needs to add lots of stuff to that shallow view inside the createLView
function:
function createLView(parentLView, tView, context, flags, ...) {
const lView = tView.blueprint.slice();
lView[HOST] = host;
lView[FLAGS] = flags | LViewFlags.CreationMode;\
lView[PARENT] = lView[DECLARATION_VIEW] = parentLView;
lView[CONTEXT] = context;
...
return lView;
}
Ideally we should avoid this churning of the views altogether.
This could be achieved by creating an instance of a view only once, caching it somewhere and then reusing it when needed. A
view container
provides an API to do exactly this.
To attach an existing view to a view container we can use the insert
method
and to detach the view without destroying it we can use the detach
method.
Here’s the interface for the methods:
abstract class ViewContainerRef {
createEmbeddedView<C>(templateRef: TemplateRef<C>, ...): EmbeddedViewRef<C>;
createComponent<C>(componentType, ...): ComponentRef<C>;
detach(index?: number): ViewRef|null;
insert(viewRef: ViewRef, index?: number): ViewRef;
move(viewRef: ViewRef, currentIndex: number): ViewRef;
remove(index?: number): void;
}
Calling the remove
method will destroy the view, so we should only use it when we don’t need to use the view anymore.
Before we explore how we can use these methods for optimization, let’s briefly discuss the topic of a ViewRef
.
You can see that it's used extensively in the API definition above.
ViewRef as a wrapper for LView
We discussed
LView data structure many times throughout this course. However, LView
is part of the framework internals and not designed to be exposed to developers. To interact with an LView
instance, Angular implements a higher level concept known as ViewRef:
export class ViewRef<T>
implements
viewEngine_EmbeddedViewRef<T>,
viewEngine_InternalViewRef,
viewEngine_ChangeDetectorRef_interface {}
It’s exposed to developers through the view container API and the ChangeDetectorRef
injectable.
When you inject a ChangeDetectorRef
into the component constructor, effectively you inject a ViewRef
that wraps the underlyng LView
. So when you call detectChanges
on the ChangeDetectorRef
, you’re calling detectChanges
on the underlying LView
:
export class ViewRef<T> implements viewEngine_ChangeDetectorRef_interface {
detectChanges(): void {
detectChangesInternal(
this._lView[TVIEW],
this._lView,
this.context as unknown as {}
);
}
}
Besides ChangeDetectorRef
, a ViewRef
also represents 3 types of views that a view container returns or takes as parameters:
export interface InternalViewRef extends ViewRef {}
export abstract class EmbeddedViewRef<C> extends ViewRef {}
export class RootViewRef<T> extends ViewRef<T> {}
Those views are:
- InternalViewRef
represents a component view. This class implementsChangeDetectorRef
interface - EmbeddedViewRef
represents a view created from a template - RootViewRef
represents a root view of a component
To render a component we can use a component class reference:
const componentRef = viewContainerRef.createComponent(ARvComponent);
The method returns
componentRef
which provides an access to a component class instance as well as related objects:
export class ComponentRef<T> extends AbstractComponentRef<T> {
override instance: T;
override hostView: ViewRef<T>;
override changeDetectorRef: ChangeDetectorRef;
override componentType: Type<T>;
}
Once we’ve got the view, it then can be attached to a view container using insert
method.
The other view you no longer want to show can be removed and preserved using detach
method.
The insert
method on a view container takes a ViewRef
.
As you can see, the ViewRef
is exposed through the property hostView
.
So when rendering the component we use this property to get the ViewRef
and then attach it to a view container:
const compRef = viewContainer.createComponent(ARvComponent);
...
// remove all views (components and embedded views) from the view container
viewContainer.detach();
...
// render the component
viewContainer.insert(compRef.hostView);
Notice again that we’re using detach
method instead of clear
or remove
to preserve the view for later reuse.
To create an embedded view we use a TemplateRef
and createEmbeddedView
method:
const embeddedView = viewContainer.createEmbeddedView(templateRef);
...
// remove all views (components and embedded views) from the view container
viewContainer.detach();
...
// render the component
viewContainer.insert(embeddedView);
The overall relationship between entities and the view container API can be illustrated like this:
All right, let’s see now how we can implement our optimization.
Preserve and re-insert the view
To reuse the view, we’ll first need to create its instance and keep a reference to it.
To render a component, we’ll insert the view into the container.
When a different component will need to be rendered,
we’ll simply detach previous views and insert the one corresponding the component.
Here’s the way to do:
export class HostRvComponent {
@ViewChild("vc", { read: ViewContainerRef, static: true }) vc;
types = [ARvComponent, BRvComponent];
show(type) {
this.vc.detach();
const componentRef = this.vc.createComponent(cmp);
this.vc.insert(componentRef.hostView);
}
}
Pretty straightforward. We’re using
createComponent
method implemented by the view container. The method instantiates a component view
and returns a viewRef
wrapped into the ComponentRef
. When inserting, we’re accessing the viewRef
through the
hostView
property.
So now the only thing left to do is to save the view and re-use it when needed:
export class HostRvComponent {
@ViewChild("vc", { read: ViewContainerRef, static: true }) vc;
cache = new Map();
types = [ARvComponent, BRvComponent];
show(type) {
this.vc.detach();
const cmp = this.types[type];
if (this.cache.has(cmp)) {
const ref = this.cache.get(cmp);
this.vc.insert(ref.hostView);
return;
}
const componentRef = this.vc.createComponent(cmp);
this.cache.set(cmp, componentRef);
}
}
If we now run the code and check the execution flow, we can clearly see that the createLView
function
is only executed when the view is created for the first time:
This optimization may not have a big impact if all you render is a single component.
But you can imagine a lot more elaborate setup with lots of nested views.
This is often the case with dashboard widgets. Let’s now see one of such use cases.
Dashboard
Suppose we have lots of widgets in a dashboard and we want to optimize them.
Each widget might have a view that contains hundreds of DOM elements:
One way to optimize is to only show the content of the widget when the widget becomes visible.
We could use a common virtual scroll approach, but that would destroy and re-create
the view inside the widget similar to how ngIf
and ngSwitch
does it.
Since we now know we could preserve the view, we could implement something like a virtual scroll without destroying the view:
In the implementation above the main content of a widget is wrapped in an element with the .container-element
CSS class.
As soon as the widget becomes visible, we can see that the number of elements increases, because we render a widget’s content into the DOM.
Once the widget is hidden, the view is removed from the DOM, and the number of elements goes down.
To implement the following optimization, we’ll need to use an intersection observer to know when to show and hide the widget.
A handler that will react to the visibility changes will notify the affected widgets so that they
can hide the view by detaching or show it through insertion.
Between these operations the view will be kept in the widget’s cache and cleaned up on the widgets destruction.
Let’s start with rendering widget items. A content of each widget is defined using the template part:
@Component({
selector: "d-rv",
template: `
<div *ngFor="let item of items" class="wrapper">
<div [container]="{item, tpl}"></div>
</div>
<ng-template #tpl let-item="item">
<span class="container-content">{{ item.name }}</span>
</ng-template>
`,
})
export class DRvComponent {
items = [
{ name: "Home 1", container: null },
{ name: "Home 2", container: null },
{ name: "Home 3", container: null },
{ name: "Home 4", container: null },
];
}
To actually render the template we’ll implement this directive:
@Directive({
selector: "[container]",
})
export class ContainerDirective {
@Input("container") params;
embeddedViewRef;
item;
constructor(public viewContainerRef: ViewContainerRef) {}
ngOnInit() {
this.item = this.params.item;
this.viewContainerRef.createEmbeddedView(this.params.tpl, { item });
}
}
When we now run the code, we’ll see 4 widgets rendered on the screen with the content inside.
Hence when we query the number of elements we get 4, no surprise here:
This is a non-optimized behavior – although only 2 items are visible,
the internal content of each widget in the .container-content
DOM wrapper is rendered for all 4 items.
Adding show and hide functionality
We could start our optimization journey by adding show/hide functionality to the directive that renders the content of a widget.
Here’s the implementation:
@Directive({
selector: "[container]",
})
export class ContainerDirective {
@Input("container") params;
embeddedViewRef;
item;
constructor(
public element: ElementRef,
public viewContainerRef: ViewContainerRef
) {}
ngOnInit() {
this.item = this.params.item;
this.embeddedViewRef = this.params.tpl.createEmbeddedView({
item: this.params.item,
});
this.viewContainerRef.insert(this.embeddedViewRef);
}
show(): void {
if (this.embeddedViewRef) {
this.viewContainerRef.insert(this.embeddedViewRef);
}
}
hide(): void {
this.viewContainerRef.detach();
}
}
This time instead of creating an embedded view through a view container, we create the view using TemplateRef
and save it.
To show the content, we insert the view into the container. When the content needs to be hidden, we simply detach it.
To test the implementation, let’s show and hide the view inside the setTimeout
callback like this:
@Directive({...})
export class ContainerDirective {
@Input('container') params;
embeddedViewRef;
item;
constructor(public element: ElementRef, public viewContainerRef: ViewContainerRef) {
setTimeout(() => this.hide(), 2000);
setTimeout(() => this.show(), 4000);
}
}
All working good:
Now our widget can show and hide the content on demand. What we then need to do is to detect when particular
widget becomes visible/hidden and trigger the corresponding action.
Reacting to visibility state change
To detect when a particular widget becomes visible we’ll use
IntersectionObserver.
Intersection observer works with DOM elements, so we’ll need to get a hold of the DOM element that describes the content.
We already inject it into the directive:
@Directive({...})
export class ContainerDirective {
constructor(
public element: ElementRef,
public viewContainerRef: ViewContainerRef
) {}
}
But we also need to somehow pass to the parent component that can register it with the observer.
For that we could use DI and simple registry pattern. Here’s a bit abridged implementation for that:
abstract class Registry {
abstract registerContainer(cmp): void;
abstract removeContainer(cmp): void;
}
@Component({
selector: 'd-rv',
providers: [
{ provide: Registry, useExisting: DRvComponent }
]
})
export class DRvComponent implements Registry {
items = [...];
registerContainer(cmp) {
const item = this.items.find((c) => c === cmp.item);
item.container = cmp;
}
removeContainer(cmp) {}
}
@Directive({...})
export class ContainerDirective {
constructor( private registry: Registry) {}
ngOnInit() {
this.item = this.params.item;
this.embeddedViewRef = this.params.tpl.createEmbeddedView({ item: this.params.item });
this.viewContainerRef.insert(this.embeddedViewRef);
this.registry.registerContainer(this);
}
show() {}
hide() {}
ngOnDestroy() {
this.registry.removeContainer(this);
}
}
The last thing that remains to do is:
- define an observer and register DOM elements with it when then are added to registry
- implement logic that reacts to visibility changes and calls the show or hide method on the corresponding widget
Here’s the implementation for that:
@Component({...})
export class DRvComponent implements Registry {
private readonly intersectionObserver = new IntersectionObserver(
(entries) => this.intersectionObserverCallback(entries),
);
private intersectionObserverCallback(entries): void {
const updateQueue = new Map();
entries.forEach((entry) => {
if (!entry.target.isConnected) return;
const transitionToVisible = entry.isIntersecting;
updateQueue.set(entry.target, transitionToVisible);
});
requestAnimationFrame(() => this.updateWidgetVisibility(updateQueue));
}
updateWidgetVisibility(updateQueue): void {
updateQueue.forEach((visible, el) => {
const { container } = this.items.find(
(i) => i.container.element.nativeElement === el
);
if (visible) {
container.show();
} else {
container.hide();
}
});
}
}
Now we can put it all together:
abstract class Registry {
abstract registerContainer(cmp): void;
abstract removeContainer(cmp): void;
}
@Component({
selector: "d-rv",
template: `
<div *ngFor="let item of items" class="wrapper">
<div [container]="{item, tpl}"></div>
</div>
<ng-template #tpl let-item="item">
<span class="container-content">{{ item.name }}</span>
</ng-template>
`,
styles: [
`
.wrapper {
background-color: #e6e6e6;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 20px 0;
border: 1px solid #d5d5f1;
min-height: 150px;
min-width: 200px;
}
.container-content {
margin: 10px;
padding: 40px;
background: lightseagreen;
}
`,
],
providers: [{ provide: Registry, useExisting: DRvComponent }],
})
export class DRvComponent implements Registry {
items = [
{ name: "Home 1", container: null },
{ name: "Home 2", container: null },
{ name: "Home 3", container: null },
{ name: "Home 4", container: null },
];
registerContainer(cmp) {
const item = this.items.find((c) => c === cmp.item);
item.container = cmp;
this.intersectionObserver.observe(cmp.element.nativeElement);
}
removeContainer(cmp) {
this.intersectionObserver.unobserve(cmp.element.nativeElement);
}
private readonly intersectionObserver = new IntersectionObserver((entries) =>
this.intersectionObserverCallback(entries)
);
private intersectionObserverCallback(entries): void {
const updateQueue = new Map();
entries.forEach((entry) => {
if (!entry.target.isConnected) return;
const transitionToVisible = entry.isIntersecting;
updateQueue.set(entry.target, transitionToVisible);
});
requestAnimationFrame(() => this.updateWidgetVisibility(updateQueue));
}
updateWidgetVisibility(updateQueue): void {
updateQueue.forEach((visible, el) => {
const { container } = this.items.find(
({ container }) => container.element.nativeElement === el
);
if (visible) {
container.show();
} else {
container.hide();
}
});
}
}
@Directive({
selector: "[container]",
})
export class ContainerDirective {
@Input("container") params;
embeddedViewRef;
item;
constructor(
public element: ElementRef,
public viewContainerRef: ViewContainerRef,
private registry: Registry
) {}
ngOnInit() {
this.item = this.params.item;
this.embeddedViewRef = this.params.tpl.createEmbeddedView({
item: this.params.item,
});
this.viewContainerRef.insert(this.embeddedViewRef);
this.registry.registerContainer(this);
}
show(): void {
if (this.embeddedViewRef) {
this.viewContainerRef.insert(this.embeddedViewRef);
}
}
hide(): void {
this.viewContainerRef.detach();
}
ngOnDestroy() {
this.registry.removeContainer(this);
}
}
When we run the example, we can see our optimization in action:
As widgets become visible, we render their internal content. This increases the number of DOM elements.
On hide, we remove the content and the number of DOM elements decreases.
To make sure that views are not re-created, we could put a logpoint inside the creatLView
function and check how many times it’s executed:
We could also add logpoints to show
method that inserts the view:
So now when we run the setup, here’s what we’ll see:
As you can see it created views eagerly for all widgets,
but when a widget later becomes hidden and visible the view is no longer created:
We could even optimize the code and not create the view until it’s needed:
@Directive({...})
export class ContainerDirective {
@Input('container') params;
embeddedViewRef;
item;
constructor(
public element: ElementRef,
public viewContainerRef: ViewContainerRef,
private registry: Registry
) {}
ngOnInit() {
this.item = this.params.item;
this.registry.registerContainer(this);
}
show(): void {
if (this.embeddedViewRef) {
this.viewContainerRef.insert(this.embeddedViewRef);
} else {
this.embeddedViewRef = this.params.tpl.createEmbeddedView({ item: this.params.item });
this.viewContainerRef.insert(this.embeddedViewRef);
}
}
}
and here’s the how it looks: