在 Web 端实现 Reveal Highlight 效果

Fluent Design System 是由微软设计团队发布的一套用于其自家平台的设计语言。在用户体验这一设计语言时,能够最为直观感受到的设计元素之一就是 Reveal Highlight:当你的鼠标划过一个按钮时,会有一个光效跟随着你的鼠标划过;当你按下鼠标时,会有一串涟漪散开。

这个效果和 Material DesignRipple 效果非常像,但是实现起来却比 Ripple 困难得多:二者主要的差异是 Ripple 只会在鼠标按下并弹起时才会被触发显示,而 Reveal Highlight 效果则需要有一个光效元素时时刻刻跟随鼠标移动,让光效在鼠标移动时跟手就是我们要解决的最大问题。

TL; DR

NPM 上有我们写好的光效组件,叫 @ax-design/reveal-highlight,文档在我们项目的网站上,因为用了 CSS Typed OM 所以兼容性可能有点问题,如果你不在意兼容性问题的话可以直接用,在意的话可以 Fork 回去自己魔改一下。

成品效果如下:

Reveal Highlight 在 Web 端的实现

我们团队做的这个实现每帧的渲染速度约为4毫秒,目前为止应该是渲染效率最好的实现了。

你可以在这里看到在线版本,请注意,如果你在使用 Chromium 的派生浏览器,请打开 about:flags 并开启 Experimental JavaScriptExperimental Web Platform features 两个棋子🚩,不然 CSS Typed OM 不能得到正常支持,效果一个都加载不出来,会 Fallback 回无特效的模式。)

技术选型

名词约定:为了确保接下来的讨论没有歧义,我们将 Reveal Highlight 边缘较深的光效称为边缘光效,将元素内部颜色较浅的光效称为容器光效。

在尝试实现这套动效的时候我们最先考虑到的是使用 DOM 的方案,直接把光效装到 div 标签里面,在里面塞个光效图片或者 CSS 渐变填充的空白 div,鼠标移动的时候光效容器跟着鼠标走。这个方案最终没有被纳入考虑的原因是,容器光效的背景应当是透明的,为了确保容器光效背景透明,且能够遮挡住边缘光效容器在中央位置的内容,我们只有三种选择:

  • 上下左右四个容器,每个容器里面放一个边缘光效容器标签,性能差;
  • 用 CSS Mask 实现,兼容性差;
  • 放弃容器光效的背景透明,不符合设计规范(Fluent-reveal-effect 用的就是这个实现思路)。

所以使用原生 DOM 的实现方案被最先砍掉了,接下来我们转而考虑使用 SVG 实现,因为 SVG Mask 的兼容性更好一些,但是我们遇到了另外的一个问题:光效不跟手

事实上无论是使用 SVG 或者使用 DOM 的解决方案,都会遇到触发 Reflow 的问题,读取一下元素位置,触发了一次 Reflow,修改一下元素位置又触发了一次 Reflow,同时 Reflow 的元素一多你的页面就卡的飞起了。为了避免这个问题我们最终决定选择使用 Canvas 来实现这个动效,实现结果非常让人满意,只要你的电脑不是老爷机,在体验动效的时候基本上都不会感到任何卡顿,接下来我将向各位分享这一动效的实现细节和优化细节。

绘制流程

绘制流程相对简单:

动画管理这部分回相对复杂一些,因为鼠标松开时动效会持续播放一段时间(涟漪散开),最后再停止。这个时候需要一个动效管理器来管理每一个 Canvas 的动效播放进度:

  • mouseup 事件被触发时,将这个 Canvas 推入动效管理器中,每次触发 requestAnimationFrame 时都轮询一次管理器中的每一个元素并刷新每个 Canvas 的动效内容;
  • 如果动效播放到一半用户重新按下了按钮,则重置这个 Canvas 的动效播放进程;
  • 动效播放完毕后将这个 Canvas 从动效管理器中删除,下一帧刷新时不再触发该 Canvas 的重绘。

优化

渐变缓存

这件事情也是从做游戏引擎开发的豆娘那边知道的:画圆相对来讲是个比较贵的事情,尤其是你做球型渐变实际上是不停的画一大堆圆,这件事情相对来讲吃的资源比较多也比较慢,所以我们选择直接将绘制好的渐变缓存成位图,如果元素的样式不发生改变,那么在用户每次滑动鼠标时都直接从缓存中把位图调出来糊到画布上。

这里我们只缓存了静态的光效而没有缓存光效扩散动效当中的每一帧,原因有两个方面:

  • 鼠标移动过程中光效跟不跟手更加影响用户体验,所以用户会滑动鼠标的时间,渲染效率必须得到良好保证;
  • 基本不会有用户会做出按住鼠标四处滑的行为,另外缓存光效扩散动效的每一帧真的很影响组件初始化的速度。

我们也在考虑把缓存放在一个变量里面做中心化管理而不是每个组件都有自己的缓存,但是由于项目组的所有人时间都很有限所以这个计划被搁置了。

此外,算坐标这件事情真的很大脑升级,你要同时考虑到页面坐标、画布坐标、缓存位图的坐标,然后算出最后填充缓存的位置,这个东西非常难算,感兴趣的读者可以自己折磨自己一下 (゚∀。)。

最后,这一优化的确极大的提升了该组件的渲染效率,感谢豆娘 (*´▽`*)。

BoundingClientRect 缓存

element.getBoundingClientRect() 是会触发 Reflow 的,所以很慢,如果每帧都拿一次 Bounding Rect 其实挺影响效率的。我们最开始考虑将它的输出结果缓存,每次都从缓存里面调用,但是当光效被放置在有滚动条的容器当中会造成渲染错误,所以这项优化虽然被写出来了但是没有被放入API文档中。

样式管理

对于样式管理的问题我们最优先考虑的是性能问题,所以选择了 CSS Typed OM。

传统的 JS 与 CSS OM 之间交互方法非常的单纯,就是字符串来回抛,JS 拿到了字符串之后自己解析,JS 改完样式丢给 CSS OM 那边之后 CSS OM 也要解析这套字符串,一方面性能很有问题,另外一方面没有了类型检查很容易出错,所以 CSS Houdini 提案中提出了 CSS Typed OM 的概念,在 JS 操作 CSS 样式时可以通过一套新的 API 来显示标定单位和类型,JS 这边会做类型检查并且直接把类型和数值传给 CSS OM 那边避免了字符串编码解码这一过程,从而提升了性能了稳定性,如果各位对这个话题感兴趣的话我以后可以再写一篇文章讲这个。

这套 API 非常新,所以兼容性并不好,而且很多类型残缺(比如颜色变量的 API 怎么设计好像还在讨论),但是性能问题是我们这个项目最优先考虑的问题,所以在技术选型上我们非常激进的选择了 CSS Typed OM。不过我们的 Reveal Highlight 组件核心算法是不依赖这套 API 的,事实上最开始的实现是在 React 上做的,样式管理全部是通过组件属性传入完成解析,但是后来考虑到希望所有框架都能用到这套组件,所以我们把这套组件从 React 迁移到了 WebComponent 上,样式管理方案也就跟着变了。

结语

以上只是一个简单的笔记,如果您对具体的实现细节感兴趣欢迎阅读我们的源代码,如果还有问题也欢迎在 Telegram 上联系我或者给我发邮件。

本项目的参与者包括(字母顺序排序,所有成员同等贡献):

  • Balthild Ires (质量控制、Bug修复)
  • Jack Works (WebComponent 实现迁移与打包发布系统架构设计)
  • Losses Don (渲染过程实现与性能优化)
  • UTL_1138 (视觉、动效参数设计与设计语言指导)

@ax-design/reveal-highlight 是 Axiom Design System 的一部分,我们希望将 Fluent Design System 与 Material Design 中优秀的部分提取出来整合为一个一致、高效、优雅的设计语言,并提供对应的 Web 端实现以优化用户的浏览体验。