最近我在处理
emoji-picker-element
时遇到了一个有趣的性能问题:
我所在的一个 Fediverse
实例有 19,000 个自定义表情符号[…],当我打开表情选择器时[…],页面至少会冻结一整秒,随后整体性能也会卡顿一阵子。
如果你不熟悉 Mastodon 或 Fediverse,不同的服务器可以拥有自己的自定义表情符号,类似于 Slack、Discord 等平台。拥有 19,000 个表情符号虽然很少见,但也不是闻所未闻的。
于是我启动了他们的复现环境,结果真的是慢得可怕:
Chrome DevTools 的截图显示表情选择器的布局和绘制开销非常高,同时有 40,000 个 DOM 节点。
这里有多个问题:
- 20,000 个自定义表情符号意味着 40,000 个元素,因为每个表情符号使用了一个
<button>
和一个<img>
。
- 20,000 个自定义表情符号意味着 40,000 个元素,因为每个表情符号使用了一个
- 没有使用虚拟化技术,所以所有这些元素都直接被塞进了 DOM 中。
- 不过值得表扬的是,我使用了
<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 自定义属性来计算预期尺寸:
.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
伪元素的背景图,从而将元素创建的时间减半。
.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 个自定义表情符号!
网友评论