使用 Angular 实现可变高度内容的虚拟滚动
您花费了宝贵的时间,使用 Angular 精心打造了一个高度可变的列表。现在,您只剩下添加虚拟滚动支持这一项任务了。您安装并集成了@angular/cdk/scrollingAngular CDK,却发现它只能处理固定高度的列表项。经过一番搜索,您找到了这篇文章。希望读完之后,您能拥有一个完美运行且支持虚拟滚动的列表。
利用 @angular/cdk
显然,我们不会重新发明轮子,自己实现虚拟滚动。当然,这也是一种选择,但我们更希望使用已被证明行之有效的现有工具,而 Angular CDK 实际上为此提供了非常强大的工具。我们将要实现的是一个自定义的虚拟滚动VirtualScrollStrategy。但在开始之前……
@angular/cdk-experimental
值得一提的是,CDK 团队目前正在开发一种自动调整大小的策略,它是 Angular CDK 实验版本的一部分。您或许能用它取得非常好的效果,但请注意,截至本文撰写之时,该策略尚未准备好用于生产环境(官方文档)。因此,您可能会遇到一些意想不到的情况。简而言之,您可以尝试一下,看看它是否能达到您想要的效果。好了,废话不多说,让我们把重点转移到自定义策略上来。
章节
我觉得这样做合乎逻辑,所以把文章分成了几个章节。如果要概括的话,我们将要:
- 提出我们的问题
- 解释什么
VirtualScrollStrategy是a以及如何利用它 - 运用该策略
- 专注于内部实现
我们将尽量快速地略过其中一些事项,因为细节可能并不重要,但掌握这些要点对于您理解最终产品中各个环节的衔接至关重要。正如列表所示,实际的策略实施将在最后一章进行探讨。
⚠️注意:如果您只是快速浏览章节并盲目复制代码示例,最终可能会得到一个无法正常运行的解决方案。如果您时间紧迫且不打算阅读文本,可以直接前往最终代码部分,浏览代码库(代码已附有文档说明)。
为了方便起见,这里提供一些有用的内容:
内容
一、案例研究——Hero Feed
如果你开发过 Angular 应用,很可能在官方文档中接触过 Hero 教程。因此,为了与此保持一致,我将构建一个 Hero 信息流。本质上,这只是一个假设的信息流,其中包含随机生成的数据,旨在模拟真实世界中的一个示例。
为此,我们来介绍一下这个HeroMessage接口。消息将代表我们的列表项:
export interface HeroMessage {
id: string;
name: string;
date: Date;
text: string;
tags: string[];
}
……这就是它在应用程序中的显示效果:
接下来,我将创建一个组件来渲染这些数据,并将其命名为`<component_name>` HeroMessageComponent。为了节省时间,我将省略细节,因为它们与我们的主要目标关系不大。
💾:您可以在GitHub上查看完整的组件代码。
现在,我们来看看策略界面。
二、VirtualScrollStrategy那是什么?
VirtualScrollStrategy它代表一个我们需要实现的接口,用于描述我们期望的滚动行为,更具体地说,是定义哪些项目应该在视口中渲染。实际上,正如cdk-virtual-scroll-viewport前面提到的,标准组件处理的是固定大小的项目,而实验性的 CDK 组件处理的是可变大小的项目。这两种模式是不同的VirtualScrollStrategy组件,分别是 `<div>` FixedSize和Autosize`<div>`。让我们来看看这些方法:
interface VirtualScrollStrategy {
/** Emits when the index of the first element visible in the viewport changes. */
scrolledIndexChange: Observable<number>;
/**
* Attaches this scroll strategy to a viewport.
* @param viewport The viewport to attach this strategy to.
*/
attach(viewport: CdkVirtualScrollViewport): void;
/** Detaches this scroll strategy from the currently attached viewport. */
detach(): void;
/** Called when the viewport is scrolled (debounced using requestAnimationFrame). */
onContentScrolled(): void;
/** Called when the length of the data changes. */
onDataLengthChanged(): void;
/** Called when the range of items rendered in the DOM has changed. */
onContentRendered(): void;
/** Called when the offset of the rendered items changed. */
onRenderedOffsetChanged(): void;
/**
* Scroll to the offset for the given index.
* @param index The index of the element to scroll to.
* @param behavior The ScrollBehavior to use when scrolling.
*/
scrollToIndex(index: number, behavior: ScrollBehavior): void;
}
通常情况下,我们不需要实现所有这些功能,除非您需要使用某些省略的功能。细节暂时先不赘述。首先,让我们实现接口并将自定义策略命名为“ HeroMessageVirtualScrollStrategy”。
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
// [...]
}
为了将我们自定义的策略应用于视口组件,我们需要使用VIRTUAL_SCROLL_STRATEGY注入令牌。我们可以通过引入一条新的指令来实现这一点:
@Directive({
selector: '[appHeroMessageVirtualScroll]',
providers: [
{
provide: VIRTUAL_SCROLL_STRATEGY,
/* We will use `useFactory` and `deps` approach for providing the instance */
useFactory: (d: HeroMessageVirtualScrollDirective) => d._scrollStrategy,
deps: [forwardRef(() => HeroMessageVirtualScrollDirective)],
},
],
})
export class HeroMessageVirtualScrollDirective {
/* Create an instance of the custom scroll strategy that we are going to provide */
_scrollStrategy = new HeroMessageVirtualScrollStrategy();
}
完成后,我们想添加一些额外的功能——提供一个消息列表,作为@Input稍后将在滚动策略中使用的列表。您会注意到滚动策略中还有一个名为 `getMessages()` 的额外方法updateMessages。我们稍后会添加它,但现在请耐心等待。
export class HeroMessageVirtualScrollDirective {
// [...]
private _messages: HeroMessage[] = [];
@Input()
set messages(value: HeroMessage[] | null) {
if (value && this._messages.length !== value.length) {
this._scrollStrategy.updateMessages(value);
this._messages = value;
}
}
}
我们刚才所做的,是添加了一个_messages属性,当该属性发生变化时,它将被更新并相应地传递给滚动策略*
* 修改可能非常主观。目前的检查机制非常原始,但可以根据具体使用情况进行调整。
💾:您可以在GitHub上查看最终文件。
概括
总结一下我们目前为止所做的工作:
- 熟悉了
VirtualScrollStrategy界面 - 我们制定了自己的策略(虽然尚未实施)。
- 我们构建了一个指令,它将与虚拟滚动组件配合使用,以推进我们的策略,这也引出了下一章的内容。
三、制定自定义策略
既然我们现在有了自定义策略,或者至少有了线框图,让我们把它插入到cdk-virtual-scroll-viewport所需宿主组件的模板中。在我的例子中,我将使用app.component.html。
<cdk-virtual-scroll-viewport
appHeroMessageVirtualScroll
[messages]="heroMsgs.messages$ | async"
>
<!-- We'll use the HeroMessageComponent in order to render our list items -->
<app-hero-message
*cdkVirtualFor="let msg of heroMsgs.messages$ | async"
[message]="msg"
[attr.data-hm-id]="msg.id"
></app-hero-message>
</cdk-virtual-scroll-viewport>
数据来源
由于本文的重点并非数据生成,您可以在 GitHub 上查看执行此任务的模拟 API 服务。我不想在文章中堆砌不必要的源代码。模拟数据app.component.ts通过服务和相应的模板提供给系统。
💾:请在GitHub上查看应用组件。
💾:请在GitHub上查看模拟 API。
无限滚动
最后,为了尽可能接近真实应用,模拟数据将在用户向下滚动列表时持续加载。为此,我们需要引入无限滚动功能。
💾:您可以在GitHub上查看最终文件。
概括
本章我们成功地做到了:
- 将自定义策略插入
CdkVirtualScrollViewport - 简要展示我们的数据生成过程
- 为了更贴近现实,可以添加一些无限滚动效果。
四、战略实施
我们已经到了可以开始实施该策略的阶段。从逻辑上讲,让我们先从attach方法入手detach:
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
private _viewport!: CdkVirtualScrollViewport | null;
attach(viewport: CdkVirtualScrollViewport): void {
this._viewport = viewport;
}
detach(): void {
this._viewport = null;
}
}
接下来,让我们向策略类添加updateMessages用于提供 -s 参数的自定义方法:HeroMessage
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
private _messages: HeroMessage[] = [];
// [...]
updateMessages(messages: HeroMessage[]) {
this._messages = messages;
if (this._viewport) {
this._viewport.checkViewportSize();
}
}
}
现在,是时候介绍我们的主要方法了,它将决定后续的实现。该方法_updateRenderedRange必须包含我们的核心逻辑,用于确定要在视口中渲染的英雄信息范围。简而言之,为了实现它,我们需要了解以下几点:
- 测量可滚动容器的总高度
- 确定滚动位置(或偏移量)
- 确定列表项的数量
如您所见,这里有一个关键点——我们必须能够区分和测量不同列表项的大小。当然,这项任务可能非常棘手。因此,我们将采用粗略估计的方法。具体该如何实现呢?让我们来看一下用户界面HeroMessage。
确定消息(列表项)的高度
如您所见,用户界面并不复杂——我们有一个醒目的标题、一个日期、一些文本和标签。标题的高度由现有元素决定(例如,只有一行文本的标题)。然而,标题的高度在增长方面没有限制(除非我们为文本设置最大高度,但这并非本文的目的)。由于我们之前提到过,我们需要的是一个粗略的估计值,而不是精确值,因此我们可以检查样式化的组件及其子元素,并记录它们的大致尺寸。事不宜迟,让我们来介绍一下我们的高度预测器。
// hero-message-height-predictor.ts
const Padding = 24 * 2;
const NameHeight = 21;
const DateHeight = 14;
const MessageMarginTop = 14;
const MessageRowHeight = 24;
const MessageRowCharCount = 35;
const TagsMarginTop = 16;
const TagsRowHeight = 36;
const TagsPerRow = 3;
export const heroMessageHeightPredictor = (m: HeroMessage) => {
const textHeight =
Math.ceil(m.text.length / MessageRowCharCount) * MessageRowHeight;
const tagsHeight = m.tags.length
? TagsMarginTop + Math.ceil(m.tags.length / TagsPerRow) * TagsRowHeight
: 0;
return (
Padding +
NameHeight +
DateHeight +
MessageMarginTop +
textHeight +
tagsHeight
);
};
如您所见,这是一个相当简单的函数,它HeroMessage根据数据估算/预测元素的高度。文本和标签的高度估算基本上是基于大致行长度的粗略估计值。不过,在现阶段,这已经足够满足我们的需求了。我想无需赘述,您也需要根据自己的具体情况开发一个类似的函数来匹配您的用户界面。现在,让我们回到策略实现上来。
💾:您可以在GitHub上查看该文件。
纳入身高预测
由于需要进行高度估计,我们可以直接采用一种使用预测器的新私有方法:
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
private _heightCache = new Map<string, number>();
// [...]
private _getMsgHeight(m: HeroMessage): number {
let height = 0;
const cachedHeight = this._heightCache.get(m.id);
if (!cachedHeight) {
height = heroMessageHeightPredictor(m);
this._heightCache.set(m.id, height);
} else {
height = cachedHeight;
}
return height;
}
}
您可以注意到我们还添加了一个缓存属性。简单来说,我们的方法将被缓存,从而避免重复计算。当用户滚动页面时,可能会出现大量的重复计算。
接下来,我们将介绍几种基于/使用这种方法的其他方法。如上所述,_updateRenderedRange我们需要知道一些信息,例如总滚动高度、消息偏移量等等。我们将首先计算一组消息的高度,这是一个相当简单的操作:
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
// [...]
private _measureMessagesHeight(messages: HeroMessage[]): number {
return messages
.map((m) => this._getMsgHeight(m))
.reduce((a, c) => a + c, 0);
}
}
之后我们可以引入以下私有方法:
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
// [...]
/**
* Returns the total height of the scrollable container
* given the size of the elements.
*/
private _getTotalHeight(): number {
return this._measureMessagesHeight(this._messages);
}
/**
* Returns the offset relative to the top of the container
* by a provided message index.
*
* @param idx
* @returns
*/
private _getOffsetByMsgIdx(idx: number): number {
return this._measureMessagesHeight(this._messages.slice(0, idx));
}
/**
* Returns the message index by a provided offset.
*
* @param offset
* @returns
*/
private _getMsgIdxByOffset(offset: number): number {
let accumOffset = 0;
for (let i = 0; i < this._messages.length; i++) {
const msg = this._messages[i];
const msgHeight = this._getMsgHeight(msg);
accumOffset += msgHeight;
if (accumOffset >= offset) {
return i;
}
}
return 0;
}
}
我们离实现目标已经非常接近了_updateRenderedRange。在上面的代码部分中,我们添加了一个测量总高度的方法、获取消息索引的滚动偏移量的方法,以及反向方法——通过滚动偏移量获取消息索引。
接下来介绍的是一个根据消息的起始索引来确定视口中消息数量的方法:
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
// [...]
private _determineMsgsCountInViewport(startIdx: number): number {
if (!this._viewport) {
return 0;
}
let totalSize = 0;
// That is the height of the scrollable container (i.e. viewport)
const viewportSize = this._viewport.getViewportSize();
for (let i = startIdx; i < this._messages.length; i++) {
const msg = this._messages[i];
totalSize += this._getMsgHeight(msg);
if (totalSize >= viewportSize) {
return i - startIdx + 1;
}
}
return 0;
}
}
经过这段简短的方法实现之后,我们终于可以介绍本章开头提到的关键方法了 _updateRenderedRange。它的作用是构建一个由起始索引和结束索引组成的范围对象,然后通过其 API 将其提供给视口对象。您还会注意到两个名为 `before`PaddingAbove和 `after`的常量PaddingBelow。它们的作用是指示虚拟滚动条在滚动视口之前和之后渲染一些额外的消息。这样,我们就能获得一些不可见但对于更好的滚动体验至关重要的已渲染内容(即,项目不必在最后一刻才渲染):
typescript
const PaddingAbove = 5;
const PaddingBelow = 5;
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
_scrolledIndexChange$ = new Subject<number>();
scrolledIndexChange: Observable<number> = this._scrolledIndexChange$.pipe(
distinctUntilChanged(),
);
// [...]
private _updateRenderedRange() {
if (!this._viewport) {
return;
}
const scrollOffset = this._viewport.measureScrollOffset();
const scrollIdx = this._getMsgIdxByOffset(scrollOffset);
const dataLength = this._viewport.getDataLength();
const renderedRange = this._viewport.getRenderedRange();
const range = {
start: renderedRange.start,
end: renderedRange.end,
};
range.start = Math.max(0, scrollIdx - PaddingAbove);
range.end = Math.min(
dataLength,
scrollIdx + this._determineMsgsCountInViewport(scrollIdx) + PaddingBelow,
);
this._viewport.setRenderedRange(range);
this._viewport.setRenderedContentOffset(
this._getOffsetByMsgIdx(range.start),
);
this._scrolledIndexChange$.next(scrollIdx);
}
}
目前为止一切顺利。你可能还记得attach我们之前实现的方法,对吧?现在我们必须用新引入的功能来更新它_updateRenderedRange:
attach(viewport: CdkVirtualScrollViewport): void {
this._viewport = viewport;
// New code
if (this._messages) {
this._viewport.setTotalContentSize(this._getTotalHeight());
this._updateRenderedRange();
}
}
现在我们还可以onDataLengthChanged向策略类中添加 `and` 属性,其代码非常相似:
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
// [...]
onDataLengthChanged(): void {
if (!this._viewport) {
return;
}
this._viewport.setTotalContentSize(this._getTotalHeight());
this._updateRenderedRange();
}
}
最后,我们需要在用户滚动页面时更新渲染范围。这可以通过以下方式轻松实现onContentScrolled:
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
// [...]
onContentScrolled(): void {
if (this._viewport) {
this._updateRenderedRange();
}
}
}
现阶段,我们应该已经完成了新策略的内部架构。下一步,我们可以实现一些其他方法——这些方法对于虚拟滚动条的运行并非至关重要——它们是VirtualScrollStrategy界面的一部分。
互补方法
本节内容非常简短。我们将重点介绍三种方法,但只会实现其中一种。
就我们的目的而言,我们不需要用到 ` onContentRenderedand` 和 ` onRenderedOffsetChanged。我们不需要在内容渲染完成或偏移量发生变化时执行任何操作。所以:
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
// [...]
onContentRendered(): void {
/** no-op */
}
onRenderedOffsetChanged(): void {
/** no-op */
}
}
另一方面,我们scrollToIndex可能需要虚拟滚动策略支持的功能,因此我们可以使用之前添加的现有私有方法快速实现它:
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
// [...]
scrollToIndex(index: number, behavior: ScrollBehavior): void {
if (!this._viewport) {
return;
}
const offset = this._getOffsetByMsgIdx(index);
this._viewport.scrollToOffset(offset, behavior);
}
}
随着最后这一步的完成,我们可以认为该项目已经全部实现VirtualScrollStrategy完毕。实际上,我们现在应该拥有一个功能齐全的虚拟滚动条,它很可能足以满足我们的需求。不过,这并不意味着我们不能再进行一些改进了。
改进身高测量
目前,我们的虚拟滚动策略严重依赖于我们引入的消息高度预测器来测量列表项的近似高度。这本身没有问题,在很多情况下都能满足需求。然而,这种设计存在一个固有的缺陷:列表项的近似高度虽然在某些情况下与实际高度几乎相同(甚至完全一致),但并非在所有情况下都相同。因此,虚拟滚动渲染的列表项越多,由于这些微小的偏差,对总高度的测量就越不精确。虽然这些偏差单独来看微不足道,但它们的累积会导致显著的高度差异。
当然,我们的目标是尽可能精确地测量这些高度。这只有通过获取已渲染列表项的高度才能实现——至少这是最简单的方法,除非我们需要进行更具体的计算——而且,仔细想想,在虚拟滚动过程中,列表项实际上已经渲染过了。总而言之:我们将在列表项渲染完成后,用它们的实际高度更新预测高度。
我们先来修改现有代码。首先,我们将更新地图的类型_heightCache:
private _heightCache = new Map<string, MessageHeight>();
其中,MessageHeight由一个新的接口定义:
interface MessageHeight {
value: number;
source: 'predicted' | 'actual';
}
我们需要区分预测高度和实际高度;因此,需要进行更改。
接下来,我们来修复一下,_getMsgHeight因为更新后的类型会导致一些错误。
private _getMsgHeight(m: HeroMessage): number {
// [...]
if (!cachedHeight) {
height = heroMessageHeightPredictor(m);
// Values from the height predictor will be marked as `predicted`
this._heightCache.set(m.id, { value: height, source: 'predicted' });
} else {
height = cachedHeight.value;
}
// [...]
}
现在,由于我们需要从 DOM 中获取实际高度,因此必须获取滚动容器的引用。在attach策略方法中执行此操作是合理的,因为这是我们代码的起点,也是我们设置视口的地方,而视口可以用于此目的。
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
private _wrapper!: ChildNode | null;
// [...]
attach(viewport: CdkVirtualScrollViewport): void {
this._viewport = viewport;
this._wrapper = viewport.getElementRef().nativeElement.childNodes[0];
// [...]
}
}
我们现在离完成目标更近了一步,但还需要向策略中添加一个私有方法。我们可以将其命名为_updateHeightCache:
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
// [...]
private _updateHeightCache() {
if (!this._wrapper || !this._viewport) {
return;
}
// Get a reference of the child nodes/list items
const nodes = this._wrapper.childNodes;
let cacheUpdated: boolean = false;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i] as HTMLElement;
// Check if the node is actually an app-hero-message component
if (node && node.nodeName === 'APP-HERO-MESSAGE') {
// Get the message ID
const id = node.getAttribute('data-hm-id') as string;
const cachedHeight = this._heightCache.get(id);
// Update the height cache, if the existing height is predicted
if (!cachedHeight || cachedHeight.source !== 'actual') {
const height = node.clientHeight;
this._heightCache.set(id, { value: height, source: 'actual' });
cacheUpdated = true;
}
}
}
// Reset the total content size only if there has been a cache change
if (cacheUpdated) {
this._viewport.setTotalContentSize(this._getTotalHeight());
}
}
}
这个方法虽然有点长,但总体来说很容易理解。你可能已经注意到这个data-hm-id属性的存在了。实际上,这个属性从第三章开始就存在于我们的代码中,但我特意没有在此刻提及它,希望你能理解它与我们实现的关联性和重要性。如果还没理解——它的作用是帮助我们轻松区分 DOM 中的不同消息。
最后,我们需要在某个地方调用这个方法。一个好地方实际上是_updateRenderedRange在其他语句执行完毕之后。
export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
// [...]
private _updateRenderedRange() {
// [...]
this._updateHeightCache();
}
}
至此,我们可以得出结论:我们的实施工作已经完成。🎉
💾:请在GitHub上查看已实现的自定义策略。
最终代码
本文中,我提供了一些指向 GitHub 代码库的链接,该代码库包含基于本文的示例代码。为了方便您访问,这里提供整个代码库的链接:
hawkgs/hero-feed-virtual-scroll
结论
本文提出的问题可以抽象化,应用于许多其他用例。例如,可以注入身高预测器。所有这些都可以得到一个相当通用的实现。但我特意没有选择这种方式来解决这个问题。无论如何,实际上,这个实现还可以进一步改进或针对其他需求进行定制;因此,它可能无法考虑到更具体的用例。
不过,我真正希望的是,通过阅读本文,您现在能够很好地了解如何实现自己的目标VirtualScrollStrategy。


