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

什么是“无缝滚动”

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

  

淘宝:  京东:html

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

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

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

无缝滚动的基本原理

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

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

  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,这无疑提示了性能。相信经过下面这张动态图,你应该能够明白个人意思了:npm

经过上图咱们能够发现:同一时间有且仅有两个屏在位移,每波切换过程,有三个屏的 DOM 位置发生了变化。下面的两张截图也很好的验证了个人猜测:浏览器

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

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 端和移动端项目的业务需求。并且很是轻量,使用起来也很简单。但愿能帮助到有这方面需求的小伙伴们,若是你们有好的建议也欢迎留言交流。

相关文章
相关标签/搜索