Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I speed up ngFor for a large array?

Tags:

angular

I'm working on a Pokedex site and thanks to there now being 721 Pokemon an ngFor is taking a long time to display all the entries the first time. Once I have all the data loaded it seems to be taking ~2400ms to actually put them in the DOM.

Here's the ngFor in question:

<entry *ngFor="let p of (pokedex | filter:search:SelectedVer), let i = index, let last = last"
    [id]="'pokemon-entry-' + p.id"
    [pokemon]="p"
    [language]="SelectedLang"
    (click)="SelectPokemon(p)"></entry>

I ran a timeline in Chrome's dev tools and got something that looks like this:

Timeline of MaterialPokedex.com loading up

I don't have much experience with the timeline but it seems to me that there's way too big a block right there in the middle (the top is labeled XHR Load (/csv/pokemon_game_indices.csv)). The ajax call itself takes 0.02 ms according to the timeline. I'm assuming what makes this such a large block is the change detection that happens after the ajax request is complete. That's when I take my models that I've been building and put them in the pokedex variable that the ngFor uses above. My understanding of the timeline is that the construction of the 721 DOM elements to be added by the ngFor is taking about 2.5s to complete.

I did try un-componentizing my entry component into just the html (the component really doesn't do anything) but that doesn't seem to impact the time in any noticeable way. Removing the pipe I use to filter the list also doesn't impact the time.

Is there a way to speed up this ngFor?

I'm using Angular 2 RC1. I am enabling prod mode. I'm running this in Chrome 51.0.2704.79 m

like image 448
Corey Ogburn Avatar asked Jun 02 '16 19:06

Corey Ogburn


1 Answers

The short and sweet answer is "don't iterate over the entire array" but that wasn't good enough for me. I wanted it to look like the entire column of entries was present. So I put a spacer above, the ngFor iterates over a subsection of the array, and a spacer below and together this makes the list look like all the elements are there all the time.

Here's a simplified version of my html with only the relevant parts to this problem (full example on bitbucket):

<div (scroll)="ColScroll($event)">
  <div [style.height]="Math.max(0, Math.max(0, scrollPos - 10) * 132)"></div>
  <entry *ngFor="let p of (base.pokemon | filter:search:SelectedVer:SelectedLang) | justafew:scrollPos" [pokemon]="p"></entry>
  <div [style.height]="Math.max(0,((base.pokemon | filter:search:SelectedVer:SelectedLang).length - scrollPos - 40)) * 132"></div>
</div>

Ultra-minimal structure for absolute clarity:

<div> <!-- column -->
  <div></div> <!-- spacer -->
  <entry *ngFor='...'></entry>
  <div></div> <!-- spacer -->
</div>

First, a very key point: <entry> is always exactly 120 pixels tall with a 12 pixel bottom margin totaling 132 pixels of total space. The CSS makes this absolute. This works for whatever constant size I wanted to pick, but I make special assumptions that the size is exactly 132 pixels.

The short version is that as you scroll the column's scrollHeight determines which entries should actually be on screen. If the first 10 elements that the ngFor actually builds are off screen then the first visible element begins at number 11. I account for a 4k screen and show 40 entries (taking up 5280 pixels) to be sure that the entire column looks full. Then, so the scrollbar looks correct, I have a spacer below the 40 entries to force the div to have the proper scrollable height. Here's an image of what's visually going on:

Spacer above and below the viewable space in the list of pokemon

Here's the relevant variables and functions in the controller (bitbucket):

scrollPos = 0;
...
ColScroll(event: Event) {
  let pos = $(event.target).scrollTop();
  this.scrollPos = Math.floor(pos / 132);
}

It kills me to use jQuery here but I was already using it for something else and I needed something cross-browser. scrollPos holds the first index of the first item that I should be showing on screen.

The ngFor that actually builds all the <entry> elements looks like this:

*ngFor="let p of (base.pokemon | filter:search:SelectedVer:SelectedLang) | justafew:scrollPos"

Breaking that down:

base.pokemon is an array of the pokemon data necessary to create each entry element.

... | filter:search:SelectedVer:SelectedLang) is used for searching through the list. I leave it in my sample here to show that you can still play with the list before my hack comes into play.

... | justafew:scrollPos is where the magic happens. Here's that filter in it's entirety (bitbucket):

import { Pipe, PipeTransform } from '@angular/core';

import { MinPokemon } from '../models/base';

@Pipe({
  name: 'justafew',
  pure: false
})
export class JustAFewPipe implements PipeTransform {
  public transform(value: MinPokemon[], start: number): MinPokemon[] {
    return value.slice(Math.max(0, start - 10), start + 30);
  }
}

scrollPos was passed in as the start parameter. For example, if I've scrolled 13200 pixels down my column then scrollPos would be set to 100 (see the scrolling event in the controller above). This will slice the array so that it returns elements 90 through 130. I want to overflow the screen a little to ensure that fast scrolling won't result in visible white space (insanely fast scrolling might still show it but you're moving so fast it's easy to think that the browser simply hasn't rendered that fast so I let it slide). I use Math.max so I don't slice using negative numbers such as when I'm at the very top of the list and scrollPos is 0.

Now the spacers. They keep the scrollbar honest. I bind their [style.height] and use a little math to make these spacers take up the required space. As I scroll down, the top spacer grows taller and the bottom spacer shrinks by the exact same amount so the column is always the same height. When I scroll back up the math works out just the opposite: the top shrinks and the bottom grows. The bottom spacer uses the exact same filter logic as the ngFor so that if I run a search that returns 100 instead of 721 pokemon it adjusts to the height of 100 entries. The first spacer using scrollPos - 10 because the justafew filter goes back 10. For the same reason, the bottom spacer uses scrollPos - 30 because that's how many justafew returns.

I know it looks like a lot of moving parts but they're all simple and quick. Unfortunately there are a lot of "magic numbers" all over the place that rely on each other but considering the performance improvements and reliability this gave me over showing the entire list I let it slide. Maybe someday I'll make a component or directive to put it all in one configurable place.

UPDATE: 2 1/2 years or so later and with Angular 7's release there's now a Angular Material package for virtual scrolling. I made a few changes to my site and got virtual scrolling working in about an hour. Even with component recycling. I thoroughly recommend using Angular Material for virtual scrolling.

like image 175
Corey Ogburn Avatar answered Nov 20 '22 16:11

Corey Ogburn