[译] 无尽滚动的复杂度 -- 来自 Google 大神的拆解

21,249 阅读9分钟

原文地址:developers.google.com/web/updates…
原文作者:Surma
译者:王芃


摘要: 重用你的DOM元素以及删除那些远离可视范围的元素。为延迟显示的元素使用占位符。这里是一个无尽滚动的演示代码

无尽滚动在互联网上到处都有应用。Google Music的艺术家列表是一个,Facebook的时间线是一个,Tweeter的话题列表也是一个。当你向下滚动,新的内容就神奇的“无中生有”了。这是一个得到广泛赞扬的、非常好的用户体验。

在这个无尽滚动背后的技术挑战其实比它看上去要难。当你想做正确的事时,你遇到的问题是巨大的。开始时是一些比较简单的事情,比如在页面尾部的链接是无法点击的,因为内容不断的把它们“挤”走。但是问题逐渐开始变得越来越难:当用户将手机从竖屏改为横屏时你该如何处理 resize 事件?或者当列表过长时你如何避免手机的卡顿?

正确的事

我们认为有充分的理由来实现一个参考设计:在保证性能的基础上,以一个可复用的方式来解决这些问题。

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

我们的demo会是一个类似聊天的窗口,我们可以滚动这些消息列表。首先需要的是一个无尽的消息数据源。从技术角度看,没有任何一个无尽列表是真正无尽的,但当有足够的数据量填充进去时,它们看上去感觉是无尽的。为简化问题,我们这里硬编码了一套消息数据,随机的抽取消息、联系人和图片。为了更像网络的真实情况,我们人为加入了一些延迟。

image_1b8s8bm77scgbh31ill1qn41h199.png-786kB

DOM 回收

DOM回收是一个未被广泛使用的技术,它的用途是让DOM的节点数保持在较低的数值。概括来说,它的机制是利用那些离开视图区域的、已经创建的DOM元素,而不是新建DOM元素。需要承认的一点是DOM节点本身并非耗能大户,但是也不是一点都不消耗性能,每一个节点都会增加一些额外的内存、布局、样式和绘制。如果一个站点的DOM节点过多,在低端设备上会发现明显的变慢,如果没有彻底卡死的话。同样需要注意的一点是,在一个较大的DOM中每一次重新布局或重新应用样式(在节点上增加或删除样式所触发的过程)的系统开销都会比较昂贵。所以进行DOM回收意味着我们会保持DOM节点在一个比较低的数量上,进而加快上面提到的这些处理过程。

第一个障碍是滚动本身。由于我们在任何时刻DOM中只有全部列表项目的一个微小子集,我们需要找到一种方式可以让浏览器正确的反映出理论上应该在“那里”的全部列表项目数量。我们这里用一个 1px * 1px 的”前哨“元素(sentinel),并且应用一个变换使得包含“逃兵”列表项目的元素(下图中的 runway)保持一个理想的高度。我们会把runaway中的每一个元素提升到它们自己的层,保持 runaway 本身是完全空的,没有背景色,神马都木有。如果 runaway层不是空的话,是不利于浏览器优化的。因为我们将不得不在显卡上存储一个由成千上万的像素组成的纹理。这样做显然在移动设备上是不可行的。

当我们进行滚动时,我们会检查是否viewport是否已经足够接近 runaway 的尾部。如果是的话,我们会通过把 sentinel和viewport中的剩余元素移向 runaway的底部来扩展 runaway,然后用新内容渲染这些元素。

向反方向滚动时也类似,但我们无论如何也不会缩小 runaway,原因是我们需要滚动栏的位置保持连续性。

墓碑(Tombstones)

如之前我们所说,我们会尽量让数据源表现的像现实世界遇到的情况:有网络延迟及其它情况。这就意味着如果我们的用户飞快地滚动,他们会很容易就把我们渲染的有数据的项目都甩在身后。如果这种情况发生时,我们就需要放置一个墓碑条目(占位)在对应位置,等到数据取到后墓碑条目会被实际内容替代。墓碑也会被回收,对于墓碑元素会有一个独立的可复用DOM元素的池。这样设计的原因是,我们希望墓碑元素在被实际数据替代时可以有一个漂亮的过渡,而不是出现那种生硬的或者让人迷失的效果。

墓碑元素

这里有一个有趣的挑战,那就是真实的条目的高度可能会超过墓碑的高度,因为不同的文本量或者图片的大小决定了这点。为了解决这个问题,每次当取到数据后我们会调整当前的滚动位置,而且在viewport之上的一个墓碑条目也会被替换。将滚动位置锚定到某一条目而非某一具体的像素位置,这个概念叫做滚动锚定。

滚动锚定

滚动锚定的触发时机有两个:一个是墓碑被替换时,另一个是窗口大小发生改变时(在设备发生翻转时也会发生)。我们必须要知道在viewport中的最顶部可见元素是什么。由于这个元素可能只是部分可见的,所以我们也需要存储从顶部元素到viewport顶部的偏移量。

滚动锚定

这样的话,当viewport改变大小时、runaway 改变时,我们是可以把场景恢复到一个看起来和原来几乎一致的样子。爽就一个字!但是改变大小的视窗意味着每个条目都可能改变了高度,那么我们如何能知道该把锚定的内容移动多少偏移量呢?我们并不知道!为了搞清楚这点,我们可能不得不把锚定条目之上的元素布局起来,把它们的高度累加在一起。但显然这样做会造成改变大小时会有明显的停顿,我们并不想要这样的结果。相反,我们借助于一个假设:在viewport之上的每个元素都是和墓碑等高的。根据这个假设来调整对应的滚动位置。当元素滚动进入 runaway 时,我们调整滚动位置,这样就有效的把布局工作延迟到真正需要的时候了。

布局

我刚才跳过了一个重要的细节:布局。每次DOM元素的回收通常情况下都会引发整个 runaway 的重新布局,这会直接影响我们的性能:无法达成每秒60帧的目标。为避免这一点,我们自己承担了布局的重任,使用了绝对定位的元素。这样我们可以让所有 runaway 中的元素感觉上还在占用空间,但其实那里毛都没有。由于我们自己在操控布局,我们便可以缓存每个元素消失前的位置,在用户往回滚动时,我们能立刻从缓存中加载正确的元素。

理想情况下,条目应该只被重绘一次,那就是当它们被加到DOM时。而且应该对于 runaway 中其它条目的增加或删除完全不受影响。这个是可能的,但是只限于现代浏览器。

极致优化

最近,Chrome增加了CSS Containment的支持,这个特性允许开发者告诉浏览器某个元素是布局和绘制的边界。由于我们这里采用的是自己来布局,这是一个很好的可以应用 containment 的机会。当我们增加一个元素到 runaway时,我们知道其它条目不应该被这个重新布局影响。所以每个条目应该设置一个 contain: layout。我们同样也不希望影响站点的其它部分,所以 runaway 本身也需要这样设置。

另一个优化点,我们考虑的是利用IntersectionObservers去检测用户是否已经滚动了足够距离,以便于我们决定是否开始回收DOM和加载新数据。但是 IntersectionObservers 是为高延迟设计的,所以我们实际上会“感觉”用了 IntersectionObservers 反而比不用时“响应更慢”。在我们当前的实现中滚动事件的处理其实也存在这个问题。也许这个问题的可信度较高的解决方案会是 Houdini’s Compositor Worklet

仍不完美

目前的DOM回收实现方式仍不是完美的,因为我们把所有“滚过”viewport的元素都添加到DOM了,而不是仅仅关心那些在屏幕上可见的元素。这就意味着,如果你滚动的真的非常非常快的话,快到你堆积了大量的布局和绘制工作,浏览器已经无法跟上的地步时,这时我们可能除了背景什么都看不到了。这当然不是世界末日但是确实是一个可以优化的地方。

我们希望你可以看到这个过程:当你想提供一个高性能的有良好用户体验的功能时,一个简单的问题是演变成复杂问题的。随着“Progressive Web Apps ”逐渐成为移动设备的一等公民,高性能的良好体验会变得越来越重要,开发者也必须持续的研究使用一些模式来应对性能约束。

所有的代码可以到这里查看,我们已经尽力让代码有可复用性了,但不会发布一个npm类库或其它单独的项目。这个代码的主要目的是教学。