问题
I'm new to rxjs, and I'm developing an angular multiselect list component that should render a long list of values (500+). I'm rendering the list based on an UL, I'm iterating over an observable that will render the LI's. I'm thinking about my options to avoid impacting the performance by rendering all the elements at once. But I don't know whether this is possible or not, and if it's possible what is the best operator to use.
The proposed solution:
- On init I load all the data into an Observable. (src) and I'll take 100 elements from it and will put them on the target observable (The one that will be used to render the list)
Everytime that the user reaches the end of the list (the scrollEnd event fires) I'll load 100 elements more, until there are no more values in the src observable.
The emission of new values in the target observable will be triggered by the scrollEnd event.
Find my code below, I still need to implement the proposed solution, but I'm stuck at this point.
EDIT: I'm implementing @martin solution, but I'm still not able to make it work in my code. My first step was to replicate it in the code, to get the logged values, but the observable is completing immediately without producing any values. Instead of triggering an event, I've added a subject. Everytime the scrollindEnd output emits, I will push a new value to the subject. The template has been modified to support this.
multiselect.component.ts
import { Component, AfterViewInit } from '@angular/core';
import { zip, Observable, fromEvent, range } from 'rxjs';
import { map, bufferCount, startWith, scan } from 'rxjs/operators';
import { MultiSelectService, ProductCategory } from './multiselect.service';
@Component({
selector: 'multiselect',
templateUrl: './multiselect.component.html',
styleUrls: ['./multiselect.component.scss']
})
export class MultiselectComponent implements AfterViewInit {
SLICE_SIZE = 100;
loadMore$: Observable<Event>;
numbers$ = range(450);
constructor() {}
ngAfterViewInit() {
this.loadMore$ = fromEvent(document.getElementsByTagName('button')[0], 'click');
zip(
this.numbers$.pipe(bufferCount(this.SLICE_SIZE)),
this.loadMore$.pipe(),
).pipe(
map(results => console.log(results)),
).subscribe({
next: v => console.log(v),
complete: () => console.log('complete ...'),
});
}
}
multiselect.component.html
<form action="#" class="multiselect-form">
<h3>Categories</h3>
<input type="text" placeholder="Search..." class="multiselect-form--search" tabindex="0"/>
<multiselect-list [categories]="categories$ | async" (scrollingFinished)="lazySubject.next($event)">
</multiselect-list>
<button class="btn-primary--large">Proceed</button>
</form>
multiselect-list.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'multiselect-list',
templateUrl: './multiselect-list.component.html'
})
export class MultiselectListComponent {
@Output() scrollingFinished = new EventEmitter<any>();
@Input() categories: Array<string> = [];
constructor() {}
onScrollingFinished() {
this.scrollingFinished.emit(null);
}
}
multiselect-list.component.html
<ul class="multiselect-list" (scrollingFinished)="onScrollingFinished($event)">
<li *ngFor="let category of categories; let idx=index" scrollTracker class="multiselect-list--option">
<input type="checkbox" id="{{ category }}" tabindex="{{ idx + 1 }}"/>
<label for="{{ category }}">{{ category }}</label>
</li>
</ul>
NOTE: The scrollingFinished event is being triggered by the scrollTracker directive that holds the tracking logic. I'm bubbling the event from multiselect-list to the multiselect component.
Thanks in advance!
回答1:
This example generates an array of 450 items and then splits them into chunks of 100
. It first dumps the first 100 items and after every button click it takes another 100
and appends it to the previous results. This chain properly completes after loading all data.
I think you should be able to take this and use to for your problem. Just instead of button clicks use a Subject
that will emit every time user scrolls to the bottom:
import { fromEvent, range, zip } from 'rxjs';
import { map, bufferCount, startWith, scan } from 'rxjs/operators';
const SLICE_SIZE = 100;
const loadMore$ = fromEvent(document.getElementsByTagName('button')[0], 'click');
const data$ = range(450);
zip(
data$.pipe(bufferCount(SLICE_SIZE)),
loadMore$.pipe(startWith(0)),
).pipe(
map(results => results[0]),
scan((acc, chunk) => [...acc, ...chunk], []),
).subscribe({
next: v => console.log(v),
complete: () => console.log('complete'),
});
Live demo: https://stackblitz.com/edit/rxjs-au9pt7?file=index.ts
If you're concerned about performance you should use trackBy
for *ngFor
to avoid re-rendering existing DOM elements but I guess you already know that.
回答2:
Here is a live demo on Stackblitz.
If your component subscribes to an observable holding the whole list to be displayed, your service will have to hold this whole list and send a new one every time an item is added. Here is an implementation using this pattern. Since lists are passed by reference, each list pushed in the observable is simply a reference and not a copy of the list, so sending a new list is not a costly operation.
For the service, use a BehaviorSubject
to inject your new items in your observable. You can get an observable from it using its asObservable()
method. Use another property to hold your current list. Each time loadMore()
is called, push the new items in your list, and then push this list in the subject, which will push it in the observable as well, and your components will rerender.
Here I am starting with a list holding all items (allCategories
), every time loadMore()
is called, a block of 100 items if taken and placed on the current list using Array.splice()
:
@Injectable({
providedIn: 'root'
})
export class MultiSelectService {
private categoriesSubject = new BehaviorSubject<Array<string>>([]);
categories$ = this.categoriesSubject.asObservable();
categories: Array<string> = [];
allCategories: Array<string> = Array.from({ length: 1000 }, (_, i) => `item #${i}`);
constructor() {
this.getNextItems();
this.categoriesSubject.next(this.categories);
}
loadMore(): void {
if (this.getNextItems()) {
this.categoriesSubject.next(this.categories);
}
}
getNextItems(): boolean {
if (this.categories.length >= this.allCategories.length) {
return false;
}
const remainingLength = Math.min(100, this.allCategories.length - this.categories.length);
this.categories.push(...this.allCategories.slice(this.categories.length, this.categories.length + remainingLength));
return true;
}
}
Then call the loadMore()
method on your service from your multiselect
component when the bottom is reached:
export class MultiselectComponent {
categories$: Observable<Array<string>>;
constructor(private dataService: MultiSelectService) {
this.categories$ = dataService.categories$;
}
onScrollingFinished() {
console.log('load more');
this.dataService.loadMore();
}
}
In your multiselect-list
component, place the scrollTracker
directive on the containing ul
and not on the li
:
<ul class="multiselect-list" scrollTracker (scrollingFinished)="onScrollingFinished()">
<li *ngFor="let category of categories; let idx=index" class="multiselect-list--option">
<input type="checkbox" id="{{ category }}" tabindex="{{ idx + 1 }}"/>
<label for="{{ category }}">{{ category }}</label>
</li>
</ul>
In order to detect a scroll to bottom and fire the event only once, use this logic to implement your scrollTracker
directive:
@Directive({
selector: '[scrollTracker]'
})
export class ScrollTrackerDirective {
@Output() scrollingFinished = new EventEmitter<void>();
emitted = false;
@HostListener("window:scroll", [])
onScroll(): void {
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight && !this.emitted) {
this.emitted = true;
this.scrollingFinished.emit();
} else if ((window.innerHeight + window.scrollY) < document.body.offsetHeight) {
this.emitted = false;
}
}
}
Hope that helps!
来源:https://stackoverflow.com/questions/54852013/take-n-values-from-observable-until-its-complete-based-on-an-event-lazy-loading