“菜鸡到鸵鸟进阶之”用H5制作酷炫的视频播放器
一、简单描述下需求
产品汪:我的需求很简单,就是做一个像下面这样的播放器就行了
程序猿:我丢雷。。。
用下面的图来形容我当时的表情
短暂的懵逼之后还是要面对现实,谁让咱是底层的劳动人民呢:unamused: 经过一番看图分析后,归纳总结出了下面结论(一本正经胡说八道,谁看得出来啊)。
做一个车辆视频播放器,支持的功能如下:
- 1、支持视频进度条显示与控制
- 2、支持音量的显示与控制
- 3、支持视频缓冲进度条的显示
- 4、支持双击播放/暂停视频
- 5、拖动进度条时显示视频的预览图
以及其他等等功能。。。
二、基本功能的实现
看起来挺高端的功能其实做起来并不复杂。其实只要参考 H5视频开发手册 ,与 哔站上的视频教程 就可完成大部分需求。
1、视频进度条显示与控制
进度显示方面,只要用 video 的 duration 属性(总时长)和 currentTime 属性(当前播放时间)就可马上得出当前视频的播放进度,至于怎么利用这些参数渲染进度条界面,这就要考验你css的功底了,这里就不详细描述了。然后在 timeupdate 事件中去更新UI的界面就可以实现进度条的实时更新。至于控制视频的播放进度那就更简单了,直接修改video的 currentTime 的属性值就行了,注意单位是秒。
2、音量的显示与控制
没啥好写的,参考video 的 volume 属性介绍即可。有个坑爹的地方需要注意:在ios上视频的音量是只读的,因此无法修改视频的音量。备选方案就是ios上没有音量控制功能,代替的是静音模式的功能,由 muted 属性控制。
3、显示视频的缓冲进度
这个功能在手册中描述地没有那么明显,需要去挖掘下 buffered 这个属性。 这个属性 返回一个 TimeRanges(表示用户的视频缓冲范围,查看该属性详细描述可点击 这里 ) 对象,如果用户采用跳跃播放,那么会获得多个缓存范围,使用 buffered.start(index)
和 buffered.end(index)
可以获得该缓冲段的起始时间与结束时间(单位秒)。既然如此,那么界面上的缓冲进度条就可以取自当前播放的时间所在的缓冲段的结束时间了,然后在 progress 事件里去更新缓冲进度条。
不废话,上代码
video.onprogress = function(){ if (video.buffered.length) { this.bufferedTime = getBufferedTime(video.currentTime); } } // 获取缓冲的时间 function getBufferedTime(currentTime){ const timeRanges = video.buffered; const result = 0; // timeRanges 不是个array对象,所以只能通过for循环遍历 // 这里省略了对timeRanges 缓存段的排序,因为用户不一定按照正常顺序去播放视频, //从而产生的缓冲段地时间顺序也是乱的,即[80-90]的缓冲段可以能会出现在[20-30]之前。 for(let index = 0; index = start && currentTime <= end) { break; } } // 找不到所在的缓冲段,就返回上一个缓冲段的结束时间 return result; }
效果如下图所示:
读到这里你不禁会想:看了半天,都没见到一点干货。说好的要成为鸵鸟呢,骗子! 客官别急啊,还请耐心看完,好戏还在后头啊。
三、高级功能实现
1、实现双击视频进行播放/暂停
因为移动端是没有双击事件的,因此需要做的就是在移动端实现自定义的双击事件。 你会想:“这算啥高级功能啊,直接判断两次点击事件触发的时间间隔不就行了嘛?”。
真的有那么简单吗?当然不是啦,主要是移动端的点击事件会有延迟,300ms以内的只能触发一次点击事件,如果把判断的间隔时间加长,有会影响用户体验。
既然如此,那我们就尝试用touch事件去模拟看看
let lastTime = 0; btn.addEventListener('touchstart', e => { const currentTime = +Date.now(); if (currentTime - lastTime < 300) { // fn是要在双击事件中实现的具体函数 fn(e); lastTime = 0; } else { lastTime = currentTime; } });
看似没毛病,但是如果上述的这个按钮也绑定了单击事件那就gg了,所以还是要做下改进。
let lastTime = 0; let timer = null; btn.addEventListener('touchstart', e => { const currentTime = +Date.now(); // 阻止默认事件,防止默认触发click事件 e.preventDefault(); clearTimeout(timer); if (currentTime - lastTime { e.target.click && e.target.click(); }, 300); } });
看似已经差不多完美了,但是还有一小点需要考虑,那就是如果这个按钮上还绑定了’touchmove’事件,那上面的代码还是会有问题。因为触发touchmove必须先执行touchstart事件,用户的动作可能只是滑动,但是上面的代码在滑动300ms后会执行click事件绑定的方法,非常影响用户体验。
因此还是需要稍微做下改进。
let lastTime = 0; let timer = null; // 记录按下的点,用于误触判断 let startPoint = {}; btn.addEventListener('touchstart', e => { startPoint.x = e.touches[0].pageX; startPoint.y = e.touches[0].pageY; const currentTime = +Date.now(); e.preventDefault(); clearTimeout(timer); if (currentTime - lastTime { e.target.click && e.target.click(); }, 300); } }); // 在move事件中清除定时器 btn.addEventListener('touchmove', e => { const currentPoint = { x: e.touches[0].pageX, y: e.touches[0].pageY }; // 即便是手指按下去微小的移动,也会触发move事件 // 因此需要做一个误触判断,两点距离大于3即为有效滑动 if (getDistance(startPoint, currentPoint) > 3) { timer && clearTimeout(timer); timer = null; } });
2、实现图片预览的功能
顾名思义,就是要根据时间获取视频中对应帧的图片。可以通过canvas将视频源以图片的形式呈现出来(基本是视频的第一帧),但是如果要根据时间截取视频中对应的图片是非常困难的,这涉及到视频二进制数据处理等相关知识。不过好在天无绝人之路,强大的阿里云平台提供了 视频截帧的api 。因此实现这个功能简直就是轻轻松松。
接着预览图随着进度条的拖动而发生变化,考虑到预览图的精确度,我是以秒为时间单位截取视频的图片。
下面介绍几种方法:
1、通过直接修改 img 的src实现图片的切换
不用想也知道这种方法肯定不行,切换图片是一个高频操作而且不能用节流,因为它对精确度要求很高。会不断触发http请求,而且上一个请求会被下一个请求替换掉,导致上一个请求会被浏览器取消。而且在拖动的过程中频繁地触发http请求,会导致浏览器主线程被阻塞,容易引起操作卡顿。 如下图所示,频繁切换img src 的情况下,只有最有一个url请求是有效的
2、预先创建好预览图
先把所以的图片元素都创建好,通过进度条的位置,改变图片父容器的左右偏移量,显示对应的图片。这种方法看似是可行的,但是一旦图片数量过多,性能上就吃不消了。因为一开始浏览器就要去拉取所有的图片,阻塞其他的关键请求。
下面是这种方法的草图,画的有点粗糙还请别介意。
3、非常理想的方法
其实上面的方法都已经想到了,这种方法自然而然就会想到,那就是图片的合并。服务器通过视频处理技术把视频所有的截图合成一张图片返回给前端,类似于一张雪碧图,前端通过改变图片显示的位置,来达到图片切换的效果。这种方法性能极佳,而且前端开发成本低,是一种非常理想的方法。优酷视频网站目前就是采用这种方法的, 点击查看图片链接 。
然而理想很丰满,现实很骨感,服务端目前没有提供这种技术支持,因此还是要自己动手丰衣足食。
做个反思,这个功能的瓶颈在于如果一次加载图片数量过多,会阻塞请求。如果图片先不加载,但是在切换的加载又容易卡顿。那么如果请求图片时不阻塞主线程的请求,且在切换图片的时候不用重新获取图片,直接加载本地缓存好的图片数据是不是就行了呢,想到这里是不是已经有思路了呢?
4、有效的方法
引入web workers,获取图片的数据可以再web workers里去做,这样就不会干扰主线程的运行。获取的图片数据以blob的形式返回给主线程,保存在一个变量里。图片的src就指向这个blob数据,切换的时候就可以之间从本地获取了。web workers的好处就是他可以开多个线程这样可以更快地获取视频的预览图(但是开多了会卡:cry:)。 在 webpack 工程下可以采用 worker-loader 来引入web worker 文件。
表面上看上去是没什么问题了,但是在项目上线后确出了问题,幸运的是大晚上没有啥用户:joy:。令人奇怪的是在预发、测试环境下都是OK的,偏偏到线上就出了问题,没办法只能在线上调试,也辛苦运维老哥了,帮我打包了好几个版本:cry:,后来发现在线上环境加载webwork的时候抛了这个错误 “Failed to construct ‘Worker’: Script at ‘http:xxxx/worker.js’ cannot be accessed from origin”,一查才发现在跨域情况下(js 加载域名和html加载域名不一致),web workers是没法执行的。预发是正常的原因它的html加载的域名和 js 加载的域名是相同的,但是线上环境是不同的。
这算是踩到大坑了,当时想的是这个功能先不上了,但是自己投入了那么多时间去做这个功能,到头来却没上线还真是不甘心啊。看了下时间9月27号晚上10点44分,今天不上线那就只能等到节后了再上了,进度预览效果对整个产品的体验来说是非常重要的。还是决定要拼一拼,于是以发际线升高了3毫米为代价:sob:,找到了一种可行的方法。舍弃了原来的 worker-loader,采用内联脚本的方式引入webworkers,临时地解决了这个问题。
可以看下效果图,webwork 线程在奋力地加载图片,但是主线程还是依旧运行流畅。
附上最后的代码:
// worker.js function fetchImage(url, index) { return new Promise(resolve => { fetch(url, { method: 'GET' }) .then(res => { if (res.ok) { return res.blob(); } }) .then(blob => { resolve({index,blob}); }) .catch(e => { console.log(e); resolve({index,blob: ''}); }); }); } self.addEventListener('message', e => { const urlList = e.data; const fetchImages = urlList.map(item => { return fetchImage(item.url, item.index); }); Promise.all(fetchImages).then(data => { self.postMessage(data); }); }); // app.js 部分代码 const blob = new Blob([document.querySelector('#worker').textContent]); var webWorkUrl = window.URL.createObjectURL(blob); // 将图片分为5段,即开启5个 webworker 进程 const imageSize = Math.floor(this.totalTime / 5); for (let t = 0; t < this.totalTime; t += imageSize) { const urlList = []; for (let i = 0; i < imageSize && i + t { const worker = new Worker(webWorkUrl); workList.push(worker); worker.postMessage(list); worker.addEventListener('message', function(e) { const imageList = e.data; const previewImageList = previewImageInfo[videoType]; imageList.forEach(item => { const { index, blob } = item; // 将blob 数据转化为一个本地的url previewImageList[index] = window.URL.createObjectURL(blob); }); // 运行完及时释放,防止内存泄露 worker.terminate(); }); })(urlList, this.currentVideoType); } // 进度条拖动时触发 updatePreviewImage(time) { const previewImageList = previewImageInfo[this.currentVideoType]; this.previewImage = previewImageList[parseInt(time)]; this.isShowPreviewImage = true; },
四、预览图效果将来的优化方案
虽然上面介绍的引入webworker 的方法能暂时满足业务需求,但是还是存在很多的优化空间。
1、图片资源节流
目前加载的预览图数量是根据视频的时长加载的,就是说这个视频时长多少秒,就要加载多少张图片。可以优化为通过进度的百分比获取相应时间的图片,这个可以大幅度节省图片下载量。
2、利用缓存机制
现在是每次重新打开应用都会重新请求图片,运气好的话可以命中浏览器缓存,但是如果缓存失效就不得不重新获取一次图片数据,即使是走了协商缓存,也是非常浪费性能的。对此可以采用 native 缓存或者利用service worker 缓存来进行优化。
3、web worker 采用队列管理
考虑到 开启 web worker 数量的限制,视频页面每次销毁的时候也会顺带销毁调web worker 线程,下次进来后又得重新开启,效率上肯定有问题。因此采用队列的方式去管理web worker 的创建,当然这样还需要做很多额外的工作,比如优先级设定、如何与缓存机制结合使用、线程何时销毁等。
五、总结
文章写得有点长,很感谢你耐心地看完了。因为是第一次做视频相关的项目,加上播放器中又有许多复杂的交互逻辑,因此总得来说还是有一定挑战性的。因此对自己这次踩的坑做个总结,防止我们重复踩坑,少走些弯路,也少点烦恼,少掉几根头发,头发就是我们的财富,请行请珍惜:joy:。