小程序仿抖音短视频切换实现方案

引言

最近在开发一个短剧小程序(技术栈taro+react),项目中有涉及到视频切换这一块的功能,和抖音的功能基本一致。本文将从头到尾实现一个高性能(自认为O(∩_∩)O)的短视频播放器。话不多说,咱们立马开始~

效果预览

模拟器上看着有点卡,真机运行很丝滑~

方案设计

一开始我想的是这个效果不就是swiper里包video嘛,敢敢单单。结果在做了一版出来之后发现emmm...问题还是有点多。首先在我们需求上是想实现在首个视频的时候下滑刷新,上滑则是无限加载下一个视频。如果在swiper中要实现此需求需要动态的切换circular的值,一通操作下来发现在切换circular的值的时候会导致swiper丢失过渡动画(也可能是我的打开方式不对)。出于种种原因,最终选择放弃swiper,采用transform+transition实现交互效果,以及通过类虚拟列表的形式实现性能优化。

交互实现

首先我们来实现一下交互,上滑和下滑主要是通过以下几个事件:

  • onTouchStart:触摸动作开始时触发
  • onTouchMove:触摸后移动触发
  • onTouchEnd:触摸动作结束触发

通过这几个事件我们可以获取到非常多的信息,例如:

  1. 用户手指移动的距离(可以用来实现视图跟着手指移动
  2. 用户手指离开的时机(离开后就可以切换下一张视图了
  3. 用户是上滑还是下滑(通过初始位置和结束位置做比对得知

下面直接开始编码(为方便讲解,下文中代码均是部分关键代码,完整代码可前往代码仓库查看)

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:如果需求内不用加入videoXDM其实食用到这里就可以了!

加入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>
        </>
    )
}

下面来说一下开发过程中几个需要注意的点:

  1. 这里没有采用videoposter作为封面是因为我们需要隐藏video的控制面板,文档上说的是隐藏了控制面板poster就会失效,所以我就没试了,直接用了一张image作为封面。
  2. 出于性能考虑,我一直在纠结这里加入的虚拟列表是否是一个反优化,频繁创建和销毁video和直接渲染大量video到底哪个性能开销更大一点呢?
  3. 经过在真机上测试,我发现还是只渲染一个video性能要好一些。渲染3video的话在创建和销毁的时候页面负担还是太大了。不过小程序的video还有个毛病,首帧会黑屏加载。这一点在使用体验上是非常糟糕的。目前官方也没有出具合适的解决方案也没有优化这个问题(这里狠狠的吐槽一下wx)。我的处理方式是先创建出video但是初始透明度是0,延迟300ms后再展示出来。
  4. 关于性能优化这一块,我觉得虚拟列表在此处并不是一个最优解,也许还有更好的解决方案。例如渲染3个常驻videovideo的频繁创建和销毁还是会耗费很多性能)通过调换他们顺序的方式来实现切换。有兴趣的小伙伴可以尝试一下

结语

本文主要讲述了通过css动画+虚拟列表的形式渲染一个视频切换的组件,感谢观看。如果小伙伴们有性能更优的解决方案可以在评论区探讨一波

完整代码

相关推荐
恋猫de小郭35 分钟前
再聊 Flutter Riverpod ,注解模式下的 Riverpod 有什么特别之处,还有发展方向
android·前端·flutter
Aress"37 分钟前
【2025前端高频面试题——系列一之MVC和MVVM】
前端·mvc
Json____42 分钟前
好玩的谷歌浏览器插件-自定义谷歌浏览器光标皮肤插件-Chrome 的自定义光标
前端·chrome·谷歌插件·谷歌浏览器插件·光标皮肤·自定义光标
蜡笔小新星2 小时前
Flask项目框架
开发语言·前端·经验分享·后端·python·学习·flask
Fantasywt5 小时前
THREEJS 片元着色器实现更自然的呼吸灯效果
前端·javascript·着色器
IT、木易6 小时前
大白话JavaScript实现一个函数,将字符串中的每个单词首字母大写。
开发语言·前端·javascript·ecmascript
张拭心8 小时前
2024 总结,我的停滞与觉醒
android·前端
念九_ysl8 小时前
深入解析Vue3单文件组件:原理、场景与实战
前端·javascript·vue.js
Jenna的海糖8 小时前
vue3如何配置环境和打包
前端·javascript·vue.js
星之卡比*8 小时前
前端知识点---库和包的概念
前端·harmonyos·鸿蒙