一起实现下自己的刷视频功能

606 阅读10分钟

1.起步

抖音刷的久了闲来无事逛下社区,看到了一位大佬的文章实现抖音 “视频无限滑动“效果,最近又刚好在写react项目,就寻思也来效仿一下,于是就做了个简易版的刷视频功能,先来看下实现效果,还是很丝滑的:(ps:文章末有完整代码)

图1.gif

在正式开始实现之前,先说下实现方案:由于主要涉及的是上下滑动播放视频,我们第一时间想到的肯定是通过遍历的方式为每个视频生成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>

这里贴上图解:

图2.jpg

做完了布局并理清了思路,那么就完成了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[];