1.起步
抖音刷的久了闲来无事逛下社区,看到了一位大佬的文章实现抖音 “视频无限滑动“效果,最近又刚好在写react项目,就寻思也来效仿一下,于是就做了个简易版的刷视频功能,先来看下实现效果,还是很丝滑的:(ps:文章末有完整代码)
在正式开始实现之前,先说下实现方案:由于主要涉及的是上下滑动播放视频,我们第一时间想到的肯定是通过遍历的方式为每个视频生成video标签,当滚到到某个视频时单独播放视频。这么做完全可以实现,也是最简单的方式,那么这种方式有什么不妥呢?相信有经验的小伙伴都能意识到,如果视频数据少完全没问题,但如果是100万个视频呢?那就要生成100万个video标签,如果直接全部渲染那浏览器真的是要卡成ppt了...既然不能直接渲染这么多元素,那么我们又有方法了,一次向数据库请求10条数据,当播放到最后一个视频的时候,再去请求将请求的结果追加依次类推。虽然这种方式避免了浏览器直接卡死,但如果刷个1000次请求,那么卡顿的结果还是避免不了...那么我选择的最终方案是,使用3个div和一个video标签,中间显示video,上下的div显示视频的默认封面,当滑动时通过不断改变视频列表dom元素(.video-list)的translateY值来实现滑动效果,当滚动到制定阈值的时候再播放对应的视频。
2. 实现
布局相关说明:
- 首先我们需要一个容器,类名为contianer,给他固定的宽高,
width:390px;height:788px
;设置overflow:hideen
,隐藏掉溢出的dom元素。 - 在容器内部,我们放入一个类名为.video-list的div,他就是我们滑动的区域,里面遍历生成3个div,每个div的宽高都是
width:390px;height:788px;
,并设置相对定位,并且我们设置.video-list默认的transform: translateY(-788px)
,目的是为了保证默认显示中间的视频,这样我们无论是上滑和下滑都能看到东西。
- 在video-list内部我们除了需要生成三个div外,还需要一个video标签,宽高
width:390px;height:788px
,同样设置绝对定位,这时video标签默认在video-list的顶部,因为我们上一步给video-list设置了transform: translateY(-788px)
,同样我们设置video距离顶部的默认top为top:788px
,这样我们就能看到video标签了,同时为剩余两个可视区域外的div设置默认封面,最终我们就能得到:
// 展示的列表,里面总是有三个视频信息
const [showList, setShowList] = useState<VideoList>([]);
// 当前正在播放的视频信息
const [videoInfo, setVideoInfo] = useState<Video>();
// ----------------------------------------------------------------------------
<div className={styles.container} id="container">
<div className={styles["video-list"]} id="video-list">
<video className={styles.video} src={videoInfo?.videoUrl} controls muted autoPlay />
{showList.map(item => (
<div key={item.videoId} className={styles["video-item"]}>
// 根据当前播放的视频判断是否显示视频封面
{videoInfo?.videoId != item.videoId && <img alt="视频封面" src={item.videoCover} />}
</div>
))}
</div>
</div>
这里贴上图解:
做完了布局并理清了思路,那么就完成了50%了,剩余的就是逻辑代码的编写了,下面我们再来分分析下,我们的js交互都需要做什么。
首先我们来分析下我们都需要哪些变量,我们请求后台结构数据按照每次10条来请求,那么我们定义一个数组来保存后台请求的视频信息数据list
,上面我们说了每次只展示3条视频,那么我们通过定义showList
来保存我们需要展示的视频,那么如何确认我们当前正在看哪个视频呢?我们来定义一个相对于list的索引currentIndex
,来记录当前正在看的是那条视频,那么知道了索引,当索引变化的时候我们自然也就能拿到视频的信息,通过定义videoInfo来保存正在播放视频的信息videoInfo
这些变量定义完之后,我们就要来分析交互了。
// 当前索引,默认展示第二个,确保当前索引在列表中间
let [currentIndex, setCurrentIndex] = useState(1);
// 展示的列表
const [showList, setShowList] = useState<VideoList>([]);
// 当前正在播放的视频信息
const [videoInfo, setVideoInfo] = useState<Video>();
交互的话无非就是用户滑动屏幕切换视频,那么这一步我们就需要涉及到三个事件,用户鼠标按下事件(mousedown),鼠标移动事件(mousemove),鼠标松开事件(mouseup)。知道涉及到哪些事件后,那么下一步任务就是要知道这三个事件中我们都需要做哪些操作了。
首先是鼠标按下事件(mousedown),这里我们需要一个变量dragAble
来记录鼠标是否按下(因为只有鼠标按下才允许用户滑动),同时定义clickOffsetY
来记录鼠标点击时距离container顶部的距离(方便后续计算用户滑动的距离)。
// 是否可拖动
let dragAble = false;
// 记录鼠标按下时距离左上角的位置
let clickOffsetY = 0;
let container = document.querySelector("#container") as HTMLElement;
// 鼠标在列表上按下
const videoListMouseDown = (e: any) => {
// 记录按下的位置(相对父元素container的左上角)
clickOffsetY = e.clientY - container.getBoundingClientRect().top;
// console.log("按下", clickOffsetY);
dragAble = true;
dragDirection = "up";
};
接下来是鼠标移动事件(mousemove),我们需要做的第一事就是判断当前索引如果为1的话,也就是播放第一个视频的时候,不允许用户向上滑动,那么我们就需要定义dragDirection
来判断滑动方向,并定义distance
来记录滑动距离。
// 向上拖or下拖
let dragDirection = "up";
// 记录拖动的距离
let distance = 0;
// 如果索引为1,表示当前正在第一个视频,不允许向上拖动
if (currentIndex == 1 && dragDirection == "down") {
dragAble = false;
distance = 0;
return;
}
然后需要判断用户的鼠标是否按下,也就是判断dragAble
的值,当鼠标按下并拖动的时候,我们来计算偏移量并便宜video-list
let videoList = document.querySelector("#video-list") as HTMLElement;
// 如果用户鼠标按下
if (dragAble && videoList) {
// 记录距离左上角的位置
let offsetY = e.clientY - container.getBoundingClientRect().top;
// 计算拖动的距离
distance = offsetY - clickOffsetY;
// 记录偏移方向
dragDirection = distance > 0 ? "down" : "up";
// 计算新的偏移量
let newTranslateY = -788 + distance;
// 设置偏移量
videoList.style.transform = `translateY(${newTranslateY}px)`;
}
最后是鼠标松开事件(mouseup),当鼠标松开的时候,我们就要判断用户是向上滑动了还是向下滑动了,进而重新设置新的播放视频信息,新的索引,新的展示列表。
let newIndex = dragDirection === "up" ? (currentIndex += 1) : (currentIndex -= 1);
setCurrentIndex(newIndex);
// 重置偏移量,让视图回到中间,永远显示showList中的第二个
videoList.style.transform = `translateY(${-788}px)`
// 监听currentIndex变化
useEffect(() => {
if (list.length > 0) {
setShowList([list[currentIndex - 1], list[currentIndex], list[currentIndex + 1]]);
setVideoInfo(list[currentIndex]);
}
}, [currentIndex]);
到这儿基本的思路流程就跑通了。接下来就是做一些完善了,现在,当我们的鼠标松开的时候,videoList会直接回到中间,那么我们如何实现一个过度呢?就是加一个transition
就可以了,在播放新视频之前,我们做一个0.3秒的过度效果,这样就会有一个平滑的过度效果,并且播放新视频的操作是在0.3秒后执行的。
videoList.style.transition = "transform 0.3s";
// 判断上移动还是下移动,设置偏移过度
videoList.style.transform = `translateY(${dragDirection === "up" ? -788 * 2 : 0}px)`;
// // 过度完成,设置新的索引,并重置偏移量
setTimeout(() => {
videoList.style.transition = "none";
let newIndex = dragDirection === "up" ? (currentIndex += 1) : (currentIndex -= 1);
setCurrentIndex(newIndex);
// 重置偏移量,让视图回到中间,永远显示showList中的第二个
videoList.style.transform = `translateY(${-788}px)`;
}, 300);
// 监听currentIndex变化
useEffect(() => {
if (list.length > 0) {
setShowList([list[currentIndex - 1], list[currentIndex], list[currentIndex + 1]]);
setVideoInfo(list[currentIndex]);
}
}, [currentIndex]);
至此,我们的功能就完成了,剩下的部分就是加一些判断,在这儿就不一一细说了,看到这里就可以完全先试着自己去实现一下了。这部分的分析主要还是以理清思路为主,可以参考下面的完整代码来理解。
3. 完整代码
index.ts主文件
import React, { useEffect, useState } from "react";
import styles from "./index.module.less";
import { selectVideos } from "@/api/video/index";
import { Video, VideoList } from "./_types";
import { message } from "antd";
const Home: React.FC = () => {
// 当前索引,默认展示第二个,确保当前索引在列表中间
let [currentIndex, setCurrentIndex] = useState(1);
// 展示的列表
const [showList, setShowList] = useState<VideoList>([]);
// 当前正在播放的视频信息
const [videoInfo, setVideoInfo] = useState<Video>();
// 接口视频是否加载完全部
const [isLoadedAll, setIsLoadedAll] = useState(false);
// 是否可拖动
let dragAble = false;
// 记录鼠标按下时距离左上角的位置
let clickOffsetY = 0;
// 向上拖or下拖
let dragDirection = "up";
// 记录拖动的距离
let distance = 0;
// 点赞
const favour = (e: any) => {
// 阻止冒泡
e.stopPropagation();
console.log("点赞");
};
// 查询视频列表
const [searchParams, setSearchParams] = useState({
pageNum: 1,
pageSize: 5,
status: "0",
});
let [list, setList] = useState<VideoList>([]);
const getVideos = async () => {
const res = await selectVideos(searchParams);
if (res.code == 200) {
if (res.data.rows.length == 0) setIsLoadedAll(true);
// 因为逻辑是默认展示第二个,所以需要把第一个添加到列表中
if (list.length == 0) {
res.data.rows.unshift({ videoId: -1 });
}
// 因为逻辑是默认展示第二个,所以需要把最后一个添加到列表中
let result = list.filter(item => item.videoId != 0).concat(res.data.rows);
res.data.rows.push({ videoId: 0 });
setList(result);
let newList = [result[currentIndex - 1], result[currentIndex], result[currentIndex + 1]];
setVideoInfo(newList[1]);
setShowList(newList);
}
};
useEffect(() => {
getVideos();
}, []);
useEffect(() => {
if (list.length > 0) {
let videoList = document.querySelector("#video-list") as HTMLElement;
let container = document.querySelector("#container") as HTMLElement;
// 鼠标在列表上按下
const videoListMouseDown = (e: any) => {
// 记录按下的位置(相对父元素的左上角)
clickOffsetY = e.clientY - container.getBoundingClientRect().top;
// console.log("按下", clickOffsetY);
dragAble = true;
dragDirection = "up";
};
// 鼠标在列表上松开
const videoListMouseUp = () => {
// console.log("松开", currentIndex, dragDirection, distance);
dragAble = false;
// 如果索引为1并且向下拖动,不做处理
if ((currentIndex == 1 && dragDirection == "down") || distance == 0) {
return;
}
console.log("isLoadedAll", isLoadedAll, currentIndex, list.length);
// 如果加载完毕,并且当前是最后一个视频
if (isLoadedAll && currentIndex == list.length - 2 && dragDirection == "up") {
return message.warning("没有更多了");
}
distance = 0;
// 判断拖动方向,重新设置偏移量
videoList.style.transition = "transform 0.3s";
// 判断上移动还是下移动,设置偏移过度
videoList.style.transform = `translateY(${dragDirection === "up" ? -788 * 2 : 0}px)`;
// // 过度完成,设置新的索引,并重置偏移量
setTimeout(() => {
videoList.style.transition = "none";
let newIndex = dragDirection === "up" ? (currentIndex += 1) : (currentIndex -= 1);
setCurrentIndex(newIndex);
// console.log("当前索引", currentIndex, dragDirection === "up", list.length - 1);
if (newIndex + 2 >= list.length - 1) {
console.log("触发了", list);
searchParams.pageNum += 1;
setSearchParams(searchParams);
getVideos();
}
// 这里创建一个图片放置到视图最顶层,防止重置偏移量时,出现闪动
const img = new Image();
img.width = 390;
img.height = 788;
img.src = list[newIndex].videoCover;
img.style.position = "absolute";
img.style.zIndex = "3";
img.style.top = "788px";
img.style.background = "rgba(0,0,0)";
videoList.appendChild(img);
// 重置偏移量,让视图回到中间,永远显示showList中的第二个
videoList.style.transform = `translateY(${-788}px)`;
// 这里延迟100ms,确保偏移量重置完成,然后删除图片
setTimeout(() => {
videoList.removeChild(img);
}, 100);
}, 300);
};
// 鼠标移动
const videoListMouseMove = (e: any) => {
// 如果索引为1,表示当前正在第一个视频,不允许向上拖动
if (currentIndex == 1 && dragDirection == "down") {
dragAble = false;
distance = 0;
return;
}
if (dragAble && videoList) {
// 记录距离坐上角的位置
let offsetY = e.clientY - container.getBoundingClientRect().top;
// 计算拖动的距离
distance = offsetY - clickOffsetY;
dragDirection = distance > 0 ? "down" : "up";
// 计算新的偏移量
let newTranslateY = -788 + distance;
// 设置偏移量
videoList.style.transform = `translateY(${newTranslateY}px)`;
}
};
// 注册事件
videoList?.addEventListener("mousedown", videoListMouseDown);
window?.addEventListener("mouseup", videoListMouseUp);
videoList?.addEventListener("mousemove", videoListMouseMove);
return () => {
// 移除事件
videoList?.removeEventListener("mousedown", videoListMouseDown);
window?.removeEventListener("mouseup", videoListMouseUp);
videoList?.removeEventListener("mousemove", videoListMouseMove);
};
}
}, [list]);
useEffect(() => {
if (list.length > 0) {
setShowList([list[currentIndex - 1], list[currentIndex], list[currentIndex + 1]]);
setVideoInfo(list[currentIndex]);
// console.log("触发了setCurrentIndex", currentIndex, list[currentIndex]);
}
}, [currentIndex]);
return (
<>
<div className={styles.container} id="container">
<div className={styles["video-list"]} id="video-list">
<video className={styles.video} src={videoInfo?.videoUrl} controls muted autoPlay />
{showList.map(item => (
<div key={item.videoId} className={styles["video-item"]}>
{videoInfo?.videoId != item.videoId && <img alt="视频封面" src={item.videoCover} />}
</div>
))}
</div>
</div>
</>
);
};
export default Home;
index.module.less样式文件
.container {
overflow: hidden;
margin: 0 auto;
width: 390px;
height: 788px;
user-select: none;
background: rgb(0 0 0);
.video-list {
position: relative;
width: 100%;
height: 100%;
transform: translateY(-788px);
.video {
position: absolute;
top: 788px;
z-index: 2;
width: 390px;
height: 788px;
}
.video-item {
position: relative;
width: 100%;
height: 100%;
color: white;
.desc {
position: absolute;
bottom: 100px;
left: 10px;
z-index: 4;
display: -webkit-box;
overflow: hidden;
width: 80%;
height: 40px;
font-size: 14px;
text-overflow: ellipsis;
color: white;
visibility: visible;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 20px;
}
.action {
position: absolute;
right: 10px;
bottom: 90px;
z-index: 4;
display: flex;
align-items: center;
width: 50px;
min-height: 100px;
flex-direction: column;
.favour {
width: 30px;
height: 30px;
text-align: center;
transition: all 0.3s;
cursor: pointer;
&:hover {
filter: drop-shadow(0 0 3px #1890ff);
}
}
.menu {
margin-top: 40px;
width: 35px;
height: 35px;
transition: all 0.3s;
cursor: pointer;
&:hover {
filter: drop-shadow(0 0 3px #1890ff);
}
}
}
}
}
::-webkit-scrollbar {
display: none;
}
}
index.d.ts声明文件
export interface Video {
videoId: number;
// 视频文案
videoDesc: string;
// 视频链接
videoUrl: string;
// 视频封面
videoCover: string;
// 视频点赞数
videoFavour: number;
// 视频时长
videoDuration: number;
// 视频大小
videoSize: string;
// 视频类型
videoStyle: string;
// 视频状态
status: string;
// 视频创建时间
createTime: string;
}
export type VideoList = Video[];