记录---实现抖音 “视频无限滑动“效果
🧑💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣
前言
在家没事的时候刷抖音玩,抖音首页的视频怎么刷也刷不完,经常不知不觉的一刷就到半夜了😅
不禁感叹道 "垃圾抖音,费我时间,毁我青春😅"
最终效果
在线预览:dy.ttentau.top/
Github地址:github.com/zyronon/dou…
实现原理
无限滑动的原理和虚拟滚动的原理差不多,要保持 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 } }