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

前言

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


这是我的 模仿抖音 系列文章的第二篇,本文将一步步实现抖音首页 视频无限滑动 的效果,干货满满

第一篇: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 ,麻了

结束

以上就是文章的全部内容,感谢看到这里,希望对你有所帮助或启发!创作不易,如果觉得文章写得不错,可以点赞收藏支持一下,也欢迎关注我的公众号 前端张余让,我会更新更多实用的前端知识与技巧,期待与你共同成长~

热门相关:辛普森一家   二对一,变态常客   帅气的射吧   华丽之后   电影儿女