前言
大家在開發過程中,或多或少都會用到輪播圖之類的元件,PC和Mobile上使用 Swiper.js ,小程式上使用swiper元件等。
本文將詳細講解如何用Vue一步步實現的類似Swiper.js的功能,無任何第三方依賴,乾貨滿滿。
最終效果
線上預覽:https://zyronon.github.io/douyin/
專案原始碼:https://github.com/zyronon/douyin
注意:PC
必須將瀏覽器切到手機模式,先按 F12
調出控制檯,再按 Ctrl+Shift+M
才能正常預覽
Demo程式碼
上面的預覽地址是最終實現的效果,下面才是本文程式碼實現的效果
為提升閱讀體驗,正文中程式碼展示有部分省略處理,完整程式碼可以在codesandbox上檢視:
https://codesandbox.io/p/devbox/mutable-grass-zm4gl5
實現原理
佈局
我們需要用到兩個div,父元素 slide
設定 overflow: hidden 禁止滾動,子元素 slide-list
使用 flex 佈局,然後將需要滾動的頁面做為孫元素放在子元素 slide-list
中,由於子元素 slide-list
是 flex
佈局,頁面會自然的平鋪排列
因為父元素 slide
的overflow: hidden
屬性會將內容裁減,不提供捲軸,也不允許使用者滾動,所以我們只能看到父元素 slide
寬高的內容。
<div class="slide">
<div class="slide-list">
<slot></slot>
</div>
</div>
.slide {
touch-action: none;
height: 100%;
width: 100%;
transition: height 0.3s;
position: relative;
overflow: hidden;
}
.slide-list {
height: 100%;
width: 100%;
display: flex;
position: relative;
}
滑動
實現滾動的關鍵點在於CSS3的 transform: translate(0, 0) 屬性。
translate()
這個 CSS 函式在水平和/或垂直方向上重新定位元素,它的座標定義了元素在每個方向上移動了多少。
因為子元素 slide-list
的內容是平鋪的,我們只需要在子元素 slide-list
監聽對應的事件,計算滑動的距離x
或y
,再動態設定到子元素 slide-list
的transform: translate(x, y)
裡面,就可以實現頁面滑動了
總結
大家可以將整個流程理解為播放膠片電影:父元素 A
是放映機,子元素 B
是膠片,而頁面
是印刷在膠片上的內容。膠片每移動一格,我們就能看到新的一幀電影
實現
監聽事件
PC 上的點選、移動,H5 的手勢操作,都離不開 DOM 事件監聽。例如滑鼠移動事件對應 mousemove
,移動端因為沒有滑鼠則對應 touchmove
我們可以透過 Pointer 事件進行多端統一的事件監聽,實現觸屏和 PC 端通用
<div class="slide horizontal">
<div
class="slide-list"
ref="wrapperEl"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
>
<slot></slot>
</div>
</div>
初始化
元件預設變數
//slide-list的ref引用
const wrapperEl = ref(null)
const state = reactive({
judgeValue: 20,//一個用於判斷滑動朝向的固定值
type: SlideType.VERTICAL,//元件型別
name: props.name,
localIndex: props.index,//當前下標
needCheck: true,//是否需要檢測,每次按下都需要檢測,up事件會重置為true
next: false,//能否滑動
isDown: false,//是否按下,用於move事件判斷
start: {x: 0, y: 0, time: 0},//按下時的起點座標
move: {x: 0, y: 0},//移動時的座標
wrapper: {width: 0, height: 0, childrenLength: 0}//slide-list的寬度和子元素數量
})
function slidePointerDown(e, el, state) {
Utils.$setCss(el, 'transition-duration', `0ms`)
//記錄起點座標,用於move事件計算移動距離
state.start.x = e.pageX
state.start.y = e.pageY
//記錄按下時間,用於up事件判斷滑動時間
state.start.time = Date.now()
state.isDown = true
}
雖然我們用 Pointer事件
統一了移動端和PC端的監聽事件,但 pointermove
事件在 PC
和移動端表現出來的效果卻不一樣,在 PC
上, pointermove
事件和 mousemove
事件一致,只要滑鼠在目標元素上方,就會觸發。而在移動端上卻只有按下並移動時發才會觸發
所以這裡用一個 isDown
的變數儲存是否按下的狀態,pointermove
事件雖然會一直觸發,但僅當 isDown
時才執行我們的程式碼邏輯
移動過程
function slidePointerMove(e,el,state) {
if (!state.isDown) return;
//計算移動距離
state.move.x = e.pageX - state.start.x
state.move.y = e.pageY - state.start.y
//檢測能否滑動
let canSlideRes = canSlide(state)
//是否是往下(右)滑動
let isNext = state.type === SlideType.HORIZONTAL ? state.move.x < 0 : state.move.y < 0
if (canSlideRes) {
if (canNext(state, isNext)) {
//能滑動,那就把事件捕獲,不能給父元件處理
Utils.$stopPropagation(e)
//獲取偏移量
let t = getSlideOffset(state, el) + (isNext ? state.judgeValue : -state.judgeValue)
let dx1 = 0,
dx2 = 0
//偏移量加當前手指移動的距離就是slide要偏移的值
if (state.type === SlideType.HORIZONTAL) {
dx1 = t + state.move.x
} else {
dx2 = t + state.move.y
}
Utils.$setCss(el, 'transition-duration', `0ms`)
Utils.$setCss(el, 'transform', `translate(${dx1}px, ${dx2}px)`)
}
}
}
用滑鼠當前的位置,再減去滑鼠按下時的位置,就是滑鼠移動的距離
移動距離再加上當前頁面 * 每個頁面的寬或高,即子元素 slide-list
整體要偏移的量
技術難點
1. 如何判斷滑動方向?是在上下滑還是左右滑?
//檢測在對應方向上能否允許滑動,比如SlideHorizontal元件就只處理左右滑動事件,SlideVertical
//只處理上下滑動事件
export function canSlide(state) {
//每次按下都需要檢測,up事件會重置為true
if (state.needCheck) {
//判斷move x和y的距離是否大於判斷值,因為距離太小無法判斷滑動方向
if (Math.abs(state.move.x) > state.judgeValue || Math.abs(state.move.y) > state.judgeValue) {
//放大再相除,根據長寬比判斷方向,angle大於1就是左右滑動,小於是上下滑動
let angle = (Math.abs(state.move.x) * 10) / (Math.abs(state.move.y) * 10)
//根據當前slide的型別,判斷能否滑動,並記錄下來,後續不再判斷,直接返回記錄值
state.next = state.type === SlideType.HORIZONTAL ? angle > 1 : angle <= 1
state.needCheck = false
} else {
return false
}
}
return state.next
}
放大移動距離後再相除,根據結果是否大於1判斷出滑動方向
2. 如何處理巢狀元件中的事件衝突?什麼時候攔截事件和放行事件?
由於事件的冒泡機制,事件是從最裡面的元素一級一級的往上冒泡的,所以我們只需在滿足下面兩個條件時攔截事件即可
- 是否在往到頭或尾滑動
如果在第一頁,不能往左/上滑動
如果在最後一面, 不能往右/下滑動
function canNext(state, isNext) {
return !(
(state.localIndex === 0 && !isNext) ||
(state.localIndex === state.wrapper.childrenLength - 1 && isNext)
)
}
- 滑動方向和元件型別相匹配
SlideHorizontal.vue
元件只允許向左/右滑動SlideVertical.vue
元件只允許向上/下滑動
滿足上述兩個條件時攔截事件,不滿足放行事件,交給上一級元件處理
//檢測在對應方向上能否允許滑動
let canSlideRes = canSlide(state)
//是否是往下(右)滑動
let isNext = state.type === SlideType.HORIZONTAL ? state.move.x < 0 : state.move.y < 0
if (canSlideRes) {
if (canNext(state, isNext)) {
//能滑動,那就把事件捕獲,不能給父元件處理
Utils.$stopPropagation(e)
...
滑動邏輯
...
}
}
結束滑動
function slidePointerUp(e, state) {
if (!state.isDown) return;
let isHorizontal = state.type === SlideType.HORIZONTAL
let isNext = isHorizontal ? state.move.x < 0 : state.move.y < 0
if (state.next) {
if (canNext(state, isNext)) {
//結合時間、距離來判斷是否成功滑動
let endTime = Date.now()
let gapTime = endTime - state.start.time
let distance = isHorizontal ? state.move.x : state.move.y
let judgeValue = isHorizontal ? state.wrapper.width : state.wrapper.height
//1、距離太短,直接不透過
if (Math.abs(distance) < 20) gapTime = 1000
//2、距離太長,直接透過
if (Math.abs(distance) > judgeValue / 3) gapTime = 100
//3、若不在上述兩種情況,那麼只需要判斷時間即可
if (gapTime < 150) {
if (isNext) state.localIndex++
else state.localIndex--
}
}
}
// 重置變數
Utils.$setCss(el, 'transition-duration', `300ms`)
let t = getSlideOffset(state, el)
let dx1 = 0,dx2 = 0
if (state.type === SlideType.HORIZONTAL) dx1 = t
else dx2 = t
Utils.$setCss(el, 'transform', `translate3d(${dx1}px, ${dx2}px, 0)`)
...
}
技術難點
- 如何讓滑動結束時的動畫更絲滑?
結合滑動時間、滑動距離來判斷滑動下一條還是保持當前條
1、距離太短,直接不透過
2、距離太長,直接透過
3、若不在上述兩種情況,那麼只需要判斷時間即可,小於150毫秒以內就算是成功滑動
其他問題
PC
上滑動有圖片的頁面,圖片“分叉”了:我們開始拖動它的“克隆”
這是因為瀏覽器有自己的對圖片和一些其他元素的拖放處理。它會在我們進行拖放操作時自動執行,並與我們的拖放處理產生了衝突
禁用它:
@dragstart="(e) => Utils.$stopPropagation(e)"
PC
上滑動結束後觸發了click事件
問題分析
首先我們滑動是利用 pointerdown
, pointermove
, pointerup
三個事件組合形成的,但是 pointerup
執行之後, click
是一定會執行的,是無法避免的,是無法用preventDefault
, stopPropagation
, stopImmediatePropagation
阻止的, 因為pointer
事件和 click
事件本身就不是一個系列的,因此沒有關係,所以當發生滑動之後,pointerup
一定會執行,click
也會在 pointerup
執行後執行
解決方案
我們設定一個全域性變數
window.isMoved = false
在 pointermove
事件中,將 window.isMoved
設為 true
。然後在 pointerup
事件中,我們用一個定時器讓這個變數在200毫秒之後發生改變為 false
,因為 pointerup
之後 click
很快就觸發了,不到200ms,因此可以保證變數還沒有發生變化,click
事件裡面去檢測這個變數,如果是變化之前,那麼不執行
如果 click
事件少還好說,直接複製幾遍無所謂。
但是一般來說 click
事件在專案中使用還是挺多的,有沒有什麼一勞永逸的辦法呢?
大部分監聽 click
事件都是用 Vue
的 @click
新增的,我們無法插手
這時給大家介紹一下 Proxy 這個物件了,Vue3
的雙向繫結就用到了 Proxy
物件。
在專案入口,我們直接代理 HTMLElement.prototype.addEventListener
這個事件,代理了之後,Vue
的 @click
語法糖新增事件時就會通知我們,這時再進行判斷是不是 click
事件,是的話再判斷 window.isMoved
的狀態
window.isMoved = false
HTMLElement.prototype.addEventListener = new Proxy(HTMLElement.prototype.addEventListener, {
apply(target, ctx, args) {
const eventName = args[0]
const listener = args[1]
if (listener instanceof Function && eventName === 'click') {
args[1] = new Proxy(listener, {
apply(target, ctx, args) {
if (window.isMoved) return
try {
return target.apply(ctx, args)
} catch (e) {
console.error(`[proxyPlayerEvent][${eventName}]`, listener, e)
}
}
})
}
return target.apply(ctx, args)
}
})
設定了 overflow: auto
的頁面在移動端不觸發 pointermove
事件
再設定一個 touch-action:pan-y
就正常了
CSS 屬性 touch-action
用於設定觸控式螢幕使用者如何操縱元素的區域 (例如,瀏覽器內建的縮放功能), pan-y
啟用單指垂直平移手勢
總結
核心程式碼加上註釋一共217行,我們實現了一個可以在 PC
和 Mobile
上通用,並且可以無限巢狀的輪播元件
結束
以上就是文章的全部內容,感謝看到這裡,希望對你有所幫助或啟發!創作不易,如果覺得文章寫得不錯,可以點贊收藏支援一下,也歡迎關注我的公眾號,我會更新更多實用的前端知識與技巧,期待與你共同成長~