记录---实现抖音 “视频无限滑动“效果

🧑‍💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣

前言

在家没事的时候刷抖音玩,抖音首页的视频怎么刷也刷不完,经常不知不觉的一刷就到半夜了😅
不禁感叹道 "垃圾抖音,费我时间,毁我青春😅"

最终效果

  • 在线预览: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
如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。

在这里插入图片描述

  • 25
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值