使用 RxJs 实现一个支持 infinite scroll 的 Angular Component

举报
Jerry Wang 发表于 2022/10/03 09:25:07 2022/10/03
【摘要】 首先看看我这个支持 infinite scroll 的 Angular 应用的运行时效果:https://jerry-infinite-scroller.stackblitz.io/滚动鼠标中键,向下滚动,可以触发 list 不断向后台发起请求,加载新的数据:下面是具体的开发步骤。(1) app.component.html 的源代码:<div> <h2>{{ title }}</h2> ...

首先看看我这个支持 infinite scroll 的 Angular 应用的运行时效果:

https://jerry-infinite-scroller.stackblitz.io/

滚动鼠标中键,向下滚动,可以触发 list 不断向后台发起请求,加载新的数据:

下面是具体的开发步骤。

(1) app.component.html 的源代码:

<div>
  <h2>{{ title }}</h2>
  <ul
    id="infinite-scroller"
    appInfiniteScroller
    scrollPerecnt="70"
    [immediateCallback]="true"
    [scrollCallback]="scrollCallback"
  >
    <li *ngFor="let item of news">{{ item.title }}</li>
  </ul>
</div>

这里我们给列表元素 ul 施加了一个自定义指令 appInfiniteScroller,从而为它赋予了支持 infinite scroll 的功能。

[scrollCallback]=“scrollCallback” 这行语句,前者是自定义执行的 input 属性,后者是 app Component 定义的一个函数,用于指定当 list 的 scroll 事件发生时,应该执行什么样的业务逻辑。

app component 里有一个类型为集合的属性 news,被 structure 指令 ngFor 展开,作为列表行项目显示。

(2) app Component 的实现:

import { Component } from '@angular/core';
import { HackerNewsService } from './hacker-news.service';

import { tap } from 'rxjs/operators';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  currentPage: number = 1;
  title = '';
  news: Array<any> = [];

  scrollCallback;

  constructor(private hackerNewsSerivce: HackerNewsService) {
    this.scrollCallback = this.getStories.bind(this);
  }

  getStories() {
    return this.hackerNewsSerivce
      .getLatestStories(this.currentPage)
      .pipe(tap(this.processData));
    // .do(this.processData);
  }

  private processData = (news) => {
    this.currentPage++;
    this.news = this.news.concat(news);
  };
}

把函数 getStories 绑定到属性 scrollCallback 上去,这样当 list scroll 事件发生时,调用 getStories 函数,读取新一页的 stories 数据,将结果合并到数组属性 this.news 里。读取 Stories 的逻辑位于 hackerNewsService 里完成。

(3) hackerNewsService 通过依赖注入的方式被 app Component 消费。

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

const BASE_URL = 'https://node-hnapi.herokuapp.com';

@Injectable()
export class HackerNewsService {
  constructor(private http: HttpClient) {}

  getLatestStories(page: number = 1) {
    return this.http.get(`${BASE_URL}/news?page=${page}`);
  }
}

(4) 最核心的部分就是自定义指令。

import { Directive, AfterViewInit, ElementRef, Input } from '@angular/core';

import { fromEvent } from 'rxjs';

import { pairwise, map, exhaustMap, filter, startWith } from 'rxjs/operators';

interface ScrollPosition {
  sH: number;
  sT: number;
  cH: number;
}

const DEFAULT_SCROLL_POSITION: ScrollPosition = {
  sH: 0,
  sT: 0,
  cH: 0,
};

@Directive({
  selector: '[appInfiniteScroller]',
})
export class InfiniteScrollerDirective implements AfterViewInit {
  private scrollEvent$;

  private userScrolledDown$;

  // private requestStream$;

  private requestOnScroll$;

  @Input()
  scrollCallback;

  @Input()
  immediateCallback;

  @Input()
  scrollPercent = 70;

  constructor(private elm: ElementRef) {}

  ngAfterViewInit() {
    this.registerScrollEvent();

    this.streamScrollEvents();

    this.requestCallbackOnScroll();
  }

  private registerScrollEvent() {
    this.scrollEvent$ = fromEvent(this.elm.nativeElement, 'scroll');
  }

  private streamScrollEvents() {
    this.userScrolledDown$ = this.scrollEvent$.pipe(
      map(
        (e: any): ScrollPosition => ({
          sH: e.target.scrollHeight,
          sT: e.target.scrollTop,
          cH: e.target.clientHeight,
        })
      ),
      pairwise(),
      filter(
        (positions) =>
          this.isUserScrollingDown(positions) &&
          this.isScrollExpectedPercent(positions[1])
      )
    );
  }

  private requestCallbackOnScroll() {
    this.requestOnScroll$ = this.userScrolledDown$;

    if (this.immediateCallback) {
      this.requestOnScroll$ = this.requestOnScroll$.pipe(
        startWith([DEFAULT_SCROLL_POSITION, DEFAULT_SCROLL_POSITION])
      );
    }

    this.requestOnScroll$
      .pipe(
        exhaustMap(() => {
          return this.scrollCallback();
        })
      )
      .subscribe(() => {});
  }

  private isUserScrollingDown = (positions) => {
    return positions[0].sT < positions[1].sT;
  };

  private isScrollExpectedPercent = (position) => {
    return (position.sT + position.cH) / position.sH > this.scrollPercent / 100;
  };
}

首先定义一个 ScrollPosition 接口,包含三个字段 sH
, sT 和 cH,分别维护 scroll 事件对象的三个字段:scrollHeight,scrollTop 和 clientHeight.

我们从施加了自定义指令的 dom 元素的 scroll 事件,构造一个 scrollEvent$ Observable 对象。这样,scroll 事件发生时,scrollEvent$ 会自动 emit 出事件对象。

因为这个事件对象的绝大多数属性信息,我们都不感兴趣,因此使用 map 将 scroll 事件对象映射成我们只感兴趣的三个字段:scrollHeight, scrollTop 和 clientHeight:

但是仅仅有这三个点的数据,我们还无法判定当前 list 的 scroll 方向。

所以使用 pairwise 这个 rxjs 提供的操作符,将每两次点击生成的坐标放到一个数组里,然后使用函数 this.isUserScrollingDown 来判断,当前用户 scroll 的方向。

如果后一个元素的 scrollTop 比前一个元素大,说明是在向下 scroll:

  private isUserScrollingDown = (positions) => {
    return positions[0].sT < positions[1].sT;
  };

我们并不是检测到当前用户向下 scroll,就立即触发 HTTP 请求加载下一页的数据,而是得超过一个阀值才行。

这个阀值的实现逻辑如下:

private isScrollExpectedPercent = (position) => {
    console.log('Jerry position: ', position);
    const reachThreshold =
      (position.sT + position.cH) / position.sH > this.scrollPercent / 100;
    const percent = ((position.sT + position.cH) * 100) / position.sH;
    console.log('reach threshold: ', reachThreshold, ' percent: ', percent);
    return reachThreshold;
  };

如下图所示:当阀值到达 70 的时候,返回 true:

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。