如何開發一款 60fps 的“無縫滾動”外掛

鄭昊川發表於2019-03-25

什麼是“無縫滾動”

所謂的“無縫滾動”就是多屏切換的過程是連續可迴圈的,而不是到最後一屏就停止播放。這種業務場景在實際開發中很常見,下面是“淘寶”和“京東” H5 版的首頁截圖,裡面的 “banner 圖”以及“頭條欄”就是典型的無縫滾動的場景。但是體驗一番之後,你會發現他們和原生 App 中的效果還是有一定差距的。你可以掃碼開啟在自己手機上體驗一下,然後再開啟他們的 App 劃一劃試一試,你會發現 H5 版本的似乎少了點什麼

如何開發一款 60fps 的“無縫滾動”外掛  如何開發一款 60fps 的“無縫滾動”外掛

淘寶:如何開發一款 60fps 的“無縫滾動”外掛  京東:如何開發一款 60fps 的“無縫滾動”外掛

你可能發現了!H5 版的似乎少了對使用者手勢意圖的判斷:比如下圖中的場景,如果在淘寶 H5 版的 banner 上你慢慢的左右晃動,它只會簡單的比較 touchstarttouchend 事件觸發時的橫座標來決定向哪個方向前進一屏。而如果在原生 App 上這麼做,在結束時,他會回彈到佔據當前螢幕大部分面積的那一屏,也就是第一屏,而當你是用手指快速掃過時,同樣的位置,他則會切換到第二屏。

如何開發一款 60fps 的“無縫滾動”外掛

相比之下京東的 H5 體驗會稍差一些,你在滑動的過程中他根本就“不跟手”,只是當你停止後,才判定方向(本文寫於 2019 年 3 月,隨著網站的升級,體驗可能會有所不同)。

作為標杆型的大廠,自己同樣的產品在 App 端 和 H5 端表現的差異性,他們自己肯定是知道的,但是為什麼沒有做到一致呢?想來用前端的技術去實現這個應該是需要一點額外的開發成本的或者存在卡頓等體驗問題的。讓我們來大致分析一下他們目前是通過什麼方式實現“無縫滾動”的。但在開始之前我們先了解一下:

無縫滾動的基本原理

如何開發一款 60fps 的“無縫滾動”外掛

如上圖所示,我們將 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,這無疑提示了效能。相信通過下面這張動態圖,你應該可以明白我的意思了:

如何開發一款 60fps 的“無縫滾動”外掛

通過上圖我們可以發現:同一時間有且僅有兩個屏在位移,每波切換過程,有三個屏的 DOM 位置發生了變化。下面的兩張截圖也很好的驗證了我的猜想:

如何開發一款 60fps 的“無縫滾動”外掛
如何開發一款 60fps 的“無縫滾動”外掛

阿里畢竟是阿里,大佬畢竟是大佬,不得不佩服!相比之下,京東就粗糙了些,用的是我最開始介紹的那種基本原理實現的。雖然兩者在實現無縫滾動的原理上存在差異,但是藉助的技術基本上都是我上面列出的四條。淘寶的實現演算法雖然很好,但是有一個致命的問題,他很難滿足點選切換的需求,如果下面的“小圓點指示器”是可以點選跳轉的,你試想一下他怎麼從第二屏跳轉到第四屏?但這種需求在 PC 端的“無縫滾動” 中很常見,作為一名開發者你不得不想在前面。而兩者也都存在我開篇提到的缺少對使用者手勢意圖揣摩的問題,所以是時候推出新的解決方案了:

seamless-scroll

這是我最近折騰的一款無縫滾動外掛,它同時滿足移動端和 PC 端的開發場景,藉助 requestAnimationFrametranslate 實現。提供類似原生 App 的體驗,新增了對“快速滑動切換”和“緩慢拖動”等手勢場景的處理。不依賴任何現存的框架或元件庫,純 JS ,也就意味著你無論在 Vue 還是 React 專案中都可以直接使用。支援 npm 安裝 和 CDN 連結 引入,?Gzip Size< 3KB,支援 IE10+IOS9+Andorid5+ 和現代瀏覽器。使用起來也很簡單,它會暴露一個 SeamlessScroll 的建構函式,你可以藉助 new 關鍵字建立一個“無縫滾動”例項,通過傳遞引數,你可以自定義動畫速度、是否自動播放等行為,建立的例項也提供 startstopgo 等方法讓你可以方便的控制播放的啟動停止或者直接跳轉到某個索引位置等。

Github 倉庫地址掃碼體驗移動端點選預覽 PC 端在 React 中使用的示例程式碼

如何開發一款 60fps 的“無縫滾動”外掛

真機 iPhone 和 小米5 上測試過,體驗還是非常流暢的,下圖是谷歌瀏覽器 Performance 皮膚的截圖,上方 FPS 一欄形成了連續穩定的 5 個綠色小塊,反應了5次移動過程中的 FPS 的變化。這些綠色小柱越高表示幀率越高,體驗就越流暢,反之如果出現紅色小柱,則很可能存在卡頓。

如何開發一款 60fps 的“無縫滾動”外掛

下面我就介紹一下我的實現思路:

  首先選取基本的實現原理:上面介紹的“淘寶式”和“京東式”兩種“無縫滾動”原理,因為要滿足直接跳轉的需求,所以選擇了後者。

  技術選型再思考:上面介紹了在實現“無縫滾動”中需要用到的四個技術點,1,2,4依然適用,但在“動若脫兔”的環節我們也許可以換個思路。上面我們說到這個過渡動畫可以利用 transition 來實現,它的表現非常流暢。不過我們知道動畫的本質其實就是一組連續運動的畫面,既然如此,我們是否可以通過連續不斷的在短時間內移動一小段距離來實現類似動畫的效果呢?當然可以。我們不妨把“無縫滾動”的過程抽象為兩大狀態的迴圈組合——靜止狀態和動畫狀態

  靜止狀態下我們通過定時器延遲一段時間後開啟下一波的動畫狀態,併為這個動畫狀態確認目標位置,而在動畫狀態下我們一步一步小心的“挪動”,隨時關注自己是否已經到達了目標位置,如果到達了,我們就停止,重新迴歸靜止狀態,並由它確認我們下一波的移動。思路已經很清晰了!那麼是否意味著我們已經可以通過兩個 setTimeout 來完成這件事情呢?答案是 No,因為理論和現實之間的距離就像愛情一樣。

  瀏覽器的渲染並不是一蹴而就的——問題就出在“連續不斷的在短時間內移動一小段距離”上,要知道在這個過程中你要實時確認自己是否已經到達目標位置,那麼就會涉及到讀取當前的 translate 偏移量和設定新的translate的工作。如此頻繁的 DOM 讀寫勢必會導致卡頓的!我們都知道 JS 直接操作 DOM 是很昂貴了!不然 Vue 也不需要 VNode 了,對吧?那麼如何優化讀寫的過程就成了保證“動畫”流暢性的關鍵!

如何開發一款 60fps 的“無縫滾動”外掛

  的問題很好解決,我們可以在內部維持一個偏移量的狀態值,任何對實際 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 端和移動端專案的業務需求。而且非常輕量,使用起來也很簡單。希望能幫助到有這方面需求的小夥伴們,如果大家有好的建議也歡迎留言交流。

相關文章