🧑💻 寫在開頭
點贊 + 收藏 === 學會🤣🤣🤣
前言
在家沒事的時候刷抖音玩,抖音首頁的影片怎麼刷也刷不完,經常不知不覺的一刷就到半夜了😅
不禁感嘆道 "垃圾抖音,費我時間,毀我青春😅"
最終效果
線上預覽:dy.ttentau.top/
Github地址:github.com/zyronon/dou…
原始碼: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
,麻了
本文轉載於:https://juejin.cn/post/7361614921519054883
如果對您有所幫助,歡迎您點個關注,我會定時更新技術文件,大家一起討論學習,一起進步。