通过 CSS content-visibility 改善渲染性能


最近我在处理 emoji-picker-element 时遇到了一个有趣的性能问题:

我所在的一个 Fediverse 实例有 19,000 个自定义表情符号[…],当我打开表情选择器时[…],页面至少会冻结一整秒,随后整体性能也会卡顿一阵子。

如果你不熟悉 Mastodon 或 Fediverse,不同的服务器可以拥有自己的自定义表情符号,类似于 Slack、Discord 等平台。拥有 19,000 个表情符号虽然很少见,但也不是闻所未闻的。

于是我启动了他们的复现环境,结果真的是慢得可怕:

Chrome DevTools 的截图显示表情选择器的布局和绘制开销非常高,同时有 40,000 个 DOM 节点。

这里有多个问题:

    1. 20,000 个自定义表情符号意味着 40,000 个元素,因为每个表情符号使用了一个 <button> 和一个 <img>
    1. 没有使用虚拟化技术,所以所有这些元素都直接被塞进了 DOM 中。
    1. 不过值得表扬的是,我使用了 <img loading="lazy">,因此这 20,000 个图像并没有一次性全部加载。但不管怎么说,渲染 40,000 个元素还是非常缓慢的 —— Lighthouse 建议的上限是 1,400 个!

我首先想到的是,“谁会有 20,000 个自定义表情符号?”紧接着我想,“唉,看来我需要实现虚拟化了。”

我一直避免在 emoji-picker-element 中使用虚拟化,主要原因是:1) 它很复杂,2) 我觉得我不需要它,3) 它对可访问性有影响。

这不是我第一次经历这种情况:Pinafore 基本上就是一个巨大的虚拟列表。我使用了 ARIA 的 feed 角色,自己做了所有的计算,还添加了禁用“无限滚动”的选项,因为有些用户不喜欢这种体验。我很清楚这条路有多难走!我只是对要写的代码感到头疼,还在考虑对我“小巧的” ~12kB 表情选择器的大小影响。

几天后,我突然想到:为什么不试试 CSS 的 content-visibility?我从性能追踪中看到大量时间花在布局和绘制上,这也可能有助于解决“卡顿”问题。这可能是比完整虚拟化更简单的解决方案。

如果你不熟悉,content-visibility 是一个比较新的 CSS 特性,允许你从布局和绘制的角度“隐藏”某些 DOM 部分。它基本不影响可访问性树(因为 DOM 节点仍然存在),也不会影响页面查找(⌘+F/Ctrl+F),并且不需要虚拟化。它只需要对屏幕外的元素进行大小估算,浏览器就可以为这些元素预留空间。

幸运的是,我在尺寸估算方面有一个很好的划分单元:表情符号分类。Fediverse 上的自定义表情符号通常分为几类:“blobs”(水滴)、“cats”(猫)等。

对于每个分类,我已经知道表情符号的大小以及行数和列数,因此可以通过 CSS 自定义属性来计算预期尺寸:

1
2
3
4
5
6
7
8
.category {
content-visibility: auto;
contain-intrinsic-size:
/* 宽度 */
calc(var(--num-columns) * var(--total-emoji-size))
/* 高度 */
calc(var(--num-rows) * var(--total-emoji-size));
}

这些占位符会占据与最终渲染出来的内容完全相同的空间,因此在滚动时不会出现页面跳动的问题。

接下来,我编写了一个 Tachometer 基准测试来跟踪进度。(我非常喜欢 Tachometer。)这帮助我验证了性能确实有所改善,以及改善的幅度。

我的第一次尝试非常容易编写,性能的确有所提升……只不过提升幅度有些令人失望。

在初次加载时,我在 Chrome 中获得了大约 15% 的性能提升,在 Firefox 中则是 5%。(Safari 只有技术预览版支持 content-visibility,因此我无法在 Tachometer 中进行测试。)虽然这并不算差,但我知道虚拟列表的效果会更好!

于是我深入研究了一下。布局成本几乎消失了,但还有一些无法解释的其他开销。例如,Chrome 追踪中这个未分类的庞大 JavaScript 时间是什么情况?

每当我觉得 Chrome “隐藏”了一些性能信息时,我会做两件事之一:要么启用 chrome:tracing,要么(最近)在 DevTools 中启用实验性的“显示所有事件”选项。

这比标准的 Chrome 追踪提供了更低层次的信息,但不需要切换到完全不同的 UI。我发现这是性能面板和 chrome:tracing 之间的不错折衷方案。

在这种情况下,我立刻看到了一些让我脑海中齿轮转动的信息:

Chrome DevTools 中的截图显示,之前的未分类时间标注为 ResourceFetcher::requestResource

什么是 ResourceFetcher::requestResource?即使没有查 Chromium 源代码,我也有个直觉——会不会是那些 <img> 标签?不可能吧……我用了 <img loading="lazy">

我按照直觉操作,简单地将每个 <img>src 注释掉,结果你猜怎么着——所有那些神秘的性能开销消失了!

我也在 Firefox 中进行了测试,效果同样显著提升。这让我相信,loading="lazy" 并不像我想象的那样免费无代价。

到了这一步,我想既然要去掉 loading="lazy",倒不如彻底来个大改动,将 40,000 个 DOM 元素减少到 20,000 个。毕竟,如果我不需要 <img>,我可以通过 CSS 把图片设置为 <button> 元素的 ::after 伪元素的背景图,从而将元素创建的时间减半。

1
2
3
.onscreen .custom-emoji::after {
background-image: var(--custom-emoji-background);
}

接下来,我只需使用一个简单的 IntersectionObserver,在分类滚动进入视口时添加 onscreen 类,从而实现一个性能更高的自定义 loading="lazy"。这次,Tachometer 报告 Chrome 中大约 40% 的性能提升,Firefox 中则是 35%。这才是我想要的效果!

注意:我本可以使用 contentvisibilityautostatechange 事件代替 IntersectionObserver,但我发现浏览器之间存在差异,此外这会惩罚 Safari,因为它会强制提前下载所有图像。一旦浏览器的支持有所改善,我肯定会使用它!

我对这个解决方案感到满意,并将其发布了。总的来说,基准测试显示 Chrome 和 Firefox 都有大约 45% 的性能提升,最初的重现问题从大约 3 秒缩短到 1.3 秒。报告这个问题的人甚至感谢我,说表情选择器现在好用多了。

不过,这件事还是让我有些不放心。从追踪结果来看,渲染 20,000 个 DOM 节点永远不可能像虚拟列表那样快。如果我要支持更大规模的 Fediverse 实例以及更多的表情符号,这个解决方案是无法扩展的。

尽管如此,我还是对 content-visibility 的“免费”效果印象深刻。尤其是我不需要更改 ARIA 策略,也不用担心页面查找功能,简直是天大的好消息。但作为一个追求完美的人,我仍然感到烦恼,觉得要获得最佳性能,虚拟列表才是正确的解决方案。

或许未来 Web 平台会有一个真正的虚拟列表作为内置的基础功能?几年前确实有过这方面的努力,但似乎停滞了。

我期待那一天的到来,但眼下我不得不承认,content-visibility 是虚拟列表的一个不错的替代方案。它实现简单,能提供不错的性能提升,并且基本没有可访问性陷阱。只不过,别让我去支持 100,000 个自定义表情符号!