如何开发一款 60fps 的“无缝滚动”插件

9,298 阅读16分钟

什么是“无缝滚动”

所谓的“无缝滚动”就是多屏切换的过程是连续可循环的,而不是到最后一屏就停止播放。这种业务场景在实际开发中很常见,下面是“淘宝”和“京东” H5 版的首页截图,里面的 “banner 图”以及“头条栏”就是典型的无缝滚动的场景。但是体验一番之后,你会发现他们和原生 App 中的效果还是有一定差距的。你可以扫码打开在自己手机上体验一下,然后再打开他们的 App 划一划试一试,你会发现 H5 版本的似乎少了点什么

  

淘宝:  京东:

你可能发现了!H5 版的似乎少了对用户手势意图的判断:比如下图中的场景,如果在淘宝 H5 版的 banner 上你慢慢的左右晃动,它只会简单的比较 touchstarttouchend 事件触发时的横坐标来决定向哪个方向前进一屏。而如果在原生 App 上这么做,在结束时,他会回弹到占据当前屏幕大部分面积的那一屏,也就是第一屏,而当你是用手指快速扫过时,同样的位置,他则会切换到第二屏。

相比之下京东的 H5 体验会稍差一些,你在滑动的过程中他根本就“不跟手”,只是当你停止后,才判定方向(本文写于 2019 年 3 月,随着网站的升级,体验可能会有所不同)。

作为标杆型的大厂,自己同样的产品在 App 端 和 H5 端表现的差异性,他们自己肯定是知道的,但是为什么没有做到一致呢?想来用前端的技术去实现这个应该是需要一点额外的开发成本的或者存在卡顿等体验问题的。让我们来大致分析一下他们目前是通过什么方式实现“无缝滚动”的。但在开始之前我们先了解一下:

无缝滚动的基本原理

如上图所示,我们将 1号、2号、3号,三张图片依次排成一排,从窗口中看,先出现的是 1号图,短暂停留后滚动到 2号,接着依次向后,也就是3号。如果要实现“无缝滚动”,而不是到3号就结束了,那么接下来就应该出现1号了,因为这样才能形成了一种视觉上的循环滚动,这也就是为什么我们需要在3号后面补充一张 1号图的原因。当这张“假”的1号图,完全滚动到充满屏幕时,我们就迅速把整体移动到最开始的状态。由于这种“瞬移”,从窗口看显示的都是完整的1号图,所以视觉上,并感受不到背后的“突变”。由于用户可以通过手势左右滚动,所以反过来就要在开始位置补充一张3号图,这里就不再赘述了,这样一来,无论向左向右滚动,都会形成视觉上的无缝效果。

那么从前端的角度去实现这个会涉及到什么技术点呢?

  1. 位移的实现:我们可以借助 position 定位 + left/top 值的方式,也可以借助 transform: translate(x, y) 的方式,孰优孰劣,答案是后者更佳。感兴趣的可以阅读这篇参考文章 Why Moving Elements With Translate() Is Better Than Pos:abs Top/left
  2. “一令一动”:启动停止,启动停止。。。显然需要用到定时器。
  3. “动若脱兔”:也就是两屏之前的切换动画。比如上图中在第一屏的样式是 transform: translate(0px, 0px),而在第二屏是 transform: translate(-200px, 0px)。通常这个改变是需要一个快速的过渡动画的,而不是瞬间从1号“突变”到2号,这时候你就需要用到 transition: translate 0.3s ease; 来表明你希望这次的切换是一个平滑的过程。这里有个问题就是:当两个1号屏需要衔接上,进行位置重置时,是需要“突变”的,也就是上图中 transform: translate(-600px, 0px)transform: translate(0px, 0px)的过程。这样就意味着列表元素的transform 属性并不是一成不变的, 所以在“一令一动”的定时器开始下一屏切换之前你需要判断当前是否是临界状态,以设置不同的 transition 时长。
  4. 移动端下的手势操作:我们需要用到与触摸相关的三个事件,也就是: touchstart(手指接触屏幕)、touchmove(滑动中,会连续触发)和touchend(手指离开屏幕)。而我们要做的就是通过event.touches或者event.changedTouches拿到他们这些事件触发时的坐标信息。比如当用户touchstart触发时,我们记录开始的 x 轴坐标,touchmove触发时我们比较此时x 轴坐标与开始时的坐标的差值,借助 translate 移动同样的距离,以实现“跟手”的效果。而当touchend触发时,我们依然通过比较与开始坐标的差值来确认用户到底是要左滑还是右划。

条条大路通罗马

上面的介绍只是实现“无缝滚动”最常见的一种思路,淘宝似乎更聪明:我们知道用户手指在屏幕上,一次连续滑动的最远距离是不可能超过一个屏幕的宽度的,就比如我此时在2号屏,我最多滑到1号屏,或者3号屏,无论如何,我一次也不能滑到4号屏去。也就是说无论我们总的有多少屏,我们同时出现在屏幕的 DOM 最多是属于相邻的两个屏的。既然如此,我们可以把剩下的屏都置于一个等待队列里,让他们呆在屏幕外的一个固定位置上即可。这样一来每次滚动,浏览器重绘的面积只是二屏——当前屏和下一屏。而不是 n + 2,这无疑提示了性能。相信通过下面这张动态图,你应该可以明白我的意思了:

通过上图我们可以发现:同一时间有且仅有两个屏在位移,每波切换过程,有三个屏的 DOM 位置发生了变化。下面的两张截图也很好的验证了我的猜想:

阿里毕竟是阿里,大佬毕竟是大佬,不得不佩服!相比之下,京东就粗糙了些,用的是我最开始介绍的那种基本原理实现的。虽然两者在实现无缝滚动的原理上存在差异,但是借助的技术基本上都是我上面列出的四条。淘宝的实现算法虽然很好,但是有一个致命的问题,他很难满足点击切换的需求,如果下面的“小圆点指示器”是可以点击跳转的,你试想一下他怎么从第二屏跳转到第四屏?但这种需求在 PC 端的“无缝滚动” 中很常见,作为一名开发者你不得不想在前面。而两者也都存在我开篇提到的缺少对用户手势意图揣摩的问题,所以是时候推出新的解决方案了:

seamless-scroll

这是我最近折腾的一款无缝滚动插件,它同时满足移动端和 PC 端的开发场景,借助 requestAnimationFrametranslate 实现。提供类似原生 App 的体验,添加了对“快速滑动切换”和“缓慢拖动”等手势场景的处理。不依赖任何现存的框架或组件库,纯 JS ,也就意味着你无论在 Vue 还是 React 项目中都可以直接使用。支持 npm 安装 和 CDN 链接 引入,📦Gzip Size< 3KB,支持 IE10+IOS9+Andorid5+ 和现代浏览器。使用起来也很简单,它会暴露一个 SeamlessScroll 的构造函数,你可以借助 new 关键字创建一个“无缝滚动”实例,通过传递参数,你可以自定义动画速度、是否自动播放等行为,创建的实例也提供 startstopgo 等方法让你可以方便的控制播放的启动停止或者直接跳转到某个索引位置等。

Github 仓库地址扫码体验移动端点击预览 PC 端在 React 中使用的示例代码

真机 iPhone 和 小米5 上测试过,体验还是非常流畅的,下图是谷歌浏览器 Performance 面板的截图,上方 FPS 一栏形成了连续稳定的 5 个绿色小块,反应了5次移动过程中的 FPS 的变化。这些绿色小柱越高表示帧率越高,体验就越流畅,反之如果出现红色小柱,则很可能存在卡顿。

下面我就介绍一下我的实现思路:

  首先选取基本的实现原理:上面介绍的“淘宝式”和“京东式”两种“无缝滚动”原理,因为要满足直接跳转的需求,所以选择了后者。

  技术选型再思考:上面介绍了在实现“无缝滚动”中需要用到的四个技术点,1,2,4依然适用,但在“动若脱兔”的环节我们也许可以换个思路。上面我们说到这个过渡动画可以利用 transition 来实现,它的表现非常流畅。不过我们知道动画的本质其实就是一组连续运动的画面,既然如此,我们是否可以通过连续不断的在短时间内移动一小段距离来实现类似动画的效果呢?当然可以。我们不妨把“无缝滚动”的过程抽象为两大状态的循环组合——静止状态和动画状态

  静止状态下我们通过定时器延迟一段时间后开启下一波的动画状态,并为这个动画状态确认目标位置,而在动画状态下我们一步一步小心的“挪动”,随时关注自己是否已经到达了目标位置,如果到达了,我们就停止,重新回归静止状态,并由它确认我们下一波的移动。思路已经很清晰了!那么是否意味着我们已经可以通过两个 setTimeout 来完成这件事情呢?答案是 No,因为理论和现实之间的距离就像爱情一样。

  浏览器的渲染并不是一蹴而就的——问题就出在“连续不断的在短时间内移动一小段距离”上,要知道在这个过程中你要实时确认自己是否已经到达目标位置,那么就会涉及到读取当前的 translate 偏移量和设置新的translate的工作。如此频繁的 DOM 读写势必会导致卡顿的!我们都知道 JS 直接操作 DOM 是很昂贵了!不然 Vue 也不需要 VNode 了,对吧?那么如何优化读写的过程就成了保证“动画”流畅性的关键!

  的问题很好解决,我们可以在内部维持一个偏移量的状态值,任何对实际 DOMtranslate 值的修改都需要先反应在这个值上,类似于 VueReact 虚拟 DOM 树的作用,只不过我们这个更简单,只是一个实际偏移量的映射,这样每次就不需要从实际 DOM 中读取当前的偏移量了。

  的过程是无法避免的,不修改 DOM 用户什么变化也看不到,动画何从说起!

  我们已经知道通过 translate 使元素的发生位移相比于 定位 + left/top 的方式,它的优点在于不会导致浏览器的重排。而在这种场景下使用 translate3d 的效果也只会更差,因为通过 JS 频繁更改该属性,浏览器每次都需要比较 xyz 三个轴上的变换,强制 GPU 加速似乎成了玄学。所以当“无缝滚动”是沿着 X 方向的,那么写入的最佳方式其实是 translateX,同理 Y 轴方向是 translateY

  写入的时机是我们的主要发力点。如果你希望用户感受到的画面是连续的,那么也就意味着每 1000 / 60 ms 也就是 16.67 ms 左右就要进行一次这种写入。我们知道 setTimeout 实际上并不准确,它依靠浏览器内置时钟的更新频率,还面临这异步队列的问题,就好比下面的一段代码,我们期望 setTimeout 3 秒后打印 Done!,但实际需要 10 秒,它会被同步进程“阻塞”!

// 期望 3 秒后打印 Done!
setTimeout(function () {
    console.log("Done!");
}, 1000 * 3);
// 这个同步进程需要 10s 才能从执行栈里推出,所以 10s 后才会打印 Done!
function waitSeconds(wait) {
    var start = Date.now();
    while (start + 1000 * wait > Date.now()) {}
}
waitSeconds(10);

  得益于 requestAnimationFrame 这个 API 的存在,才使得我们通过这种思路实现流畅的“无缝滚动”成为了可能。

window.requestAnimationFrame()告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

  对 transform 的修改会导致重绘,也就意味着我们通过类似递归的方式可以形成一组连续的动画。复制下面这段代码到浏览器控制台里,体验一下页面漂移的感觉。

var target = 200
var offset = 0
function moveBody(){
	document.body.style.transform = `translateX(${++offset}px)`
	if(offset<target){
		requestAnimationFrame(moveBody)
	}
}
requestAnimationFrame(moveBody)

于是按照这个思路 seamless-scroll 就诞生了。还有更多设计细节,比如如何实现暂停继续,如何通知外部当前索引值的变化,如何揣摩用户的手势意图,如果选取最优的移动路径,比如从 第5屏 到 第2屏,按照 5,1,2 的顺序移动是优于 5,4,3,2 的顺序的,因为这才会真正形成视觉上的 “无缝” 效果,而不是倒回去。有兴趣的可以读一下我的源码。我也做了诸如添加 will-change 属性等的优化尝试,但是效果似乎不明显。欢迎大佬们批评指正,当然 PR 我是更欢迎的,特别是能显著提升性能的那种😝。接下来就简单介绍一个这款插件的使用

安装

npm i seamless-scroll
# 或者
yarn add seamless-scroll

快速开始

建议参考这个 Demo 项目, 它包括 PC 端 + 移动端的示例代码

为了插件更好的运行,页面的 DOM 结构需按照下面的约定设置:

<!-- 容器 -->
<div id="box">
  <!-- 列表 -->
  <ul>
    <!-- 子元素们 -->
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
  </ul>
  <!-- 此处可以添加“小圆点指示器”或“前进后退箭头”等 DOM 元素-->
</div>

初始化一个“无缝滚动”实例,就是这么简单🍳,一个棒棒哒💯的 banner 轮播就完成了:

// 引入插件
import SeamlessScroll from 'seamless-scroll';

// 创建实例
const scroller = new SeamlessScroll({
  el: 'box',
  direction: 'left',
  width: 375,
  height: 175,
  autoPlay: false
});

// 用户点击“开始按钮”时,调用实例的 start 方法,开始播放
const startBtn = document.getElementById('start-btn');
startBtn.addEventListener('click', function() {
  scroller.start();
});

参数

参数名 说明 可选值 默认值 必填
el 容器元素。可以是已经获取到的 DOM 对象,也可以是元素 id DOMElementString
direction 滚动的方向 left, right, up, down left
width 容器的宽度,单位 px Number
height 容器的高度,单位 px Number
delay 每屏停留的时间,单位 ms Number 3000
duration 滚动一屏需要的时间,单位 ms Number 300
activeIndex 默认显示的元素在列表中的索引,从 0 开始 Number 0
autoPlay 是否自动开始播放,如果设置为 false,稍后可以调用实例的 start 方法手动开始 Boolean true
prevent 阻止页面滚动,通常用于竖向播放的情况,设置为 true 时,可避免用户在组件内的滑动手势导致的页面上下滚动 Boolean true
onChange 屏与屏之间切换时的回调函数,入参为当前屏的索引,可用于自定义小圆点指示器这样的场景 Function

实例方法

start

非自动播放时,调用此方法可手动开始播放。只能调用一次,仅限于 autoPlayfalse 且从未开始的情况下使用。

stop

停止播放。

continue

继续播放。配合 stop 方法使用。

go

直接滚动的某个索引的位置,或者向某个方向滚动一屏。你可以借助此方法实现快速跳转或者前后切换的业务场景。该方法跳转的逻辑是选取目标屏与当前屏的最短距离进行位移,比如从 第5屏第2屏,会按照 5,1,2 的顺序移动,而不是 5,4,3,2 的顺序,这样的好处在于真正形成视觉上的 “无缝” 效果。

  • 示例:scroller.go(0)scroller.go('left')
  • 参数类型:Numberleft, right, up, down

resize

更新容器的宽高。

  • 示例:scroller.resize(375, 175) // width, height
  • 参数类型:Number,单位 px

比如下面这段代码,就是在监听到浏览器窗口大小改变后,重新设置了容器的宽高。

(function(vm) {
  var resizing,
    resizeTimer,
    requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame;

  vm.resizeHandler = function() {
    if (!resizing) {
      // 第一次触发,停止 scroller 的滚动
      resizing = true;
      scroller.stop();
    }
    resizeTimer && clearTimeout(resizeTimer);
    resizeTimer = setTimeout(() => {
      // 停下来后,重设 scroller 的宽高,并继续之前的播放
      resizing = false;
      scroller.resize(document.body.clientWidth, 300);
      requestAnimationFrame(function() {
        scroller.continue();
      });
    }, 100);
  };
  window.addEventListener('resize', vm.resizeHandler);
})(this);

不要忘记在离开页面时,清除监听器!下面是在 VuebeforeDestroy 钩子中清除对窗口变化监听的示例

beforeDestroy(){
  window.removeEventListener('resize', this.resizeHandler);
}

destroy

销毁实例,恢复元素的默认样式

下面是在 ReactcomponentWillUnmount 钩子中调用该方法的示例:

componentWillUnmount(){
  this.scroller.destroy()
}

总结

这款插件在保障流畅性的前提下,不仅支持了对用户手势意图的智能识别,也足以满足大部分 PC 端和移动端项目的业务需求。而且非常轻量,使用起来也很简单。希望能帮助到有这方面需求的小伙伴们,如果大家有好的建议也欢迎留言交流。