前言
在家沒事的時候刷抖音玩,抖音首頁的影片怎麼刷也刷不完,經常不知不覺的一刷就到半夜了😅
不禁感嘆道 "垃圾抖音,費我時間,毀我青春😅"
這是我的 模仿抖音 系列文章的第二篇,本文將一步步實現抖音首頁 影片無限滑動
的效果,乾貨滿滿
第一篇:200行程式碼實現類似Swiper.js的輪播元件
第三篇:Vue 路由使用介紹以及新增轉場動畫
第四篇:Vue 有條件路由快取,就像傳統新聞網站一樣
第五篇:Github Actions 部署 Pages、同步到 Gitee、翻譯 README 、 打包 docker 映象
如果您對滑動原理不太熟悉,推薦先看我的這篇文章:200行程式碼實現類似Swiper.js的輪播元件
最終效果
線上預覽:dy.ttentau.top/
Github地址:https://github.com/zyronon/douyin
原始碼:SlideVerticalInfinite.vue
實現原理
無限滑動的原理和虛擬滾動的原理差不多,要保持 SlideList
裡面永遠只有 N
個 SlideItem
,就要在滑動時不斷的刪除和增加 SlideItem
。
滑動時調整 SlideList
的偏移量 translateY
的值,以及列表裡那幾個 SlideItem
的 top
值,就可以了
為什麼要調整 SlideList
的偏移量 translateY
的值同時還要調整 SlideItem
的 top
值呢?
因為 translateY
只是將整個列表移動,如果我們列表裡面的元素是固定的,不會變多和減少,那麼沒關係,只調整 translateY
值就可以了,上滑了幾頁就減幾頁的高度,下滑同理
但是如果整個列表向前移動了一頁,同時前面的 SlideItem
也少了一個,,那麼最終效果就是移動了兩頁...因為 塌陷
了一頁
這顯然不是我們想要的,所以我們還需要同時調整 SlideItem
的 top
值,加上前面少的 SlideItem
的高度,這樣才能顯示出正常的內容
步驟
定義
virtualTotal
:頁面中同時存在多少個 SlideItem
,預設為 5
。
//頁面中同時存在多少個SlideItem
virtualTotal: {
type: Number,
default: () => 5
},
設定這個值可以讓外部元件使用時傳入,畢竟每個人的需求不同,有的要求同時存在 10
條,有的要求同時存在 5
條即可。
不過同時存在的數量越大,使用體驗就越好,即使使用者快速滑動,我們依然有時間處理。
如果只同時存在 5
條,使用者只需要快速滑動兩次就到底了(因為螢幕中顯示第 3
條,剛開始除外),我們可能來不及新增新的影片到最後
render
:渲染函式,SlideItem
內顯示什麼由render
返回值決定
render: {
type: Function,
default: () => {
return null
}
},
之所以要設定這個值,是因為抖音首頁可不只有影片,還有圖集、推薦使用者、廣告等內容,所以我們不能寫死顯示影片。
最好是定義一個方法,外部去實現,我們內部去呼叫,拿到返回值,新增到 SlideList
中
list
:資料列表,外部傳入
list: {
type: Array,
default: () => {
return []
}
},
我們從 list
中取出資料,然後呼叫並傳給 render
函式,將其返回值插入到 SlideList中
初始化
watch(
() => props.list,
(newVal, oldVal) => {
//新資料長度比老資料長度小,說明是重新整理
if (newVal.length < oldVal.length) {
//從list中取出資料,然後呼叫並傳給render函式,將其返回值插入到SlideList中
insertContent()
} else {
//沒資料就直接插入
if (oldVal.length === 0) {
insertContent()
} else {
// 走到這裡,說明是透過介面載入了下一頁的資料,
// 為了在使用者快速滑動時,無需頻繁等待請求介面載入資料,給使用者更好的使用體驗
// 這裡額外載入3條資料。所以此刻,html裡面有原本的5個加新增的3個,一共8個dom
// 使用者往下滑動時只刪除前面多餘的dom,等滑動到臨界值(virtualTotal/2+1)時,再去執行新增邏輯
}
}
}
)
用 watch
監聽 list
是因為它一開始不一定有值,透過介面請求之後才有值
同時當我們下滑 載入更多
時,也會觸發介面請求新的資料,用 watch
可以在有新資料時,多新增幾條到 SlideList
的最後面,這樣使用者快速滑動也不怕了
如何滑動
這裡就不再贅述,參考我的這篇文章:200行程式碼實現類似Swiper.js的輪播元件
滑動結束
判斷滑動的方向
當我們向上滑動時,需要刪除最前面的 dom
,然後在最後面新增一個 dom
下滑時反之
slideTouchEnd(e, state, canNext, (isNext) => {
if (props.list.length > props.virtualTotal) {
//手指往上滑(即列表展示下一條影片)
if (isNext) {
//刪除最前面的 `dom` ,然後在最後面新增一個 `dom`
} else {
//刪除最後面的 `dom` ,然後在最前面新增一個 `dom`
}
}
})
手指往上滑(即列表展示下一條影片)
- 首先判斷是否要載入更多,快到列表末尾時就要載入更多資料了
- 再判斷是否符合
騰挪
的條件,即當前位置要大於half
,且小於列表長度減half
。 - 在最後面新增一個
dom
- 刪除最前面的
dom
- 將所有
dom
設定為最新的top
值(原因前面有講,因為刪除了最前面的dom
,導致塌陷一頁,所以要加上刪除dom
的高度)
let half = (props.virtualTotal - 1) / 2
//刪除最前面的 `dom` ,然後在最後面新增一個 `dom`
if (state.localIndex > props.list.length - props.virtualTotal && state.localIndex > half) {
emit('loadMore')
}
//是否符合 `騰挪` 的條件
if (state.localIndex > half && state.localIndex < props.list.length - half) {
//在最後面新增一個 `dom`
let addItemIndex = state.localIndex + half
let res = slideListEl.value.querySelector(`.${itemClassName}[data-index='${addItemIndex}']`)
if (!res) {
slideListEl.value.appendChild(getInsEl(props.list[addItemIndex], addItemIndex))
}
//刪除最前面的 `dom`
let index = slideListEl.value
.querySelector(`.${itemClassName}:first-child`)
.getAttribute('data-index')
appInsMap.get(Number(index)).unmount()
slideListEl.value.querySelectorAll(`.${itemClassName}`).forEach((item) => {
_css(item, 'top', (state.localIndex - half) * state.wrapper.height)
})
}
手指往下滑(即列表展示上一條影片)
邏輯和上滑都差不多,不過是反著來而已
- 再判斷是否符合
騰挪
的條件,和上面反著 - 在最前面新增一個
dom
- 刪除最後面的
dom
- 將所有
dom
設定為最新的top
值
//刪除最後面的 `dom` ,然後在最前面新增一個 `dom`
if (state.localIndex >= half && state.localIndex < props.list.length - (half + 1)) {
let addIndex = state.localIndex - half
if (addIndex >= 0) {
let res = slideListEl.value.querySelector(`.${itemClassName}[data-index='${addIndex}']`)
if (!res) {
slideListEl.value.prepend(getInsEl(props.list[addIndex], addIndex))
}
}
let index = slideListEl.value
.querySelector(`.${itemClassName}:last-child`)
.getAttribute('data-index')
appInsMap.get(Number(index)).unmount()
slideListEl.value.querySelectorAll(`.${itemClassName}`).forEach((item) => {
_css(item, 'top', (state.localIndex - half) * state.wrapper.height)
})
}
其他問題
為什麼不直接用 v-for
直接生成 SlideItem
呢?
如果內容不是影片就可以。要刪除或者新增時,直接操作 list
資料來源,這樣省事多了
如果內容是影片,修改 list
時,Vue
會快速的替換 dom
,正在播放的影片,突然一下從頭開始播放了😅😅😅
如何獲取 Vue
元件的最終 dom
有兩種方式,各有利弊
- 用
Vue
的render
方法- 優點:只是渲染一個
VNode
而已,理論上講記憶體消耗更少。 - 缺點:但我在開發中,用了這個方法,任何修改都會重新整理頁面,有點難蚌😅
- 優點:只是渲染一個
- 用
Vue
的createApp
方法再建立一個Vue
的例項- 和上面相反😅
import { createApp, onMounted, reactive, ref, render as vueRender, watch } from 'vue'
/**
* 獲取Vue元件渲染之後的dom元素
* @param item
* @param index
* @param play
*/
function getInsEl(item, index, play = false) {
// console.log('index', cloneDeep(item), index, play)
let slideVNode = props.render(item, index, play, props.uniqueId)
const parent = document.createElement('div')
//TODO 打包到線上時用這個,這個在開發時任何修改都會重新整理頁面
if (import.meta.env.PROD) {
parent.classList.add('slide-item')
parent.setAttribute('data-index', index)
//將Vue元件渲染到一個div上
vueRender(slideVNode, parent)
appInsMap.set(index, {
unmount: () => {
vueRender(null, parent)
parent.remove()
}
})
return parent
} else {
//建立一個新的Vue例項,並掛載到一個div上
const app = createApp({
render() {
return <SlideItem data-index={index}>{slideVNode}</SlideItem>
}
})
const ins = app.mount(parent)
appInsMap.set(index, app)
return ins.$el
}
}
總結
原理其實並不難。主要是一開始可能會用 v-for
去弄,折騰半天發現不行。v-for
不行,就只能想想怎麼把 Vue
元件搞到 html
裡面去,又去研究如何獲取 Vue
元件的最終 dom
,又查了半天資料,Vue
官方文件也不寫,還得去翻 api
,麻了
結束
以上就是文章的全部內容,感謝看到這裡,希望對你有所幫助或啟發!創作不易,如果覺得文章寫得不錯,可以點贊收藏支援一下,也歡迎關注我的公眾號 前端張餘讓,我會更新更多實用的前端知識與技巧,期待與你共同成長~