造輪子之圖片輪播元件(swiper)
圖片輪播是種很常見的場景和功能,一般移動網站首頁的輪播 banner
,商品閒情頁的商品圖片等位置都會用到此功能
像這種常用的場景功能肯定是有人早就寫好外掛了的,所以遇到這種場景,一般都遵循以下三步:
開啟冰箱啟動 Github- 搜尋
swiper
、slider
、Album
等關鍵字 - 找到想要的庫,
npm install
之
這種做法沒毛病,有現成的輪子可用當然拿來主義,因為專案用的是 vue
,所以我在網上找了一圈 基於 vue
的輪播元件庫,找到了兩個比較滿意的庫:vue-awesome-swiper、vue-swipe
比較知名的輪播框架,一般都會優先使用這個庫,功能豐富,適用於各種輪播場景,什麼 左右按鈕,動態指示點、進度條指示器、垂直切換、一次性顯示多個 slides
餓了麼前端團隊出品的一個庫,比較精簡,程式碼量也很少,但又過於精簡了,例如不支援無限輪播,不支援自定義 swiperItem
,而且總感覺有些生硬的感覺
至於其他本人能夠搜尋到的庫,都沒什麼名氣或者下載量太小,不敢輕易在生產環境引入,於是就萌生了自己造個輪子來搞定這件事,這樣組價庫的功能和程式碼體積自己都能控制,就算有什麼 bug
先看下最終實現效果:
或者你想自己體驗一下,這裡也有個寫好的 Demo
我已經將此功能打包成了一個
npm package
,可直接下載安裝使用,包括樣式在內的程式碼體積壓縮後不到18KB
,Gzipped之後不到7KB
,原始碼 已上傳
滑動形式
為了描述方便,先定義一下名詞,將每一個滑動小塊稱為 swiperItem
,將容納所有滑動小塊的容器稱為 swiper
:
目前大多數的滑動元件庫,都是通過兩種方式實現元件的滑動的
第一種,同一時間只渲染三個 swiperItem
,每次滑動到下一個 swiperItem
之後,立即更新這三個 swiperItem
這種做法的優點是,無論有多少個 swiperItem
都不會影響到瀏覽器的渲染效能,因為無論多少個,每次都只渲染其中的三個,缺點在於如果 swiperItem
的數量本來就少於三個,就需要額外的處理了,而且因為每次最多隻能滑動一個 swiperItem
的距離,使用起來不是那麼順滑,vue-swipe採用的是這種
第二種,一次性渲染所有的 swiperItem
,並且有時候為了更順滑的體驗,還會在原 swiperItem
的首尾,再各新增一個 swiperItem
例如,原 swiperItem
的資料為 1, 2, 3, 4, 5
,處理之後變成 5, 1, 2, 3, 4, 5, 1
,vue-awesome-swiper採用的是這種
優點在於使用起來更順滑,缺點是如果資料量很多,比如有幾百幾千個的資料量,會影響到瀏覽器的渲染效能,但一般情況下也不會有那麼大的資料量,幾十個都已經很少了
綜合考慮之下,本人決定採用第二種
資料處理
本元件庫提供了兩種傳入 swiperItem
資料的方式
- 第一種是直接通過
props
傳入一個圖片的陣列
一般來說,輪播元件主要元素都只是一張展示用的圖片,所以直接通過 props
傳入圖片陣列的方式基本上可以滿足大部分需求
<swiper :urlList="urlList" />
複製程式碼
對於這種情況下的首尾追加操作就比較簡單,其實就是操作一個數組:
this.currentList = this.urlList.length > 1
? this.urlList.slice(-1).concat(this.urlList, this.urlList.slice(0, 1)).map((url, index) => ({ url, _id: index }))
: this.urlList.map((url, index) => ({ url, _id: index }))
複製程式碼
然後直接渲染到模板上即可:
<div class="img-box" v-for="item in currentList" :key="item._id" :style="{
backgroundImage: `url(${item.url})`,
backgroundSize
}"></div>
複製程式碼
順便說下關於圖片佈局的問題,我沒有直接寫個 img
元素而是將圖片當成了背景圖渲染,這種處理的好處在於,可以很輕鬆地實現對圖片無論是長寬大小還是位置的 UI
控制,想要圖片完全顯示那就 background-size: contain
,想要完全充滿那就 background-size: cover
,或者直接具體到畫素的調整,水平垂直居中也根本不用什麼 display: flex;
,這東西在某些情況的某些裝置上很容易出現相容問題,直接 background-position: 50%;
搞定
延伸開來,平時做需求碰到一些小 icon
的佈局,也完全可以採用這種方式,對齊起來非常順手,根本不用拿什麼 vertical-align
慢慢調,也不會有任何相容問題
- 第二種是接收
swiperItem
子元件
這種方式給了開發者很高的定製化空間,能夠自定義 swiperItem
的內容而不僅限於一張圖片,但做起啦稍微有點麻煩,因為 slot
作為元件層面的東西,不太好動態處理,難不成直接操縱原生API
?可以是可以,但既然都已經用框架了,再直接改 DOM
似乎氣氛有點不太對……糾結許久,後來想到了動態元件 component
以及 render
函式,這才解決
主要思路就是傳入 swiperItem
當成 slot
正常渲染在 swiper
這個父元件內,但與此同時,在slot
的前後,再各渲染一個 component
動態元件:
<swiper>
<swiperItem />
<swiperItem />
<swiperItem />
</swiper>
複製程式碼
<!-- 這是 swiper父元件 -->
<component :is="firstSwiperItem"></component>
<slot></slot>
<component :is="lastSwiperItem"></component>
複製程式碼
這兩個放在 slot
前後位置的 component
動態元件 firstSwiperItem
和 lastSwiperItem
,就是上面說的 5,1,2,3,4,5,1
中的 5
和 1
:
updateChild (slots) {
this.firstSwiperItem = {
render (h) {
return h('div', {
staticClass: 'swiper-item-box'
}, slots.slice(-1))
}
}
this.lastSwiperItem = {
render (h) {
return h('div', {
staticClass: 'swiper-item-box'
}, slots.slice(0, 1))
}
}
}
複製程式碼
其實一開始我是想通過 template
來解決這件事的,更簡單一點,但因為要使用 template
就必須引用同時包含執行時和編譯器的完整版本的 vue
,價效比太低,也不適合生產環境,所以最終還是選擇了 render
函式
touch事件
對 touch
事件的監聽,結合 translate3d
實時改變位移,就是滑動的精髓所在
在
touchstart
事件中記錄起始位置座標,在touchmove
事件中計算距離差進行實時位置的改變,在touchend
中進行收尾
邏輯上是很清晰的,但一些細節方面的東西處理起來還是有點頭疼的
例如,如果使用者用多隻手指操作的怎麼辦?如果 touchstart
的時候用是兩指,touchmove
的時候就剩下單指怎麼辦?如果使用者先左滑右滑,怎麼判斷相比於初始到底是左滑還是右滑?如果連續滑過多個 swiperItem
,怎麼判斷結束時到底是左滑還是右滑……
如果使用者老老實實按照 最佳操作指南 來使用,這些問題當然不存在,但是你不可能要求使用者這麼做的,所以就必須解決這些問題
對於多指操作的問題,我一律以 e.touches
列表中最後一個為準:
stStartX = e.touches[touchCount - 1].clientX
複製程式碼
左滑右滑的問題,則通過 diffX
與基準值 criticalWidth
的比較,結合滑動座標 toX
進行雙重判斷,在程式碼量儘量少的情況下得出結論:
// diffX 大於0 說明是右滑,小於0 則是左滑
if (diffX > 0) {
stDirectionFlag = -1
stAutoNext = diffX > criticalWidth
toX = stAutoNext ? -clientW * (activeIndex - 1) : -clientW * activeIndex
} else if (diffX < 0) {
stDirectionFlag = 1
stAutoNext = Math.abs(diffX) > criticalWidth
toX = stAutoNext ? -clientW * (activeIndex + 1) : -clientW * activeIndex
} else {
stDirectionFlag = 0
stAutoNext = false
toX = -clientW * activeIndex
}
複製程式碼
連續滑過多個 swiperItem
,則將其處理成通常情況,也就是隻滑過最多一個 swiperItem
的情況進行處理:
// 如果連續滑過超過一個 swiperItem 塊
if (Math.abs(diffX) > clientW) {
activeIndex = Math.ceil(-this.transX / clientW)
diffX = diffX - clientW * wholeBlock
}
複製程式碼
更接近原生的順滑體驗
一些移動端原生的輪播元件,都提供了一種滑動攔截的能力,具體就是,滑動一個 swiperItem
,然後手指離開,這個 swiperItem
會自動滑動到固定的位置,但你可以通過手指觸控或再次滑動打斷這個過程,改變 swiperItem
原本的軌跡:
大概看了下,似乎 vue-awesome-swiper 和 vue-swipe 都沒有提供這種能力,雖說無傷大雅,但就因為少了這一個能力,總感覺就沒有原生的那種順滑的體驗,所以我決定加上
針對這個功能,一開始是想將 自動滑動 的這個動作,使用 js
來動態計算,利用 requestAnimationFrame
來模擬自動滑動的動畫效果,這樣就能夠很方便地獲取任何時刻 swiperItem
的 translate
數值了,接下來實現攔截的能力也就很簡單了
但後來又考慮到用 js
模擬動畫的價效比太低了,實際生產過程中很容易碰到卡頓的情況,於是轉向了另外一種實現
自動滑動的動畫交給 css
來處理,當手指觸控正在滑動中的 swiperItem
時,通過 getBoundingClientRect API
獲取實時位置
getBoundingClientRect API
的相容性已經很好了,用於實際生產環境基本上沒什麼問題,不過考慮到無論怎麼說,也還是會有一些老舊裝置不支援這個 API
,所以我也做了降級處理:
const isSupportGetBoundingClientRect = typeof document.documentElement.getBoundingClientRect === 'function'
// ...
if (this.isTransToX) {
if (!isSupportGetBoundingClientRect) {
return touchStatus = 0
}
this.isTransToX = false
this.transX = stPrevX = this.$refs.sliderWrapper.getBoundingClientRect().left - this.$refs.swiperContainer.getBoundingClientRect().left
}
複製程式碼
總結
在冒出要自己動手造輪子的念頭時候,覺得這個輪子沒什麼難度,快的話一天慢點三天也差不多了,然而真正開始動手開發的時候,才發現沒那麼簡單,因為只有工作之餘才有時間做這個東西,所以最終愣是搗鼓了一星期都還沒搞定,主體部分的程式碼很快寫完,但解決各種異常情況和自測卻佔據了絕大部分的時間,不過不管怎麼說,最終還是做完了