引言
最近在开发一个短剧小程序(技术栈
taro+react
),项目中有涉及到视频切换这一块的功能,和抖音的功能基本一致。本文将从头到尾实现一个高性能(自认为O(∩_∩)O)的短视频播放器。话不多说,咱们立马开始~
效果预览
模拟器上看着有点卡,真机运行很丝滑~
方案设计
一开始我想的是这个效果不就是swiper
里包video
嘛,敢敢单单。结果在做了一版出来之后发现emmm...问题还是有点多。首先在我们需求上是想实现在首个视频的时候下滑刷新,上滑则是无限加载下一个视频。如果在swiper
中要实现此需求需要动态的切换circular
的值,一通操作下来发现在切换circular
的值的时候会导致swiper
丢失过渡动画(也可能是我的打开方式不对)。出于种种原因,最终选择放弃swiper
,采用transform+transition
实现交互效果,以及通过类虚拟列表的形式实现性能优化。
交互实现
首先我们来实现一下交互,上滑和下滑主要是通过以下几个事件:
onTouchStart
:触摸动作开始时触发onTouchMove
:触摸后移动触发onTouchEnd
:触摸动作结束触发
通过这几个事件我们可以获取到非常多的信息,例如:
- 用户手指移动的距离(
可以用来实现视图跟着手指移动
) - 用户手指离开的时机(
离开后就可以切换下一张视图了
) - 用户是上滑还是下滑(
通过初始位置和结束位置做比对得知
)
下面直接开始编码(为方便讲解,下文中代码均是部分关键代码,完整代码可前往代码仓库查看)
index.tsx
/**
* 视图组件
*/
export default function CustomSwiper({swierpList}){
const [curIdx, setCurIdx] = useState(0); // 当前视图的索引
const [translateY, setTranslateY] = useState(0); // 移动偏移量
const touchStartY = useRef(0); // 手指触摸屏幕的位置
const touchMoveY = useRef(0); // 手指移动的位置
const isAnimating = useRef(false); // 节流标志,防止滑动过快
const timer = useRef()
return (
<>
<View className="custom-swiper">
<View
className="swiper-content"
style={{
height: `${swiperList.length * 100}vh`,
transform: `translateY(${translateY}px)`,
transition: translateY === 0 ? "transform 0.3s" : "none",
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
</View>
</View>
</>
)
}
这里custom-swiper
这个盒子固定100vh
的高度,而swiper-content
则是用来模拟所有数据渲染完毕后的高度,而我们在移动视图的时候其实也就是在移动他的位置。
他们大概长这个样子
在手指移动的过程中我们通过translateY
去同步移动swiper-content
的位置。
ini
// 手指触摸屏幕时触发
const handleTouchStart = (e: any) => {
if (isAnimating.current) return;
touchStartY.current = e.touches[0].clientY;
touchMoveY.current = 0;
};
// 手指移动时触发
const handleTouchMove = (e: any) => {
if (isAnimating.current) return;
e.preventDefault?.(); // 阻止默认滚动
e.stopPropagation?.();
touchMoveY.current = e.touches[0].clientY;
const distance = touchMoveY.current - touchStartY.current;
if (
(curIdx === 0 && distance > 0) ||
(curIdx === swiperList.length - 1 && distance < 0)
) {
setTranslateY(distance * 0.3);
return;
}
setTranslateY(distance);
};
// 手指离开屏幕时触发
const handleTouchEnd = () => {
if (isAnimating.current) return;
const distance = touchMoveY.current - touchStartY.current;
if (Math.abs(distance) > 100 && touchMoveY.current !== 0) {
// 开始切换时设置动画状态
isAnimating.current = true;
// 根据移动距离来判断是切换上一张还是下一张
if (distance > 0 && curIdx > 0) {
setCurIdx(curIdx - 1);
} else if (distance < 0 && curIdx < swiperList.length - 1) {
setCurIdx(curIdx + 1);
}
// 等待动画完成后重置状态
if (timer.current) {
clearTimeout(timer.current);
}
timer.current = setTimeout(() => {
isAnimating.current = false;
}, 500);
}
setTranslateY(0);
};
这一段代码里主要是完善了一下三个监听函数,整体逻辑就是在手指触摸屏幕时记录下当前位置touchStartY
,移动的过程中去记录移动位置touchMoveY
,同时去更新移动偏移量TranslateY(TranslateY = touchMoveY - touchStartY)
。手指离开屏幕后根据移动偏移量来修改当前索引的++
或是--
。同时再把移动偏移量置为0。
这里有一个小细节:大家还记得最开始需求里说的当处于第一张视图时下滑是刷新的效果而不是切换到最后一张视图吗?
ini
// 手指移动时触发
const handleTouchMove = (e: any) => {
...
if (
(curIdx === 0 && distance > 0) ||
(curIdx === swiperList.length - 1 && distance < 0)
) {
setTranslateY(distance * 0.3);
return;
}
setTranslateY(distance);
};
这里这个判断就是来区分是刷新还是切换的,如果是刷新的话我们给到一个0.3
倍的偏移量,达到一个阻尼效果。
虚拟列表
好了,接下来我们就需要接入虚拟列表逻辑来渲染真实的视图了。
出于性能考虑,页面上我们只真实渲染3个视图,也就是下面这样
这就需要我们去计算出真实视图的数据
ini
// 只渲染当前项及其前后各一项
const visibleItems = useMemo(() => {
const start = Math.max(0, curIdx - 1);
const end = Math.min(swiperList.length, curIdx + 2);
return swiperList.slice(start, end).map((item, index) => ({
data: { ...item },
virtualIndex: start + index,// 当前视图卡在源数据中的索引,用来计算他的偏移量
}));
}, [swiperList, curIdx]);
visibleItems
才是我们需要遍历渲染出的数据
ini
<View className="custom-swiper">
<View
className="swiper-content"
style={{
height: `${swiperList.length * 100}vh`,
transform: `translateY(${translateY}px)`,
transition: translateY === 0 ? "transform 0.3s" : "none",
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{visibleItems.map((item) => (
<View
key={item.virtualIndex}
className="swiper-item"
style={{
transform: `translateY(${item.virtualIndex * 100}%)`,
}}
>
{children({
item: item.data,
index: item.virtualIndex,
isActive: item.virtualIndex === curIdx,
})}
</View>
))}
</View>
</View>
这里有一个需要注意的点:
- 每一个视图卡的位置是
自身索引*100vh
,所以我们在计算visibleItems
的时候也把当前项在源数据中的索引记录下来了,也就是virtualIndex
写到这里我们的视图还不能完成切换,因为目前为止我们只是把视图渲染在了他原本应该处于的位置也就是translateY(${item.virtualIndex * 100}%)
。所以他本身并不具备移动位置的能力,真正偏移位置的其实是swiper-content
这个盒子
还记不记得上文中我们给swiper-content
设置的高度是100vh*swiperList.length
?
所以我们现在需要去让他发生偏移。那偏移量其实就是当前可见视图的索引*100vh
。
还是拿上边那个图来解释:
由此我们可以知道swiper-content
的偏移量等于当前可见视图索引*100vh + translateY
ini
// 计算当前项的位置
const basePosition = useMemo(() => {
return -curIdx * 100;
}, [curIdx]);
<View
className="swiper-content"
style={{
height: `${swiperList.length * 100}vh`,
transform: `translateY(calc(${basePosition}vh + ${translateY}px))`,
transition: translateY === 0 ? "transform 0.3s" : "none",
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
做到这里其实我们就完成了一个带虚拟列表的swiper
组件了,下面就是将video
加入进去了。
tips:如果需求内不用加入video
的XDM
其实食用到这里就可以了!
加入video
这里由于抖音的内容区域不仅仅会展示视频,可能还会有直播,广告等别的内容。所以我们的内容区域并不能写死video
,需要留给父组件来传递渲染内容。
ini
<CustomSwiper swiperList={swiperList}>
{
({ item, index, isActive }) => {
return <VideoPlayer
item={item}
index={index}
isActive={isActive}
swiperList={swiperList}
updateSwiperList={updateSwiperList}
handeleGetList={handeleGetList}
/>
}
}
</CustomSwiper>
接下来我们去实现VideoPlayer
这个组件
VideoPlayer
这里就不详细的去说每一个实现了,直接把完整代码贴出来,大家可以参考一下(好像也没啥参考价值哈哈哈)
ini
import { Video, View, Image } from "@tarojs/components"
import { useEffect, useRef, useState, useLayoutEffect } from "react"
import './index.scss'
import Taro from "@tarojs/taro";
import FooterBar from "./components/FooterBar";
import RightBar from "./components/RightBar";
interface VideoPlayerProps {
item: Record<string, any>;
isActive: boolean;
index: number;
swiperList: Record<string, any>[];
updateSwiperList: (time: number, index: number) => void
handeleGetList: () => void
}
export default function VideoPlayer(
{
item,
isActive,
index,
swiperList,
updateSwiperList,
handeleGetList
}: VideoPlayerProps
) {
const videoRef = useRef<any>(null);// 存储视频实例
const error = useRef(false);
const currentPlayTime = useRef(0); // 存储当前视频的播放时间
const [isPlaying, setIsPlaying] = useState(false); // 添加播放状态
const [showVideo, setShowVideo] = useState(false)
const handleError = (e) => {
console.error('Video error:', e);
error.current = true
};
const handlePlay = () => {
try {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
setIsPlaying(false);
} else {
videoRef.current.play();
setIsPlaying(true);
}
}
} catch (err) {
console.error('Play error:', err);
}
};
const handleTimeUpdate = (e) => {
const { currentTime } = e.detail;
// 记录当前视频的播放时间
currentPlayTime.current = currentTime
};
// 暂停事件
const handleVideoPause = () => {
// 更新当前视频项的播放时间
updateSwiperList(currentPlayTime.current, index)
setIsPlaying(false)
}
useLayoutEffect(() => {
// 获取视频实例,用以操作视频
if (!videoRef.current) {
videoRef.current = Taro.createVideoContext(`VideoRef_${index}`);
}
}, [])
// 自动播放当前视频
useEffect(() => {
// 处理激活状态变化
if (videoRef.current) {
if (isActive) {
// 从上次暂停时间开始播放
videoRef.current.seek(item.seekTime)
videoRef.current.play()
// 设置播放状态
setIsPlaying(true)
} else {
videoRef.current.pause()
setIsPlaying(false)
}
}
const timer = setTimeout(() => {
setShowVideo(isActive)
}, 300)
return () => clearTimeout(timer)
}, [isActive]);
// 分页
useEffect(() => {
if (index === swiperList.length - 2) {
handeleGetList()
}
}, [index])
return (
<>
<View
onClick={handlePlay}
className="video-box"
>
{/* 视频播放器 */}
{isActive && <Video
style={{
opacity: showVideo ? '1' : '0'
}}
id={`VideoRef_${index}`}
src={item.src}
autoplay={false}
controls={false}
showMuteBtn={false}
enablePlayGesture={false}
showProgress={false}
showBottomProgress={false}
showFullscreenBtn={false}
showPlayBtn={false}
showCenterPlayBtn={false}
className="video"
loop
objectFit="cover"
onTimeUpdate={handleTimeUpdate}
onPlay={() => setIsPlaying(true)} // 监听播放事件
onPause={() => handleVideoPause()} // 监听暂停事件
onEnded={() => setIsPlaying(false)} // 监听结束事件
onError={handleError}
/>}
{/* 封面图 */}
<Image
className="image"
style={{
opacity: !showVideo ? '1' : '0'
}}
mode="aspectFill"
src="https://fastly.picsum.photos/id/866/375/750.jpg?hmac=owU55smBewyf03jO0sqcWIGo2y7J5Q9u3-k_2x6COIw"
/>
</View>
</>
)
}
下面来说一下开发过程中几个需要注意的点:
- 这里没有采用
video
的poster
作为封面是因为我们需要隐藏video
的控制面板,文档上说的是隐藏了控制面板poster
就会失效,所以我就没试了,直接用了一张image
作为封面。 - 出于性能考虑,我一直在纠结这里加入的虚拟列表是否是一个反优化,频繁创建和销毁
video
和直接渲染大量video
到底哪个性能开销更大一点呢? - 经过在真机上测试,我发现还是只渲染一个
video
性能要好一些。渲染3
个video
的话在创建和销毁的时候页面负担还是太大了。不过小程序的video
还有个毛病,首帧会黑屏加载。这一点在使用体验上是非常糟糕的。目前官方也没有出具合适的解决方案也没有优化这个问题(这里狠狠的吐槽一下wx)。我的处理方式是先创建出video
但是初始透明度是0
,延迟300ms
后再展示出来。 - 关于性能优化这一块,我觉得虚拟列表在此处并不是一个最优解,也许还有更好的解决方案。例如渲染3个常驻
video
(video
的频繁创建和销毁还是会耗费很多性能)通过调换他们顺序的方式来实现切换。有兴趣的小伙伴可以尝试一下
结语
本文主要讲述了通过css动画+虚拟列表的形式渲染一个视频切换的组件,感谢观看。如果小伙伴们有性能更优的解决方案可以在评论区探讨一波