Edeity's Blog

滑动优化填坑记

为了迎接新学期,金山文档换上了新皮肤。但在滑动到顶部时,顶部工具栏总会唰地跳出来,如同梦寐女神脱袜漏腿毛,带有某种不可描述的视觉冲击。效果如下:

为此,产品强烈要求优化滑动,让滑动能如丝般光滑的体验,效果如下:

那么如何实现呢?

原实现方式

假设我们的文档结构如下:

<body>
<header>顶部工具栏</header>
<main>中间内容</main>
</body>

原效果实现非常简单:

 // 后续代码 $main $body即代表对应的DOM节点
const $header = document.querySelector('header');
const $main = document.querySelector('main');

$main.addEventListener('scroll', function() {
const { scrollTop } = $main;
if (scrollTop > 20) {
$header.style.setProperty('top', '50px');
$main.style.setProperty('top', '0px');
} else if (scrollTop <= 0) {
$header.style.setProperty('top', '0px');
$main.style.setProperty('top', '50px');
}
});

计划通

Plan 1

通过缓动减少突兀感:transition: top .2s;

这种方法的优点是:简单,代码都不用写!

缺点是:无论何种缓动动效,只能减少而无法消除变化的突兀感。其必然存在50->0的“突然”变化。

Plan 2

通过监听touchMove的偏移量,同步更改$main$header的top。比如,手指向上move1px,同时更改$main$header49px(众所周知,更改top会触发重排,对此可将top替换成transformY进行优化,但非关键,不在此展开。)

let startY = 0;
window.addEventListener('touchstart', function (event) {
startY = event.touches[0].clientY;
});
window.addEventListener('touchmove', function (event) {
let offsetY = startY - event.touches[0].clientY;
offsetY = Math.min(Math.round(offsetY * 10) / 10, 50);
if (offsetY > 0) {
$header.style.setProperty('top', 0 - offsetY + 'px');
$main.style.setProperty('top', 50 - offsetY + 'px');
} else {
$header.style.setProperty('top', '0px');
$main.style.setProperty('top', '50px');
}
});

这种方法的优点:DOM的偏移量和手指的偏移量同步。

缺点:大家别忘了$main本身是可以滚动的。所以会出现,touchMove时,$main同时向上滚动了若干像素,而导致内容被顶部栏覆盖的情况(覆盖的高度刚好是向上滚动的高度)。

或许可以考察下overscroll-behavior,但此css属性在safari下未被支持

Plan 2.1

虽然$main是可以滚动的,但工具栏只有在最开始的时候,才需要触发同步收起效果,所以能否在开始时,将$main设置为overflow: hidden,当$header收起时,再将$main设置为overflow: auto

理想很丰满,现实却很骨感。这个实现方法的问题在于,滑动过程中更改overflow属性,并不能立即生效,即使touchMove的偏移量已经大于50px$main也被设置成overflow: auto,此时$main仍然是不可滑动的(需要touchend后才生效)。导致上滑时,需要两次滑动(一次用于伸缩顶部工具栏,一次用于滑动内容)。

假如两次滑动还能接受,那么更糟糕的是其在ios下的表现。因为弹簧效果的存在,ios会出现短暂的不可滑动,或出现抖动的情况。

Plan 3

有没有可能保持$main的位置,而让超出$main的内容可见?即$main的top一直都是50px,而向上滑动时,内容在0-50px部分的内容仍然可见?

我们需要一种类似于overflow:auto-but-visible,但很遗憾,单纯通过CSS无法实现这种效果。

基本设计

柯南·道尔曾经说过:当排除一切不可能,剩下的,不管多难以置信,那都是事实。所以,只剩下一种方案可供选择:模拟滚动

布局很简单,但和常见的模拟滚动稍有不同:

除了基本的top/bottom外,多一个axisTop/axisBottom,这是为了让模拟的Y轴滚动条距离滚动区域能有一定的偏移,即滚动条区域 = 可视区域- axisTop/axisBottom,以实现overflow: visible-hidden;

layout

DOM大致如下:

 <!-- 滑动区 -->
<OuterWrapper>
<!-- 可视区 -->
<InnerWrapper>
<!-- 正文内容 -->
<Context/>
</InnerWrapper>
</OuterWrapper>

底部弹出工具栏时,仅需要改变outerWrapper的尺寸,减少重排的损耗。

至于模拟滚动,则监听touch/wheel事件,同步更改CSS3属性transform: translate(x, y, z)

看起来并不复杂嘛!恩,看起来…

各种优化

当我信心满满地花了一周写完组件以及处理各种偏移量后,提交测试。未闲一天,测试即反馈:太卡顿了。(从技术角度,即无法再16ms~32内执行一帧更新)

老衲擦指一算,我去,排版太卡了。

typo

原C++代码中,排版是单独的线程,但迁移到JS上时,因为JS是单线程生物,导致排版像霸道总裁一样卡在那里,以及因为“某些原因”,排版也不能执行类似requestAnimate时间分片。导致更改transfrom触发排版后,整个页面进入了假死的状态。可怜我的模拟滚动,在漫长的执行周期中,连几ms执行权力都没有。

优化1:采用原生写法

虽然React的虚拟dom能减少我们操作原生dom的频率,但本身执行的流程还是有一定损耗的,在变化频繁,性能敏感的场景中,显得比较致命。所以需要将绝大部分的事件以及状态变更,均采用原生的写法。

setState

优化2:开启缓存

模拟滚动能带天然的优化:内部的状态必须由程序自己托管,从而避免了排版高凭读取正文DOM属性的消耗,优化首屏打开速度或其他各种操作。通过暴露唯一的更改尺寸的接口,配合Observe(ResizeOberver & MutationObserver),可以实现这种效果。

cache

优化3:小碎步 + 大跨步

小碎步:以“段”的加载方式替换以前“屏”的加载方式,减少单次排版耗时

大跨步:实现,实际的DOM高度 !== 滚动区域,能预置高度,避免每次排版更新导致的重排消耗

size

小碎步 + 大跨步是一套组合拳,小碎步通过减少单次排版区域,避免单次卡顿时间过长。大跨步,则是为了避免因为过频繁的小碎步导致过频触发重排消耗。

优化4:内存回收

浏览器原生不实现overflow: visible-hidden,我认为有一个重要的原因,就是内容过多时,不方便判断什么是可视区外的元素,从而导致渲染内容paint过多。

通过简单的标记法,标记可视区外的元素,在空闲时进行内存回收,可以减少单次需要渲染的元素,减少滑动时性能消耗。美中不足的是,回收后,DOM变成了Fragment片段从文档流中移除,后续滑动到对应区域,需要重新添加到文档流中,这也是一种消耗,所以需要权衡,不能过频回收。

gc

优化5:终极大招,大道若简

以上几种手段,能通过优化代码执行效率,减少卡顿的情况。但真正解决卡顿情景,还得借助“多线程”。那么Javascript能否在某种程度上的异步呢。WebWorker虽然能达到这种效果,但因为限制太死(比如不能读取DOM,和主线程只能过postMessage的方式进行数据交换,途中还要序列化和反序列化),暂不在考察范围。

其实很简单,基础的CSS就可以做到!在一般的CSS渲染中,需要进行JavaScript -> Style -> Layout -> Paint -> Composite操作,即这张被用到烂的图:

flow

这是在主线程进行的。但当开启3D加速后,部分渲染会提升到GPU中,在Layer线程中渲染。这就是某种意义的“多线程”。针对无交互场景(一般为离手后的惯性滑动),可通过计算最终的滚动位置,配合缓动函数cubic-bezier,通过transition-timing-function,可以向浏览器提交滑动到某段距离的动效,然后腾出时间给排版。一般的惯性滑动时间为2000~2500ms,所以在无用户操作的前提下,哪怕排版不要脸暂用两秒的执行时间,用户也没有明显感觉卡顿。

当然,这也是有弊端的,就是两个线程并不能及时通讯,导致在滑动过程中,需要不断getComputeStyle来修正DOM属性。由于存在Style -> Layout -> Paint这样的流程,导致触发touchstart停止后获取滑动位置(执行Javascript),和最终的位置(执行Paint)存在几毫秒时间差,导致两次时机得到位置不相等造成闪烁(低性能机器比较明显)。当然,采用动效后,效果提升体显著,对比小概率的闪烁还是好处大大滴。

总结

其实在开发的过程中还发现一些别的问题,比如之前:

  1. 自己手写的debounce存在性能问题
  2. 部分代码混淆不够
  3. babel编译析构操作符(…),会对Array执行slice + concat操作造成性能损耗
  4. ….

以及吐槽下产品的产品,在“滑动流畅”没有任何描述,各种滚动因子,长短距离滑动,长按短触,多指操作,惯性滑动,都是全靠开发想象,和测试PK,一点点磨出来的。(自己经验不足也是其中一部分,小声)

当然,优化后滑动文档,在加载文档的场景下,比原生滑动还要流畅,作为开发还是很自豪的。

总结一句话,就是,那些看起来很简单的东西,可能隐藏着各种大坑,还需要继续努力啊!冲啊!