造輪子之圖片輪播元件(swiper)

清夜發表於2019-01-03

圖片輪播是種很常見的場景和功能,一般移動網站首頁的輪播 banner,商品閒情頁的商品圖片等位置都會用到此功能

像這種常用的場景功能肯定是有人早就寫好外掛了的,所以遇到這種場景,一般都遵循以下三步:

  • 開啟冰箱 啟動 Github
  • 搜尋 swipersliderAlbum等關鍵字
  • 找到想要的庫,npm install

這種做法沒毛病,有現成的輪子可用當然拿來主義,因為專案用的是 vue,所以我在網上找了一圈 基於 vue的輪播元件庫,找到了兩個比較滿意的庫:vue-awesome-swipervue-swipe

比較知名的輪播框架,一般都會優先使用這個庫,功能豐富,適用於各種輪播場景,什麼 左右按鈕,動態指示點、進度條指示器、垂直切換、一次性顯示多個 slides……功能簡直不要太完善 but 我只是想用其中一小部分基本功能而已,如此多的功能於我而言不僅是看文件費勁,更關鍵的是會在專案中引入太多的冗餘程式碼,好不容易通過各種手段將程式碼體積降下來,結果就因為引入了一個包一下回到解放前,要不得要不得

餓了麼前端團隊出品的一個庫,比較精簡,程式碼量也很少,但又過於精簡了,例如不支援無限輪播,不支援自定義 swiperItem,而且總感覺有些生硬的感覺

至於其他本人能夠搜尋到的庫,都沒什麼名氣或者下載量太小,不敢輕易在生產環境引入,於是就萌生了自己造個輪子來搞定這件事,這樣組價庫的功能和程式碼體積自己都能控制,就算有什麼 bug也能很快自行修正

先看下最終實現效果:

造輪子之圖片輪播元件(swiper)

或者你想自己體驗一下,這裡也有個寫好的 Demo

我已經將此功能打包成了一個 npm package,可直接下載安裝使用,包括樣式在內的程式碼體積壓縮後不到 18KB,Gzipped之後不到 7KB原始碼 已上傳

滑動形式

為了描述方便,先定義一下名詞,將每一個滑動小塊稱為 swiperItem,將容納所有滑動小塊的容器稱為 swiper

造輪子之圖片輪播元件(swiper)

目前大多數的滑動元件庫,都是通過兩種方式實現元件的滑動的

第一種,同一時間只渲染三個 swiperItem,每次滑動到下一個 swiperItem之後,立即更新這三個 swiperItem

這種做法的優點是,無論有多少個 swiperItem都不會影響到瀏覽器的渲染效能,因為無論多少個,每次都只渲染其中的三個,缺點在於如果 swiperItem的數量本來就少於三個,就需要額外的處理了,而且因為每次最多隻能滑動一個 swiperItem 的距離,使用起來不是那麼順滑,vue-swipe採用的是這種

第二種,一次性渲染所有的 swiperItem,並且有時候為了更順滑的體驗,還會在原 swiperItem的首尾,再各新增一個 swiperItem 例如,原 swiperItem的資料為 1, 2, 3, 4, 5,處理之後變成 5, 1, 2, 3, 4, 5, 1vue-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動態元件 firstSwiperItemlastSwiperItem,就是上面說的 5,1,2,3,4,5,1中的 51

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原本的軌跡:

造輪子之圖片輪播元件(swiper)

大概看了下,似乎 vue-awesome-swipervue-swipe 都沒有提供這種能力,雖說無傷大雅,但就因為少了這一個能力,總感覺就沒有原生的那種順滑的體驗,所以我決定加上

針對這個功能,一開始是想將 自動滑動 的這個動作,使用 js來動態計算,利用 requestAnimationFrame來模擬自動滑動的動畫效果,這樣就能夠很方便地獲取任何時刻 swiperItemtranslate數值了,接下來實現攔截的能力也就很簡單了

但後來又考慮到用 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
}
複製程式碼

總結

在冒出要自己動手造輪子的念頭時候,覺得這個輪子沒什麼難度,快的話一天慢點三天也差不多了,然而真正開始動手開發的時候,才發現沒那麼簡單,因為只有工作之餘才有時間做這個東西,所以最終愣是搗鼓了一星期都還沒搞定,主體部分的程式碼很快寫完,但解決各種異常情況和自測卻佔據了絕大部分的時間,不過不管怎麼說,最終還是做完了

原始碼已經放到 github上了,程式碼註釋得也算是比較詳細,感興趣的可以參考下,如果有什麼問題,歡迎提 issues

相關文章