短视频无尽流从结构上可以拆解为两层:滑动轮播容器、单张内容卡片。单张内容卡片又可以拆解为自定义控制栏的视频播放器(下文简称视频播放器)和内容相关信息两部分。
内容相关信息为业务呈现模块,不同的业务有各自的表达方式,本文不对该部分展开介绍。下面将基于react介绍滑动轮播容器和视频播放器的开发指南。
![]()
▐ 视频播放器
家装家居内容短视频无尽流使用的是淘宝App内置的同层渲染播放器(VideoX桥接),本文为了增强普适性,直接采用HTML5 video标签作为播放器来介绍。
播放器自身的控制栏样式比较单一,往往不能满足业务诉求,需要实现自定义的控制栏。本小节将介绍如何实现播放器状态按钮和播放器进度条,以及播放器的激活和销毁,为应用在滑动轮播容器做前置准备。
常规来讲播放器需要展示出两种状态:暂停中、缓冲中,播放中有进度条在推进一般不需要做额外展示。状态按钮组件实现如下:
tips:将按钮状态内置在组件中,暴露修改状态的方法给父元素,可以避免在改变按钮状态时触发父元素的re-render。
const StatusButton = forwardRef<IStatusButtonRef>((_, ref) => { const [status, setStatus] = useState<EStatus>(EStatus.PLAY);
useImperativeHandle(ref, () => ({ setStatus, }));
return ( <div className={styles.statusButton}> {(() => { switch (status) { case EStatus.PAUSE: return <div>{}</div>; case EStatus.WAITING: return <div>{}</div>; default: return null; } })()} </div>);});
export default memo(StatusButton);
const VideoPlayer: FC<IVideoPlayer> = (props) => { const { source } = props;
const videoPlayerRef = useRef<HTMLVideoElement | null>(null); const statusButtonRef = useRef<IStatusButtonRef | null>(null);
const onPlay = () => { statusButtonRef.current?.setStatus(EPlayerStatus.PLAY); };
useEffect(() => { videoPlayerRef.current?.addEventListener('play', onPlay);
return () => { videoPlayerRef.current?.removeEventListener('play', onPlay); }; }, []);
return ( <div className={styles.videoPlayerContainer}> {/* 播放器 */} <video ref={videoPlayerRef} className={styles.item} src={source} playsInline autoPlay /> {/* 播放器状态按钮 */} <StatusButton ref={statusButtonRef} /> </div> );};
export default memo(VideoPlayer);
![]()
有两种情况会引起进度条“走动”:
视频正常播放,进度更新。
用户手动拖拽进度条。
对于1,进度条组件对父元素暴露更新进度的方法,父元素监听到播放器 timeupdate 时去调用该方法即可。
对于2,可以使用 @use-gesture/vanilla 实现跟手的拖拽效果,用户停止拖拽时去做播放器的跳帧操作:
useEffect(() => { const gesture = new DragGesture( thumbRef.current, (state) => { if (state.first) { setIsDragging(true); }
const x = state.xy[0];
let walked: number; if (x < 0) { walked = 0; } else if (x > OVERALL_WIDTH) { walked = OVERALL_WIDTH; } else { walked = x; }
setCurrentWalked(walked);
if (state.last) { const duration = Math.ceil((walked / OVERALL_WIDTH) * maxDuration); onChangeCurrentTime(duration); setIsDragging(false); } }, { axis: 'x', pointer: { touch: true }, }, );
return () => { gesture.destroy(); };}, []);
return ( <div ref={thumbRef} />);
虽然该场景下存在n个内容卡片,但是我们只需要屏幕当中的那一个内容卡片渲染视频播放器,其余内容卡片仅保留封面图占位即可,减少内存占用。
![]()
const VideoPlayer = forwardRef<IVideoPlayerRef, IVideoPlayerProps>((props, ref) => { const [activeStatus, setActiveStatus] = useState<boolean>(false); const hidePoster = () => { posterRef.current?.hide(); };
const activate = () => { setActiveStatus(true); }; const inActivate = () => { setActiveStatus(false); posterRef.current?.show(); };
useImperativeHandle(ref, () => ({ activate, inActivate, }));
useEffect(() => { videoPlayerRef.current?.addEventListener('loadeddata', hidePoster);
return () => { videoPlayerRef.current?.removeEventListener('loadeddata', hidePoster); }; }, []); });
export default memo(VideoPlayer);
▐ 滑动轮播容器
对于滑动轮播容器,采用swiper实现。swiper是强大的轮播组件,有丰富的内置能力,封装了react组件可以方便地使用。
由于短视频无尽流有”无尽“的特性,用户单次可能会浏览几十篇内容,因此可以使用swiper的virtual能力减少内存占用,会随着手动轮播切换增删节点,仅保留视角内上下有限个swiper slide节点。如下图所示,当前需要用到500个slide,但是通过动态增删节点保证实际渲染出的节点数最多只有5个(个数可配置)。
![]()
swiper入参配置可参考: