起步
年前单位需要搞一个类似抖音的需求,这本应是客户端的任务,然而,不知天高地厚的我却接了下来,然而下细致调研之下,发现网上并没有成熟的方案,但是却又很多需求,各大论坛全是提问的帖子,却少有人回答和解决。
这一瞬间,俺慌了,毕竟单位的活,排期都是定死的,这时候临阵退缩,实乃下下策。于是只能撸起袖子加油干。毕竟自己揽的事,含着泪也要干完,这就是男人,一个吐沫一个钉!
调研
大家知道,web端比起客户端的劣势有几点,想要做出类似客户端的复杂的交互效果,需要考虑几个问题:
- 性能--怎样能达到最优(当然想要跟客户端比肩这是不可能的)
- 体验--怎样达到客户端的优秀体验,比如视频缓冲怎么处理,初始化怎么处理,要不要视频预加载
- 兼容--ios 和安卓的设备以及他们各个版本之间的浏览器的实现都略有不同
而在我调研了抖音的web端、git上的一些开源的相关项目、以及一些零零散散的回答之后,发现都不太匹配 他们在实现上,那么只能集几百家之长自己来了,既然自己来就需要针对当前三个问题来寻找既能解决问题,又能快速实现的方案(毕竟有排期)
实现思路
在实现的初步设想中,我们不只需要解决问题,其实也需要考虑一些架构设计,也就是你怎样去将关注度分离,怎样将组件的颗粒度拆的细致,能将每一个组件独立出来,外部单独引用,怎样将每一个组件做通用,方便日后维护,并且还能快速开发,不耽误排期,这其实就是你在这做也无需求之初需要去想的一些问题,总结如下
首先,来说毋庸置疑的是:要想实现滑动的效果,现成的方案最快的就是swiper,swiper在web端的地位也是不可动摇。
其次,原生video标签体验大家也都知道,一塌糊涂,那么这一块也就需要自己实现,比如进度条,拖动,暂停播放,缓冲中等等内容。并且类似抖音中的视频上方的一些元素,比如点赞,分享等功能需要外部传入,让别的开发者在使用时自己定制
最后,怎样将组件的的结构拆分出来,能单独打包上传npm 供大家使用。
组件设计的设想俺才疏学浅也就能想到这了,接下来就该解决在调研中发现的三个问题:
- 最容易解决的问题就是兼容问题,babel完美解决,cli工具命令行直接生成,swiper 在能实现功能的情况下尽量使用老的版本。
- 性能问题是最难解决,如果渲染到很多视频之后,难免会有很多的video存在于dom中,这里我们采用了web抖音的方案,在整个dom中只渲染一个活动sidle的video其他的slide中渲染空节点,这样就能大大减少dom的数量,再配合vue的diff 能提供一个还算过得去的性能。
- 体验问题虽然不难,但是仅仅靠前端是无法解决的,需要多方配合,他需要压缩视频大小,提供封面图,增加缓冲效果,等等,而且不同的设备不同系统不同版本在video的表现差异还非常大,这其实是一个不可用技术解决的兼容问题,那么,我们只能从交互上来解决问题。
工程构建
工程构建为了装逼上了最新的vite ,体验了一把,开发体验确实是丝滑快速。由于vite天生支持库的开发,只需要在vite.config.ts 添加build内容即可。
build: {
lib: {
entry: path.resolve(__dirname, 'src/components/index.ts'),
name: 'videoSlide',
fileName: (format) => `index.${format}.js`
},
rollupOptions: {
// 确保外部化处理那些你不想打包进库的依赖
external: ['vue'],
output: {
// 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
globals: {
vue: 'Vue'
}
}
}
},
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
由于库可能给ts大佬使用,需要安装vite-plugin-dts 插件,来生成d.ts文件。
代码实现
由于视频内容和轮播部分的处理是两个独立的逻辑,所以将代码拆分为两个组件video.vue以及slide.vue。
video实现
video的实现的基本思路就是重写原生video 标签默认ui来达到自定义的目的,样式就不在赘述,主要就是video提供的一些事件重写video默认行为,这里简述下重点的函数。
// vue
<video
playsinline="true"
webkit-playsinline="true"
mediatype="video"
:poster="poster"
@progress="progress"
@durationchange="durationchange"
@loadeddata="loadeddata"
@playing="playing"
@waiting="waiting"
@timeupdate="timeupdate"
@canplay="playing"
@ended="ended"
>
<source :src="src" type="video/mp4" />
</video>
//js
setup({ autoplay }) {
// 是否是暂停状态
const paused = ref(true);
// 视频总时间
const endTime = ref(second(0));
//播放的时间
const startTime = ref(second(0));
// 是否是按下状态
const isPress = ref(false);
//缓冲进度
const percentageBuffer = ref(0);
// 播放进度
const percentage = ref(0);
// 保存计算后的播放时间
const calculationTime = ref(0);
// 拿到video 实例
const video = ref(null);
// 是否展示封面图
const showImg = ref(true);
// 是否处于缓冲中
const loading = ref(false);
// 播放
function play() {
video.value.play();
paused.value = false;
}
// 暂停
function pause() {
if (paused.value) return;
video.value.pause();
paused.value = true;
loading.value = false;
}
// 获取缓冲进度
function progress() {
if (!video.value) return;
percentageBuffer.value = Math.floor(
(video.value.buffered.length
? video.value.buffered.end(video.value.buffered.length - 1) /
video.value.duration
: 0) * 100
);
}
// 时间改变
function durationchange() {
endTime.value = second(video.value.duration);
console.log("时间改变触发");
}
// 首帧加载触发,为了获取视频时长
function loadeddata() {
console.log("首帧渲染触发");
showImg.value = false;
autoplay && play();
}
//当播放准备开始时(之前被暂停或者由于数据缺乏被暂缓)被触发
function playing() {
console.log("缓冲结束");
loading.value = false;
}
//缓冲的时候触发
function waiting() {
console.log("处于缓冲中");
loading.value = true;
}
// 时间改变触发
function timeupdate() {
// 如果是按下状态不能走进度,表示需要执行拖动
if (isPress.value || !video.value) return;
startTime.value = second(Math.floor(video.value.currentTime));
percentage.value = Math.floor(
(video.value.currentTime / video.value.duration) * 100
);
}
// 按下开始触发
function touchstart() {
isPress.value = true;
}
//松开按钮触发
function touchend() {
isPress.value = false;
video.value.currentTime = calculationTime.value;
}
// 拖动的时候触发
function touchmove(e) {
const width = window.screen.width;
const tx = e.clientX || e.changedTouches[0].clientX;
if (tx < 0 || tx > width) {
return;
}
calculationTime.value = video.value.duration * (tx / width);
startTime.value = second(Math.floor(calculationTime.value));
percentage.value = Math.floor((tx / width) * 100);
}
//点击进度条触发
function handleProgress(e) {
touchmove(e);
touchend();
}
// 播放结束时触发
function ended() {
play();
}
onMounted(() => {});
return {
video,
paused,
pause,
play,
progress,
durationchange,
loadeddata,
endTime,
startTime,
playing,
percentage,
waiting,
timeupdate,
percentageBuffer,
touchstart,
touchend,
touchmove,
isPress,
ended,
handleProgress,
loading,
showImg,
};
},
- 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.
- 48.
- 49.
- 50.
- 51.
- 52.
- 53.
- 54.
- 55.
- 56.
- 57.
- 58.
- 59.
- 60.
- 61.
- 62.
- 63.
- 64.
- 65.
- 66.
- 67.
- 68.
- 69.
- 70.
- 71.
- 72.
- 73.
- 74.
- 75.
- 76.
- 77.
- 78.
- 79.
- 80.
- 81.
- 82.
- 83.
- 84.
- 85.
- 86.
- 87.
- 88.
- 89.
- 90.
- 91.
- 92.
- 93.
- 94.
- 95.
- 96.
- 97.
- 98.
- 99.
- 100.
- 101.
- 102.
- 103.
- 104.
- 105.
- 106.
- 107.
- 108.
- 109.
- 110.
- 111.
- 112.
- 113.
- 114.
- 115.
- 116.
- 117.
- 118.
- 119.
- 120.
- 121.
- 122.
- 123.
- 124.
- 125.
- 126.
- 127.
- 128.
- 129.
- 130.
- 131.
- 132.
- 133.
- 134.
- 135.
- 136.
- 137.
- 138.
- 139.
- 140.
- 141.
- 142.
- 143.
- 144.
- 145.
- 146.
需要注意的是,需要自定义内容交给了使用者去自定义,全部通过插槽传入当前组件,这样就方便了根据内容自定义样式了。
slide.vue
slide.vue 就是处理滑动内容的组件,他包含了常用的上拉刷新,预加载等内容核心代码如下:
// vue
<swiper
direction="vertical"
@transitionStart="transitionStart"
>
<swiper-slide class="slide-box" v-for="(item, index) in list" :key="index">
<slot
:item="item"
:index="index"
:activeIndex="activeIndex"
v-if="activeIndex >= index - 1 && activeIndex <= index + 1"
></slot>
</swiper-slide>
</swiper>
//js
setup({ list }, { emit }) {
const activeIndex = ref(0);
function transitionStart(swiper) {
//表示没有滑动,不做处理
if (activeIndex.value === swiper.activeIndex) {
// 表示是第一个轮播图
if (swiper.swipeDirection === "prev" && swiper.activeIndex === 0) {
// 表示上拉刷新
emit("refresh");
} else if (
swiper.swipeDirection === "next" &&
swiper.activeIndex === list.length - 1
) {
// 滑动到底部
emit("toBottom");
}
} else {
activeIndex.value = swiper.activeIndex;
// 为了预加载视频,提前load 数据
if (swiper.activeIndex === list.length - 1) {
emit("load");
}
}
}
return {
transitionStart,
activeIndex,
};
},
- 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.
需要注意的是有两点:
- 为了预加载数据会在滑动到最后一帧的时候去请求数据,但是由于请求是异步的,如果在滑动到最后一个视频的时候在快速下滑会触发滑动到底部的事件,这时候其实新数据请求回来之后便又不是底部了,这时候则需要你去做个判断,如果正在请求中滑动到底部不去处理你的逻辑。
- 为了性能考虑,只渲染了active 、prev、next内容,其他一律渲染空节点,并且为了防止页面中出现多个vidoe标签,prev 和next 只渲染默认图内容。
组合使用
组合使用其实就非常简单了。
//vue
<Yslide
:list="data"
v-slot="{ item, index, activeIndex }"
@refresh="refresh"
@toBottom="toBottom"
@load="load"
>
<Yvideo
:src="item.entStoreVO.video"
:poster="item.entStoreVO.videoImg"
:index="index"
:activeIndex="activeIndex"
autoplay
>
<div class="mantle">
<div class="right" @click.stop="">
<div class="right-btn fabulous" @click="fabulous">点赞</div>
<div class="right-btn comment" @click="comment">评论</div>
<div class="right-btn collection" @click="collection">收藏</div>
<div class="right-btn share" @click="share">分享</div>
</div>
</div>
</Yvideo>
</Yslide>
- 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.
在组合使用中,我将video通过插槽的方式传入silide内部,这样做的原因是,为了用户能自定义传入内容,这也是很多插件库惯用的伎俩,增加了组件的灵活性,又增加了组件的独立性。
视频自动播放问题
在web浏览器中你经常会看到DOMException: play() failed because the user didn't interact with the document first 这个问题。
首先可以肯定的是在web浏览器中在与浏览器没有交互的情况下是不允许自动播放的,目前暂时还无法突破这个限制
如果你要嵌入app中,webview 可以突破,具体方法大家可自行查询,网上教程数不胜数。
git地址
将插件地址奉上,供大佬们参考,如有需求可直接引用,也可,克隆下来自行修改,如有问题请提issuesgithub.com/yixinagqing…