无限滚动游戏的复杂性

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

无限滚动器在整个互联网上随处可见。Google Music 的音乐人列表是一个,Facebook 的时间轴是一个,Twitter 的实时动态也是一个。您向下滚动,在到达底部之前,新内容会神奇地出现,仿佛凭空出现一般。对于用户来说,这是一种无缝体验,因此很容易看出其吸引力。

不过,无限滚动器背后的技术挑战比看起来要难。当您想做正确的事时,会遇到各种各样的问题。首先,页脚中的链接几乎无法访问,因为内容不断将页脚推到下方。但问题会越来越难。当用户将手机从竖屏模式切换为横屏模式时,您如何处理调整大小事件?或者,当列表过长时,您如何防止手机卡顿?

The right thing™

我们认为,这足以促使我们开发一个参考实现,展示如何以可重用的方式解决所有这些问题,同时保持性能标准。

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

我们的演示案例将是一个类似 Hangouts 的聊天窗口,我们可以在其中滚动浏览消息。首先,我们需要一个无限的聊天消息来源。从技术上讲,目前市面上没有一款无限滚动器是真正的无限滚动器,但由于可供这些滚动器使用的数据量非常庞大,因此它们几乎可以算是无限滚动器。为简单起见,我们将只对一组聊天消息进行硬编码,并随机选择消息、作者和偶尔的图片附件,同时添加少量人为延迟,使其行为更像真实的网络。

聊天应用屏幕截图

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 上或作为单独的代码库发布。主要用途是教育。