滑动优化填坑记
为了迎接新学期,金山文档换上了新皮肤。但在滑动到顶部时,顶部工具栏总会唰地跳出来,如同梦寐女神脱袜漏腿毛,带有某种不可描述的视觉冲击。效果如下:
为此,产品强烈要求优化滑动,让滑动能如丝般光滑的体验,效果如下:
那么如何实现呢?
原实现方式
假设我们的文档结构如下:
<body> |
原效果实现非常简单:
// 后续代码 $main $body即代表对应的DOM节点 |
计划通
Plan 1
通过缓动减少突兀感:transition: top .2s;
这种方法的优点是:简单,代码都不用写!
缺点是:无论何种缓动动效,只能减少而无法消除变化的突兀感。其必然存在50
->0
的“突然”变化。
Plan 2
通过监听touchMove
的偏移量,同步更改$main
和$header
的top。比如,手指向上move1px
,同时更改$main
、$header
为49px
。(众所周知,更改top会触发重排
,对此可将top
替换成transformY
进行优化,但非关键,不在此展开。)
let startY = 0; |
这种方法的优点: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
;
DOM大致如下:
<!-- 滑动区 --> |
底部弹出工具栏时,仅需要改变outerWrapper
的尺寸,减少重排的损耗。
至于模拟滚动,则监听touch/wheel
事件,同步更改CSS3属性transform: translate(x, y, z)
。
看起来并不复杂嘛!恩,看起来…
各种优化
当我信心满满地花了一周写完组件以及处理各种偏移量后,提交测试。未闲一天,测试即反馈:太卡顿了。(从技术角度,即无法再16ms~32内执行一帧更新)
老衲擦指一算,我去,排版
太卡了。
原C++代码中,排版是单独的线程,但迁移到JS上时,因为JS是单线程生物,导致排版
像霸道总裁一样卡在那里,以及因为“某些原因”,排版也不能执行类似requestAnimate
时间分片。导致更改transfrom
触发排版后,整个页面进入了假死的状态。可怜我的模拟滚动,在漫长的执行周期中,连几ms执行权力都没有。
优化1:采用原生写法
虽然React
的虚拟dom能减少我们操作原生dom的频率,但本身执行的流程还是有一定损耗的,在变化频繁,性能敏感的场景中,显得比较致命。所以需要将绝大部分的事件以及状态变更,均采用原生的写法。
优化2:开启缓存
模拟滚动能带天然的优化:内部的状态必须由程序自己托管,从而避免了排版高凭读取正文DOM属性的消耗,优化首屏打开速度或其他各种操作。通过暴露唯一的更改尺寸的接口,配合Observe
(ResizeOberver & MutationObserver),可以实现这种效果。
优化3:小碎步 + 大跨步
小碎步:以“段”的加载方式替换以前“屏”的加载方式,减少单次排版耗时
大跨步:实现,实际的DOM高度 !== 滚动区域,能预置高度,避免每次排版更新导致的重排消耗
小碎步 + 大跨步是一套组合拳,小碎步通过减少单次排版区域,避免单次卡顿时间过长。大跨步,则是为了避免因为过频繁的小碎步导致过频触发重排消耗。
优化4:内存回收
浏览器原生不实现overflow: visible-hidden
,我认为有一个重要的原因,就是内容过多时,不方便判断什么是可视区外的元素,从而导致渲染内容paint
过多。
通过简单的标记法,标记可视区外的元素,在空闲时进行内存回收,可以减少单次需要渲染的元素,减少滑动时性能消耗。美中不足的是,回收后,DOM变成了Fragment
片段从文档流中移除,后续滑动到对应区域,需要重新添加到文档流中,这也是一种消耗,所以需要权衡,不能过频回收。
优化5:终极大招,大道若简
以上几种手段,能通过优化代码执行效率,减少卡顿的情况。但真正解决卡顿情景,还得借助“多线程”。那么Javascript能否在某种程度上的异步呢。WebWorker
虽然能达到这种效果,但因为限制太死(比如不能读取DOM,和主线程只能过postMessage的方式进行数据交换,途中还要序列化和反序列化),暂不在考察范围。
其实很简单,基础的CSS就可以做到!在一般的CSS渲染中,需要进行JavaScript -> Style -> Layout -> Paint -> Composite
操作,即这张被用到烂的图:
这是在主线程进行的。但当开启3D加速后,部分渲染会提升到GPU中,在Layer
线程中渲染。这就是某种意义的“多线程”。针对无交互场景(一般为离手后的惯性滑动),可通过计算最终的滚动位置,配合缓动函数cubic-bezier
,通过transition-timing-function
,可以向浏览器提交滑动到某段距离的动效,然后腾出时间给排版。一般的惯性滑动时间为2000~2500ms,所以在无用户操作的前提下,哪怕排版不要脸暂用两秒的执行时间,用户也没有明显感觉卡顿。
当然,这也是有弊端的,就是两个线程并不能及时通讯,导致在滑动过程中,需要不断getComputeStyle
来修正DOM属性。由于存在Style -> Layout -> Paint
这样的流程,导致触发touchstart
停止后获取滑动位置(执行Javascript),和最终的位置(执行Paint)存在几毫秒时间差,导致两次时机得到位置不相等造成闪烁(低性能机器比较明显)。当然,采用动效后,效果提升体显著,对比小概率的闪烁还是好处大大滴。
总结
其实在开发的过程中还发现一些别的问题,比如之前:
- 自己手写的
debounce
存在性能问题 - 部分代码混淆不够
- babel编译析构操作符(…),会对Array执行
slice
+concat
操作造成性能损耗 - ….
以及吐槽下产品的产品,在“滑动流畅”没有任何描述,各种滚动因子,长短距离滑动,长按短触,多指操作,惯性滑动,都是全靠开发想象,和测试PK,一点点磨出来的。(自己经验不足也是其中一部分,小声)
当然,优化后滑动文档,在加载文档的场景下,比原生滑动还要流畅,作为开发还是很自豪的。
总结一句话,就是,那些看起来很简单的东西,可能隐藏着各种大坑,还需要继续努力啊!冲啊!