反应性无限卷轴
#javascript #网络开发人员 #angular #rxjs

在尝试加载大量数据时,您是否曾经在网页上经历过缓慢的加载或滞后?如果是这样,您并不孤单。改善体验的有效解决方案是使用无限滚动,该滚动类似于您的Twitter在向下滚动时如何连续加载更多的推文。

What is Infinity Scroll

一种网页设计技术,当用户向下滚动页面时,更多内容会自动且连续地加载在底部,从而消除了用户需要单击下一页的内容。

向下滚动以滚动到resultsee the complete code

问题

无限滚动通常出于一些关键原因:

  • 数据获取:一次加载大数据集都可能导致延迟问题,甚至浏览器崩溃。

  • 移动可用性:在移动平台上,滚动比在多个页面上导航更直观。

  • 资源优化:增量数据加载通常更具资源效率,可以减少服务器和客户端的负载,这可以导致更快的负载时间和更好的用户体验。

解决方案

您将使用RXJS构建最小而高效的功能。它将包括:

  • 支持垂直滚动
  • LTR和RTL的水平滚动支持
  • 确定何时获取更多数据的阈值
  • Loading state

写作假设您对RXJ有基本的理解。不过,不用担心,我将在此过程中解释任何特殊的代码或RXJS功能。因此,请准备好,因为您将要潜入一些RXJS操作员! ð

对于那些已经适应RXJ,you can skip the next sectionjump to The Code

的人

好吧,让我们开始!

入门

您唯一需要开始的是RXJ。使用此命令安装它:

npm i rxjs

注意:我主要使用打字稿来清晰显示通过类型可用的选项。您可以自由省略它们,但是如果您想使用类型,我建议您选择一个具有内置打字稿支持的框架。

RXJS操作员

RXJS运算符是操纵和转换可观察到的序列的函数。这些操作员可用于过滤,组合,投影或执行可观察到的事件序列的其他操作。

常见的RXJS操作员

有很多,大多数(由我使用)是tapmapfilterswitchMapfinalize。您可能已经知道如何使用这些,但是幸运的是,我们将了解其他有用的操作员!

看以下可观察的内容:

const source$ = from([1, 2, 3, 4, 5]);
source$.subscribe(event => console.log(event));

结果将是1 2 3 4 5.-在新线路中播放 -

筛选

仅记录奇数

const source$ = from([1, 2, 3, 4, 5]);
source$
  .pipe(filter(event => event % 2))
  .subscribe(event => console.log(event));

说,source$可能会发出null值。您可以使用filter阻止它通过其余序列。

const source$ = from([1, 2, 3, null, 5]);
source$
  .pipe(filter(event => event !== null))
  .subscribe(event => console.log(event));

地图

要更改事件的顺序,您可以使用map操作员。

source$
  .pipe(map(event => (event > 3 ? `Large number` : "Good enough")))
  .subscribe(event => console.log(event));

如果我想检查事件而不更改源序列

该怎么办

轻敲

source$
  .pipe(
    tap(event => {
      logger.log("log an event in the console");
      // you can perform any operation as well, however return statment are ignore in tap function
    })
  )
  .subscribe(event => console.log(event));

最终确定

要监视可观察到的生命周期的末端,您可以使用finalize操作员。可观察到的完成后,它会触发。

例如,它通常用于执行一些清理操作,停止加载动画或调试内存,例如,添加日志语句以确保可观察到的可观察到完整并且不会粘在内存中。

debouncetime

想象一下,您正在构建登录表单,并且在输入其密码的用户时,您要击中后端服务器以确保密码符合某些条件。

condt source$ = fromEvent(passwordInput, 'input').pipe(
  map((event) => passwordInput.value),
  switchMap((password) => checkPasswordValidaity(password))
)
source$.subscribe(event => console.log(event));

此示例可能会与一个关键警告一起工作;在每次击键上,都会向后端服务器发送请求,这要归功于switchMap,它将取消以前的请求,因此可能不会造成太大的损害,但是,使用debounceTime,您可以忽略input事件,直到dueTime -argument-- argument-通过。<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< br>

const source$ = fromEvent(passwordInput, 'input').pipe(
  debounceTime(2000)
  map((event) => passwordInput.value),
  switchMap((password) => checkPasswordValidaity(password))
)
source$.subscribe(event => console.log(event));

添加debounceTime本质上意味着在每种键手术之间创建2秒钟,因此用户输入“ Hello”,然后在2秒之前进入“ World”,并且只会发送一个请求。换句话说,每个事件必须距离上一个事件有2秒的距离。

从...开始

可观察的可能没有立即具有价值,您需要一个可用于新的source$订户的事件。

const defaultTimezone = '+1'
condt source$ = fromEvent(timezoneInput, 'input').pipe(
  map((event) => timezoneInput.value),
  startWith(defaultTimezone)
)
source$.subscribe(event => console.log(event));

即使从未输入timezoneInput值,该示例也会立即记录“ +1”

fromevent

您可以将上一个示例重写如下

const timezoneInputController = new Subject<string>();
const timezoneInputValue$ = timezoneInputController.asObservable();
timezoneInput.addEventListener("input", () =>
  subject.next(timezoneInputController.value)
);

const source$ = timezoneInputValue$.pipe(
  map(event => event.target.value),
  startWith(defaultTimezone)
);
source$.subscribe(event => console.log(event));

多亏了RXJS,您可以使用fromEvent封装样板,您需要做的就是说要聆听哪个事件以及从哪些元素中聆听。当然,fromEvent返回可观察的ð

情况

我承认这可能很难消化,这是对我的。以相同的上一个示例为例,假设您有一个表单,输入和提交按钮。当用户单击“提交”按钮时,您要停止收听timezoneInput Element input事件。是的,takeUntil听起来时,它可以让订户采取事件,直到提供的可观察到的发射至少一次为止。

const formSubmission$ = fromEvent(formEl, 'submit')

const defaultTimezone = '+1'
condt source$ = fromEvent(timezoneInput, 'input').pipe(
  map((event) => timezoneInput.value),
  startWith(defaultTimezone)
)
// normally, this subscriber will keep logging the event even if the users clicked on the submit button
source$.subscribe(event => console.log(event));

// Now, once the submit button are clicked the subscriber subscription will be canceled
source$
  .pipe(takeUntil(formSubmission))
  .subscribe(event => console.log(event));

管道

RXJS中的管道函数是用于在可观察物上组成操作的实用程序。使用它以可读的方式将多个操作员链在一起,或创建可重复使用的自定义操作员。当源序列要复杂时,这至关重要。

import { pipe } from "rxjs"; // add it to not to be confused with Observable.pipe

// Create a reusable custom operator using `pipe`
const doubleOddNumbers = pipe<number>(
  filter(n => n % 2 === 1),
  map(n => n * 2)
);

const source$ = from([1, 2, 3, 4, 5]);

source$.pipe(doubleOddNumbers).subscribe(x => console.log(x));
// result: 1, 6, 10

平坦的操作员

有时,您需要在每个传入的事件中获取一些数据,例如后端服务器。有几种方法可以做到。

switchmap

像常规地图一样,switchMap运算符使用一个项目函数,该项目函数返回可观察到的第一个参数 - 被称为内部观察值。当发生事件时,switchMap会订阅此内部可观察的可观察,创建订阅,该订阅持续到内在可观察到完成为止。如果新事件在上一个内部观察到完成之前到达,则switchMap取消现有订阅并开始新的订阅。换句话说,它切换到新订阅。

const source$ = from([1, 2, 3, 4, 5]);

function fetchData(id: number) {
  return from(fetch(`https://jsonplaceholder.typicode.com/todos/{id}`));
}

source$
  .pipe(switchMap(event => fetchData(event)))
  .subscribe(event => console.log(event));

在此示例中,只有ID 5的TODO才会记录,因为switchMap可以通过切换的优先级,如上所述。 from([...])将立即互相发射事件,因此switchMap将在不考虑上一个事件的情况下,将其切换为下一个事件内部可观察的事件。开关操作本质上是指从以前的内部观察值中取消订阅,并订阅了新的。

辅助图

除非内部可观察到完成,否则它会阻止新事件通过源序列。它对于数据库编写操作或动画/移动元素特别有用,在开始另一个动作之前,重要的是
很重要。

source$
  .pipe(concatMap(event => fetchData(event)))
  .subscribe(event => console.log(event));

此样本将按顺序记录所有戒酒。从本质上讲,发生的是concatMap阻止源序列,直到可观察到的内部可观察到。

合并

它不会取消先前的订阅,也不会阻止源序列。 mergeMap将不考虑其完成的内部观察订阅,因此,如果事件发生并且先前的内部观察值尚未完成,但这还不错,mergeMap将无论如何都可以订阅内部观察。


source$
  .pipe(mergeMap(event => fetchData(event)))
  .subscribe(event => console.log(event));

此示例将记录所有毒品,但按照不确定的顺序,第二个请求可能会在第一个请求之前解决,而mergeMap不在乎该顺序,如果这很重要,请使用concatMap

排气板

最后一篇文章中最重要的是exhaustMap:它就像switchMap,但有一个关键区别。与switchMap相反,它忽略了最近的内部可观察到的事件,该事件与switchMap相反,switchMap取消了先前的内部可观察订阅,而有利于新的订阅。

source$
  .pipe(exhaustMap(event => fetchData(event)))
  .subscribe(event => console.log(event));

此示例只会记录第一个待办事项,因为第一个待办事项请求尚未完成。

总结

  1. switchMap将在新事件到达时取消订阅现有订阅(如果尚未完成的内部观察值尚未完成)。
  2. concatMap将阻止源序列,因此必须在允许其他事件流动之前完成手头上的内部观察序列。
  3. mergeMap不在乎内部观察的状态,因此随着事件的发生,它将订阅内部可观察的。
  4. exhaustMap将忽略任何事件,直到当前内部观察到完成为止。

好吧,这很多,不是吗?我知道,如果您是RXJ的新手,您可能无法消化所有这些信息,最好的选择就是练习,这就是您要在这里做的。

哇,我真的做到了,你也做了ð

是时候谈论一些滚动API(S)


滚动API

您已经知道滚动条,它位于页面的右端ð¥ˆ,不,当用户朝任何方向滚动时,浏览器会发出一些事件,例如scrollscrollendwheel

您将学习足够的学习,以解决手头的问题。

让我们从scrollscrollend开始:

滚动和滚动事件

scroll事件在滚动滚动时发射元素,滚动完成后scrollend发射。

element.addEventListener("scroll", () => {
  console.log(`I'm being scrolled`);
});

element.addEventListener("scrollend", () => {
  console.log(`User stopped scrolling`);
});

请记住,这仅在具有事件侦听器(处理程序)的元素可以滚动时起作用,而不是其父母或任何祖先或后代元素。

车轮事件

wheel事件在使用鼠标/触控板 wheel 滚动时发射元素或任何孩子,这意味着尝试使用键盘向下滚动/向上滚动不会触发它。

尺寸属性

对于手头的任务,滚动事件将是主要重点。但是,我还概述了一些其他事件和属性,可以为您提供全面的理解。现在,让我们看一下您需要了解的关键尺寸属性:

  • element.clientWidth:元素的内部宽度,不包括边界和滚动条。
  • element.scrollWidth:内容的宽度,包括屏幕上不可见的内容。如果该元素不可水平滚动,则与clientWidth相同。
  • element.clientHeight:元素的内部高度,不包括边界和滚动条。
  • element.scrollHeight:内容的高度,包括屏幕上不可见的内容。如果该元素不能垂直滚动,则与clientHeight相同。
  • element.scrollTop:垂直滚动元素内容的像素数。

注意:当我说“内容”时,我的意思是HTML元素中包含的整个内容。

Client-Height-Scroll-Height.

绿色框是元素,而左侧的黑框是宽度溢出 右边是高度溢出

让我们以下面的示例,计算从用户当前滚动位置到可滚动元素末尾的剩余像素。

function calculateDistanceFromBottom(element: HTMLElement) {
  const scrollPosition = element.scrollTop;
  const clientHeight = element.clientHeight;
  const totalHeight = element.scrollHeight;
  return totalHeight - (scrollPosition + clientHeight);
}

查看下图。

Scroll-Top.

卷轴指示用户滚动
的意义

假定totalHeight500pxclientHeight 300pxscrollPosition100px,从totalHeight扣除scrollPositionclientHeight的总和,将导致100px导致100px导致其剩余距离,以达到元素的剩余距离。 在计算剩余距离到末端时的类似公式

function calculateRemainingDistanceOnXAxis(element: HTMLElement): number {
  const scrollPosition = Math.abs(element.scrollLeft);
  const clientWidth = element.clientWidth;
  const totalWidth = element.scrollWidth;
  return totalWidth - (scrollPosition + clientWidth);
}

假定totalWidth750pxclientWidth 500pxscrollPosition150px,从totalWidth扣除scrollPositionclientWidth的总和,将导致100px导致100px导致其余距离达到Xaxis。 您可能已经注意到使用了Math.abs,并且由于用户必须朝相反方向行驶的RTL方向,这将使scrollPosition值为负面,因此使用Math.abs将其沿两个方向统一。

Scroll-XAxis.

侧面提示:使用有关元素尺寸的信息,您还可以发挥功能来检查元素是否可以滚动。

type InfinityScrollDirection = "horizontal" | "vertical";

function isScrollable(
  element: HTMLElement,
  direction: InfinityScrollDirection = "vertical"
) {
  if (direction === "horizontal") {
    return element.scrollWidth > element.clientWidth;
  } else {
    return element.scrollHeight > element.clientHeight;
  }
}

简单地说,如果元素滚动的大小与客户端的大小相同,则不可滚动。

代码

我知道您一直在寻找本节,最后,我们将所有的学习付诸实践,让我们从创建一个名为infinityScroll的函数,该函数接受options参数

export interface InfinityScrollOptions<T> {
  /**
   * The element that is scrollable.
   */
  element: HTMLElement;
  /**
   * A BehaviorSubject that emits true when loading and false when not loading.
   */
  loading: BehaviorSubject<boolean>;
  /**
   * Indicates how far from the end of the scrollable element the user must be
   * before the loadFn is called.
   */
  threshold: number;
  /**
   * The initial page index to start loading from.
   */
  initialPageIndex: number;
  /**
   * The direction of the scrollable element.
   */
  scrollDirection?: InfinityScrollDirection;
  /**
   * The function that is called when the user scrolls to the end of the
   * scrollable element with respect to the threshold.
   */
  loadFn: (result: InfinityScrollResult) => ObservableInput<T>;
}

function infinityScroll<T extends any[]>(options: InfinityScrollOptions<T>) {
  // Logic
}

如所承诺的,您现在可以根据自己的喜好自定义无限滚动功能。接下来,您将学习如何将事件侦听器附加到包含您无限可滚动物品列表的特定元素。

function infinityScroll<T extends any[]>(options: InfinityScrollOptions<T>) {
  return fromEvent(options.element, "scroll").pipe(
    startWith(null),
    ensureScrolled,
    fetchData
  );
}
  • fromEvent聆听可滚动元素的scroll事件。
  • startsWith启动了源序列以获取第一批数据。
  • ensureScrolled是可链的操作员,确认滚动位置在继续之前超过预定义的阈值。
  • fetchData是另一个可链式操作员,它基于pageIndex获取数据,稍后再详细介绍。

确保滚动

const ensureScrolled = pipe(
  filter(() => !options.loading.value), // ignore scroll event if already loading
  debounceTime(100), // debounce scroll event to prevent lagginess on heavy scroll pages
  filter(() => {
    const remainingDistance = calculateRemainingDistance(
      options.element,
      options.scrollDirection
    );
    return remainingDistance <= options.threshold;
  })
);

function calculateRemainingDistance(
  element: HTMLElement,
  direction: InfinityScrollDirection = "vertical"
) {
  if (direction === "horizontal") {
    return calculateRemainingDistanceOnXAxis(element);
  } else {
    return calculateRemainingDistanceToBottom(element);
  }
}
  • filter仅在元素可滚动时通过滚动事件,否则,它可能导致意外行为。
  • debounceTime将跳过任何事件,在我们的情况下,滚动序列的滚动事件
  • filter正在检查remainingDistance是否到底部(如果垂直滚动)或在水平滚动的情况下到Xaxis的末端小于threshold。假定threshold为100px,然后当滚动位置在100像素内到达末端(垂直或水平,取决于配置)时,将调用LoadMore.next(),表明应加载更多内容。
  • >

fetchdata

const fetchData = pipe(
  exhaustMap((_, index) => {
    options.loading.next(true);
    return options.loadFn({
      pageIndex: options.initialPageIndex + index,
    });
  }),
  tap(() => options.loading.next(false)),
  // stop loading if error or explicitly completed (no more data)
  finalize(() => options.loading.next(false))
);
  • exhaustMap忽略了任何事件,直到loadFn完成。如果调用了exhaustMap项目函数(其第一个参数),则意味着前一个(如果有)可观察到的可观察到并准备接受新事件 - 加载更多数据 - 。
  • tap正在发出信号数据加载。
  • 在我们的情况下,finalize与TAP相同,但是,如果loadFn-重新要求后端服务器响应错误,则不会调用tap,并且如果出现错误,则源可观察到的源可观察到finalize。换句话说,如果源序列错误或用户明确完成了源,则停止加载。

注意exhaustMap如何指示加载状态。您可能会质疑为什么在exhaustMap之前不将加载信号放在tap运算符中。这样做会导致可观察到的负载在加载触发时发出真实。但这并不一定意味着是时候加载更多数据了 - loadFn的先前内部可观察到尚未完成 - 。为了避免这种情况,exhaustMap用于确认已准备好加载更多数据。

真正的代码;增加页面索引以获取下一个数据补丁

exhaustMap((_, index) => {
  // ...code
  return options.loadFn({
    pageIndex: options.initialPageIndex + index,
    // ...code
  });
});

exhaustMap项目功能有两个参数

  1. 来自源序列的事件。
  2. 该索引对应于最新事件(此数字表示最新事件的位置)。

在这种特定情况下,您将专注于事件位置或index。查看以下示例,以更清楚地了解其运作方式。

  • 第一次来源loadMore排放index将为零。
  • 加载了3批数据,因此下一个index为4。
  • 假设initialPageIndex为1,并且要首次加载数据,则pageIndex为1
  • 假设initialPageIndex为1,并且将第五次加载数据,那么pageIndex为6
  • 假设initialPageIndex为4,并且要首次加载数据,则pageIndex为4

最后一个案件可能会关闭;通常,您可能有initialPageIndex 0,但是假设您正在滚动Twitter feed,并且由于某种原因,浏览器重新加载了,因此您不是从一开始就加载数据,而是决定将pageIndex存储在某些状态(url查询字符串)(url查询字符串)因此,在这种情况下,只有最后一个pageIndex的数据才会存在,因此体验会继续,好像什么都没有发生。 先前的数据也需要通过将其加载到pageIndex或实现相反的滚动方向数据加载ð¥²

来存在。

使用koude29koude3koude28怎么样?您可能已经考虑过了!

给定以下方案:用户滚动到页面末尾,但是加载更多数据的请求仍在待处理。用户不断向下滚动到页面末尾,但是数据尚未解决。您认为会发生什么?

注意:以下录音使用慢3G网络速度

使用Mergemap

在每个滚动事件中,mergeMap订阅了内部观察值,而无需考虑以前的订阅,本质上会导致新的请求-loadmode-与每个经过验证的滚动事件-below -below the The The Threshold-

Edit in CodePen

Issues with using merge map.

使用switchmap

在每个滚动事件中,switchMap将取消/取消订阅上一个订阅并再次订阅内部观察值,从本质上导致了新的请求,但是先前的未解决的请求将被取消,因此只有一个请求将在一次待定。但是,这可能没关系,事件位置index每次switchMap订阅中都会增加内部观察值,这会导致加载不正确的数据。

Edit in CodePen

Issues with using switch map.

使用ConcatMap

在每个滚动事件中,concatMap将订阅内部可观察的可观察序列,阻止源序列,直到当前订阅完成-Loadmore请求解决 - 基本上导致新的请求,并将每个经过验证的滚动持有,但将它们固定在上新事件。事件位置index每次concatMap订阅内部观察值时都会增加,这会导致要求更多的数据。请参阅下面的录制,并很好地了解用户停止滚动时的网络点击的情况。

Edit in CodePen

Issues with using concat map.

使用ExaustMap

在这种情况下,它是赢家,因为它有效地管理了未决的请求。当滚动事件触发新的请求时,排气板将忽略任何后续的滚动事件,直到当前请求(内部可观察到)完成为止。这样可以确保一次只有一个请求,并且它可以防止索引不正确地增加。


话虽如此,一个简单的解决方法是即使在数据加载时也要明确忽略任何滚动。

const fetchData = pipe(
  filter(() => options.loading.value === false),
  // mergeMap, switchMap and concatMap should work now.
  exaustMap((_, index) => {
    // ...
  })
  // ...
);

但是,这种方法有限制。由于options.loading是一个可观察到的用户定义的,因此用户可能会更改其值。如果发生这种情况,问题将会出现。

例子

垂直滚动

水平滚动

RTL水平滚动

UX和可访问性考虑

无限滚动不是一个神奇的修复。我知道有些人强烈建议不要使用它。原因是:

  1. 对键盘用户不利:如果您使用键盘来绕过网站,那么无限的滚动会弄乱并使您陷入困境。特别是如果无限滚动是导航网站

  2. 很难在您关闭的位置上接下来:没有页码,很难回到您的位置。这使用户难以实现用户的头痛。

  3. 无法到达的内容:使某些像页脚这样的内容很难到达。

  4. 令人困惑的屏幕读取器:如果某人使用屏幕读取器,则常数加载会使页面结构混淆。

  5. 太多,太快了:对于某些人来说,像那些容易分心的人一样,永无止境的内容流可能会压倒性。 这只是我的看法,但这是要考虑的东西。

构建无限滚动时,您已经考虑了重要因素,例如:

  1. 正确放置内容并使其像页脚一样可以访问,并联系信息。
  2. 允许用户返回他们以前的位置。
  3. 提供跳跃的能力。
  4. 确保仅依靠键盘的用户可以导航体验。

我认识到这些任务带来了重大的发展挑战。但是,俗话说,质量是有代价的。

这并不是说无限的滚动是不好的。相反,重点是谨慎应用它。

其他分页策略

  • 传统分页:此方法结合了编号的分页和“上一个”/“下一个”按钮,可以提供特定和顺序的页面访问。

  • “加载更多”按钮:在可见内容末尾包含一个按钮;单击它将其他项目附加到列表。

  • 内容细分:利用选项卡或过滤器对内容进行分类,从而使快速导航到特定于主题的数据,例如,将推文分为科学,技术,Angular,2021等,等等。

下一步

除了核心功能外,还可以纳入进一步的增强

  1. 简历旅程:存储pageIndex恢复用户旅程的选项,例如历史API。
  2. 错误恢复:操作失败时重试加载数据。尽管我认为它不应该是Infinity Scroll功能的一部分,但您可以作为选项提供。
  3. 滚动时加载更多数据:想象一下您导航到配置文件页面,然后像在Twitter上一样回到feed。最后一页索引可以保存在历史API中,指导接下来要获取什么。但是,如果您不能一次加载所有早期数据怎么办?在这种情况下,您还可以在用户滚动时加载更多内容,而不仅仅是向下滚动时。
  4. 通过集成虚拟滚动仅渲染可见元素来提高性能。

概括

恭喜!您已经学会了如何实施无限的滚动,并对此功能的RXJS操作员有了深刻的了解。除了技术方面,您还对无限滚动带来的潜在可访问性挑战进行了批判性,为您提供了平衡的优点和缺点。

此实现是框架 - 不可能的,仅需要RXJ作为依赖项。虽然Typescript用于类型安全,但这并不是一个很难的要求,并且可以轻松省略。

请继续关注虚拟卷轴的即将发布的帖子。确保关注我在发布时得到通知。您的反馈和意见受到很高的重视,因此请随时分享。

资源