前言
短视频无尽流是当下比较热门的一种业务场景,在日常生活中随处可见。本文基于对家装家居内容短视频无尽流的开发实践,总结出了一套适应于该场景及衍生场景的前端开发指南,通过阅读本文可以快速了解短视频无尽流的前端开发。
短视频无尽流介绍
短视频有着“短、平、快”的特点,用户可以通过短视频快速获得一些输入。在家装家居领域,可以通过几十秒到几分钟的短视频向用户输出装修干货经验、居家好物推荐等等。在短视频无尽流场景中,会基于引流内容以及相关算法推荐出更多内容,用户随着手势上滑可以持续浏览获得内容输入。
短视频无尽流结构拆解
短视频无尽流从结构上可以拆解为两层:滑动轮播容器、单张内容卡片。单张内容卡片又可以拆解为自定义控制栏的视频播放器(下文简称视频播放器)和内容相关信息两部分。
内容相关信息为业务呈现模块,不同的业务有各自的表达方式,本文不对该部分展开介绍。下面将基于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>{/* 暂停Icon */}</div>;
case EStatus.WAITING:
return <div>{/* 缓冲Icon */}</div>;
default:
return null;
}
})()}
</div>
);
});
export default memo(StatusButton);
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
// ...
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);
// 暂停(pause)和缓冲(waiting)监听方法类似
// ...
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.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 视频播放器进度条
有两种情况会引起进度条“走动”:
- 视频正常播放,进度更新。
- 用户手动拖拽进度条。
对于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} />
);
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 视频播放器激活及销毁
虽然该场景下存在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);
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 46.
- 47.
▐ 滑动轮播容器
对于滑动轮播容器,采用swiper实现。swiper是强大的轮播组件,有丰富的内置能力,封装了react组件可以方便地使用。
- 虚拟轮播
由于短视频无尽流有”无尽“的特性,用户单次可能会浏览几十篇内容,因此可以使用swiper的virtual能力减少内存占用,会随着手动轮播切换增删节点,仅保留视角内上下有限个swiper slide节点。如下图所示,当前需要用到500个slide,但是通过动态增删节点保证实际渲染出的节点数最多只有5个(个数可配置)。
swiper入参配置可参考:
// swiper 6.x版本 和 8.x版本 使用上会有一定区别,注释中会将不同点标注出来
import * as React from 'react';
// [swiper 8.x] 引入 swiper
import { Virtual } from 'swiper';
// [swiper 6.x] 引入 swiper
// import SwiperCore, { Virtual } from 'swiper';
import { Swiper, SwiperSlide } from 'swiper/react';
import type { FC } from 'react';
import type { IVideoCardItem } from '../../types';
import styles from './index.module.less';
// [swiper 6.x] 加载 Virtual 模块
// SwiperCore.use([Virtual]);
const VideoSwiper: FC = () => {
return (
<Swiper
className={styles.videoSwiperContainer}
// 切换方向
direction="vertical"
// 初始索引
initialSlide={0}
// 切换角度,防止误切
touchAngle={30}
// [swiper 8.x] 加载 Virtual 模块
modules={[Virtual]}
// virtual配置,如下配置会保证至多有5 slide
virtual={{
// 在active的slide前多渲染1个slide
addSlidesBefore: 1,
// 在active的slide后多预渲染1个slide
addSlidesAfter: 1,
}}
>
{/* ... */}
</Swiper>
);
};
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
tips:移动端swiper切换时可能存在闪屏/抖动的异常情况,可以使用如下代码开启硬件加速,可以解决大部分异常情况。
.videoSwiperContainer :global {
.swiper-wrapper {
transform: translate3d(0, 0, 0);
.swiper-slide {
transform: translate3d(0, 0, 0);
}
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 视频播放器实例管理
上述中提到只需要屏幕当中的那一个内容卡片渲染播放器,其余展示封面图占位即可。在轮播容器完成一次切换即 onTransitionEnd 时销毁上一个内容卡片的播放器,同时激活当前内容卡片的播放器,保证始终只有一个播放器处于激活状态。
tips:当前内容卡片的播放器激活后,由于还需要加载视频资源,因此切换后会有短暂的等待时间。为了提升用户体验,可以配合视频资源的预加载,优先使用端侧提供的预加载能力,若没有该支持,可以尝试使用blob等预加载方案。
- 底部悬浮loading条
无尽流场景不可避免的是加载loading,对于全屏幕的轮播容器,loading的出现/消失尽量避免产生布局偏移,如果loading过程中用户想去做一些点击操作,但刚好操作的瞬间loading结束了,若此时发生了布局偏移,会造成用户点到非预期的行动点,有损用户体验。可以采用类似此轻量级的悬浮式loading:
.loadingBar {
&Container {
position: fixed;
left: 0;
bottom: 0;
z-index: 99;
display: flex;
align-items: center;
justify-content: flex-end;
width: 100vw;
height: 5rpx;
}
&Item {
height: 5rpx;
background-color: #fd0;
animation: loading 0.6s linear infinite;
}
}
@keyframes loading {
0% {
width: 0;
opacity: 0;
}
30% {
width: 30vw;
opacity: 1;
}
70% {
width: 70vw;
opacity: 1;
}
100% {
width: 100vw;
opacity: 0;
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
总结
本文通过对短视频无尽流结构的拆解,从各个功能点的角度介绍了如何实现并落地该场景。除了短视频无尽流外,还适用于其衍生场景,如图文卡片无尽流、直播无尽流、3D场景无尽流等等。针对于不同场景,不变的是轮播容器的构建,在此基础上根据不同场景构建单个场景卡片的逻辑即可。
团队介绍
我们是大淘宝-家装家居技术-前端团队,团队支撑大淘宝家居家装业务。旗下包括:每平每屋App、淘宝【极有家】频道。我们连通电商平台和商家店铺,覆盖居家生活、装修设计、线下市场,3D场景化展现居家生活,我们力求让每件单品都不再孤立呈现,置身其中,感受家的优选方案。期待与您一起共筑美好的理想家。