无限滚动游戏的复杂性

要点:重复使用 DOM 元素,并移除距离视口较远的元素。使用占位符来处理延迟的数据。以下是无限滚动条的演示代码

无限滚动功能在互联网上随处可见。Google Music 的音乐人列表是其中之一,Facebook 的时间轴是其中之一,Twitter 的实时动态也是其中之一。您向下滚动,在到达底部之前,新内容似乎凭空出现。这为用户提供了顺畅的体验,而且其吸引力不言而喻。

不过,无限滚动条背后的技术难题比看起来要难得多。在您想要“做正确的事”时,会遇到各种各样的问题。首先从简单的事情入手,例如,由于内容不断推开页脚,导致页脚中的链接几乎无法访问。但问题会越来越难。当用户将手机从纵向模式切换为横向模式时,您如何处理大小调整事件?或者,当列表过长时,您如何防止手机卡顿?

The right thing™

我们认为,这足以成为我们提出参考实现的原因,该实现展示了如何以可重复使用的方式解决所有这些问题,同时保持性能标准。

我们将使用 3 种技术来实现我们的目标:DOM 回收、墓碑和滚动锚点。

我们的演示示例将是一个类似 Hangouts 的聊天窗口,我们可以在其中滚动浏览消息。首先,我们需要无限的聊天消息来源。从技术层面讲,目前没有任何无限滚动条是真正无限的,但考虑到可注入到这些滚动条中的数据量,它们也许可以视为无限。为简单起见,我们将直接对一组聊天消息进行硬编码,并随机选择消息、作者和偶尔的图片附件,同时添加一些人工延迟,以使其行为更像真实网络。

Chat 应用屏幕截图

DOM 回收

DOM 回收是一种用于减少 DOM 节点数的技术,但利用率较低。一般而言,应使用已创建但不在屏幕上的 DOM 元素,而不是创建新的 DOM 元素。诚然,DOM 节点本身的开销很低,但并非免费,因为每个节点都会增加内存、布局、样式和绘制方面的额外开销。如果网站的 DOM 过大,低端设备的速度会明显变慢,甚至完全无法使用。另请注意,每次重新布局和重新应用样式(每当向节点添加或从节点移除类时都会触发此过程)都会增加 DOM 的开销。回收 DOM 节点意味着我们将大幅减少 DOM 节点的总数,从而加快所有这些进程。

第一个障碍是滚动本身。由于在任何给定时间,我们只会看到 DOM 中所有可用项的一小部分,因此我们需要另找一种方法,让浏览器的滚动条能够正确反映理论上存在的内容量。我们将使用 1 像素 x 1 像素的哨兵元素和转换,强制包含项的元素(即跑道)具有所需的高度。我们会将跑道中的每个元素提升到各自的图层,以确保跑道本身的图层完全空白。没有背景颜色,什么都没有。如果跑道的图层不为空,则不符合浏览器的优化条件,并且我们将不得不在显卡上存储高度为几十万像素的纹理。绝对不适合在移动设备上使用。

每次滚动时,我们都会检查视口是否已足够靠近跑道末尾。如果是,我们将通过移动信标元素并将离开视口的项移至跑道底部,并用新内容填充这些项来延长跑道。

Runway Sentinel Viewport

反之亦然。不过,在实现过程中,我们绝不会缩小跑道,以便滚动条位置保持一致。

Tombstone

如前所述,我们会尽量让数据源的行为像真实世界中的行为一样。网络延迟和其他因素。这意味着,如果用户使用快速滑动功能,则可能会轻松滚动到我们有数据的最后一个元素。如果发生这种情况,我们会放置一个墓碑项(占位符),该项会在数据到达后被实际内容项替换。墓碑也会被回收,并且有一个单独的池用于可重复使用的 DOM 元素。我们需要这样做,才能从墓碑页顺利过渡到填充了内容的项,否则会给用户带来不适,甚至可能会让他们忘记自己关注的内容。

这样的坟墓。非常坚硬。哇

这里有一个有趣的挑战,即由于每个项的文本量或附加图片不同,因此实际项的高度可能会大于墓碑项的高度。为解决此问题,我们会在每次有数据传入且在视口上方替换墓碑时调整当前滚动位置,将滚动位置锚定到元素,而不是像素值。此概念称为滚动锚定。

滚动锚点

在替换墓碑和调整窗口大小时(在设备翻转时也会发生!),系统都会调用我们的滚动锚点。我们必须找出视口中可见的顶部元素。由于该元素可能仅部分可见,因此我们还会存储该元素顶部(视口开始处)的偏移量。

滚动锚点图。

如果视口大小发生变化,并且跑道发生变化,我们能够恢复视觉上与用户完全相同的情况。胜出!不过,调整大小的窗口意味着每个项的高度都可能会发生变化,那么我们如何知道锚定内容应放置在距离底部多远的位置?我们不会!为了确定这一点,我们必须在锚定项上方布局每个元素,并将它们的高度相加;这可能会导致在调整大小后出现明显的暂停,而我们不希望这样。我们假定上述每个项的大小都与墓碑一样,并相应地调整滚动位置。当元素滚动到跑道时,我们会调整滚动位置,从而有效地将布局工作推迟到实际需要时执行。

布局

我忽略了一个重要的细节:布局。DOM 元素每次回收通常都会重新布局整个跑道,这会使我们远远低于每秒 60 帧的目标。为避免这种情况,我们将布局工作揽到自己身上,并将绝对定位元素与转换搭配使用。这样,我们就可以假装跑道上方所有元素仍占用空间,而实际上只有空白空间。由于我们自行进行布局,因此可以缓存每个项最终的位置,并在用户向后滚动时立即从缓存中加载正确的元素。

理想情况下,项在附加到 DOM 后只会重新绘制一次,并且不会因跑道中其他项的添加或移除而受到影响。这确实可行,但仅适用于现代浏览器。

最新调整

近期,Chrome 添加了对 CSS Containment 的支持。借助这项功能,我们开发者可以告知浏览器某个元素是布局和绘制工作的边界。由于我们要自行设置布局,因此它非常适合用于容器。每当我们向跑道添加元素时,我们都知道其他项不需要受到重新布局的影响。因此,每个项都应获取 contain: layout。我们也不希望影响网站的其余部分,因此跑道本身也应获得此样式指令。

我们考虑的另一种方法是使用 IntersectionObservers 作为一种机制来检测用户滚动到什么位置时,我们可以开始回收元素并加载新数据。不过,IntersectionObserver 被指定为高延迟(就像使用 requestIdleCallback 一样),因此与不使用 IntersectionObserver 相比,我们实际上可能会感觉响应速度较慢。即使我们目前使用 scroll 事件的实现也存在此问题,因为滚动事件是“尽力”分派的。最终,Houdini 的 Compositor Worklet 将成为解决此问题的高保真解决方案。

但仍有待改进

我们目前的 DOM 回收实现并不理想,因为它会添加穿过视口的所有元素,而不是仅关心实际显示的元素。也就是说,当您非常快速滚动时,Chrome 会执行大量布局和绘制工作,导致其无法跟上速度。最后,您将只看到背景。这并不是世界末日,但肯定需要改进。

我们希望您能了解,当您希望同时兼顾出色的用户体验和高性能标准时,简单的问题可能会变得多么具有挑战性。随着渐进式 Web 应用成为移动设备上的核心体验,这一点变得更加重要,Web 开发者必须继续投资于使用符合性能限制的模式。

您可以在我们的代码库中找到所有代码。我们已尽最大努力使其可重复使用,但不会将其作为实际库在 npm 上发布或作为单独的代码库发布。主要用于教育目的。