Deferred use of directive functionality in Angular

Recently, I had to solve the problem of changing the old mechanism for displaying tooltips, implemented by means of our component library, to a new one. As always, I decided not to invent the bicycle. In order to start solving this problem, I started looking for an open source library written in pure JavaScript that could be placed in the Angular directive and used in this form. In my case, since I work a lot with popper.js , I found the tippy.js library



written by the same developer. For me, such a library looked like the perfect solution to the problem. The tippy.js library has an extensive set of features. With its help, you can create tooltips (tooltip elements), and many other elements. These elements can be customized using themes, they are fast, strongly typed, provide accessibility to content and have many other useful features.

I started by creating a wrapper directive for tippy.js:

@Directive({ selector: '[tooltip]' })
export class TooltipDirective {
  private instance: Instance;
  private _content: string;

  get content() {
    return this._content;
  }

  @Input('tooltip') set content(content: string) {
    this._content = content;
    if (this.instance) this.instance.setContent(content);
  }

  constructor(private host: ElementRef<Element>, private zone: NgZone) {}

  ngAfterViewInit() {
    this.zone.runOutsideAngular(() => {
      this.instance = tippy(this.host.nativeElement, {
        content: this.content,
      });
    });
}

A tooltip is created by calling a function tippyand passing elements hostand to it content. In addition, we call tippyoutside the Angular Zone, since we do not need the events that are logged tippyto trigger the change detection cycle.

Now we’ll use the tooltip in a large list of 700 elements:

@Component({
  selector: 'my-app',
  template: `
    <ul>
      <li *ngFor="let item of data" [tooltip]="item.label">
         {{ item.label }}
      </li>
    </ul>
  `
})
export class AppComponent {
  data = Array.from({ length: 700 }, (_, i) => ({
    id: i,
    label: `Value ${i}`,
  }));
}

Everything works as expected. Each item displays a tooltip. But we can solve this problem better. In our case, 700 copies were created tippy. And for each element, tippy.js tools added 4 event listeners. This means that we registered 2800 listeners (700 * 4).

To see this for yourself, you can use the method getEventListenersin the Chrome Developer Tools console. The view construct getEventListeners(element)returns information about event listeners registered for a given element.


Summary of all event listeners

If you leave the code in this form, this can affect the memory consumption of the application and for the time of its first rendering. This is especially true for page output on mobile devices. Let us ponder this. Do I need to create instancestippyfor items that are not displayed in the viewport? No, it doesn `t need.

We’ll use the APIIntersectionObserverto delay the inclusion of tooltip support until the item appears on the screen. If you are not familiar with the APIIntersectionObserver, take a look at the documentation

Create for theIntersectionObserverwrapper represented by the observed object:

const hasSupport = 'IntersectionObserver' in window;

export function inView(
  element: Element,
  options: IntersectionObserverInit = {
    root: null,
    threshold: 0.5,
  }
) {
  return new Observable((subscriber) => {
    if (!hasSupport) {
      subscriber.next(true);
      subscriber.complete();
    }

    const observer = new IntersectionObserver(([entry]) => {
      subscriber.next(entry.isIntersecting);
    }, options);

    observer.observe(element);

    return () => observer.disconnect();
  });
}

We created an observable object that informs subscribers about the moment the element intersects with a given area. In addition, here we check IntersectionObserverbrowser support . If the browser does not support IntersectionObserver, we simply issue trueand exit. IE users themselves are to blame for their misery.

Now inViewwe can use the observed object in a directive that implements the tooltip functionality:

@Directive({ selector: '[tooltip]' })
export class TooltipDirective {
  ...

  ngAfterViewInit() {
    //   
    inView(this.host.nativeElement).subscribe((inView) => {
      if (inView && !this.instance) {
        this.zone.runOutsideAngular(() => {
          this.instance = tippy(this.host.nativeElement, {
            content: this.content,
          });
        });
      } else if (this.instance) {
        this.instance.destroy();
        this.instance = null;
      }
    });
  }
}

Run the code again to analyze the number of event listeners.


Summary of all event listeners after the finalization of the project

Excellent. Now we create tooltips for visible elements only.

Let's look for answers to a couple of questions related to the new solution.

Why don't we use virtual scrolling to solve this problem? Virtual scrolling cannot be used in any situations. And, in addition, the Angular Material library caches the template, as a result, the corresponding data will continue to occupy memory.

What about event delegation? To do this, you need to implement additional mechanisms yourself, in Angular there is no universal way to solve this problem.

Summary


Here we talked about how to defer the application of the functionality of directives. This allows the application to load faster and consume less memory. The tooltip example is just one of many cases in which a similar technique can be applied. I am sure you will find many ways to use it in your own projects.

And how would you solve the problem of displaying a large list of elements, each of which needs to be equipped with a tooltip?

We remind you that we are continuing the prediction contest in which you can win a brand new iPhone. There is still time to break into it, and make the most accurate forecast on topical values.


All Articles