如何開發一款 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 端和移動端項目的業務需求。並且很是輕量,使用起來也很簡單。但願能幫助到有這方面需求的小夥伴們,若是你們有好的建議也歡迎留言交流。

相關文章
相關標籤/搜索