實現抖音 “影片無限滑動“效果

ttentau發表於2024-05-15

前言

在家沒事的時候刷抖音玩,抖音首頁的影片怎麼刷也刷不完,經常不知不覺的一刷就到半夜了😅
不禁感嘆道 "垃圾抖音,費我時間,毀我青春😅"


這是我的 模仿抖音 系列文章的第二篇,本文將一步步實現抖音首頁 影片無限滑動 的效果,乾貨滿滿

第一篇: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 裡面永遠只有 NSlideItem,就要在滑動時不斷的刪除和增加 SlideItem
滑動時調整 SlideList 的偏移量 translateY 的值,以及列表裡那幾個 SlideItemtop 值,就可以了

為什麼要調整 SlideList 的偏移量 translateY 的值同時還要調整 SlideItemtop 值呢?
因為 translateY 只是將整個列表移動,如果我們列表裡面的元素是固定的,不會變多和減少,那麼沒關係,只調整 translateY 值就可以了,上滑了幾頁就減幾頁的高度,下滑同理

但是如果整個列表向前移動了一頁,同時前面的 SlideItem 也少了一個,,那麼最終效果就是移動了兩頁...因為 塌陷 了一頁
這顯然不是我們想要的,所以我們還需要同時調整 SlideItemtop 值,加上前面少的 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

有兩種方式,各有利弊

  • Vuerender 方法
    • 優點:只是渲染一個 VNode 而已,理論上講記憶體消耗更少。
    • 缺點:但我在開發中,用了這個方法,任何修改都會重新整理頁面,有點難蚌😅
  • VuecreateApp 方法再建立一個 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 ,麻了

結束

以上就是文章的全部內容,感謝看到這裡,希望對你有所幫助或啟發!創作不易,如果覺得文章寫得不錯,可以點贊收藏支援一下,也歡迎關注我的公眾號 前端張餘讓,我會更新更多實用的前端知識與技巧,期待與你共同成長~

相關文章