How can I speed up ngFor for a large array?
Asked Answered
R

2

22

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

Rotberg answered 2/6, 2016 at 19:4 Comment(5)
I'm not sure how you're displaying the data, but showing 721 things on the screen isn't necessary. You'll only be able to see a fraction of them. Can't you just page the data so it's easier to look through? So you'd only show 10 - 100 pokemon instead of 721. That would make it much faster.Gearard
and this should help you with the paginationVizor
The best optimization is to not do something at all. As mentioned, render only what needs to be displayed at once.Insufficiency
Your site is much faster. How did you end up fixing this?Merited
Two fold solution. First, unrelated to this question, I better divided up when I request what data. I reduced the initial download from ~12 MB to 35 KB by not giving you the entire pokedex up front. It only requests the basic data needed for the list, the filters, and some other stuff. The second solution is 100% relevant to this question and I'm going to post it as an answer.Rotberg
R
23

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.

Rotberg answered 31/12, 2016 at 3:17 Comment(5)
Me, I had to use [style.height.px] instead of [style.height] to get it working. Awesome solution, anyways!Click
Also, Angular throws an error, when using Math inside the template. I had to declare getters in the component to resolve this. Thanks for the simple and elegant solution!Letaletch
@Letaletch Right, I should have mentioned that. In my component that houses the column I have public Math: Math = Math; that makes it available.Rotberg
But how you will scroll down the 13200 pixels, when you are rendering only 5320 pixels at max in once?Knock
Thanks to the header and footer the 13200 pixels are always available to scroll through. Only 5320 pixels have content in them and that window of content moves with you as you scroll.Rotberg
S
0

Using async pipe also improves ngFor performance on large data set.

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

The iterable array has to be Observable. In .ts file you can defined it as below.

pokedex$: Observable<any[]>;  //define a observable variable

this.pokedex$ = of(pokedex); // convert an array into observable array

You can find the complete example on below link.

Handling observable with ngFor and async pipe

Sorbian answered 31/5, 2022 at 13:7 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.