轮询图片展示的监控墙
本文记录了监控墙从视频流的形式,改成轮询图片展示,每一秒拉取一次,一次从1张到30张不等。
涉及内容:
- 依次请求
- 渲染到 Canvas
- 可视区内发送请求
浏览器的限制
浏览器限制同一个域名同时发送请求的数量有限,chrome 通常为6个。讨论以最高上限为准,同时发送30个,6个请求正在被处理,其余的请求只能排队,此时若本次请求在1s内还未返回,又会在1s过后再次发送30个请求,导致请求越来越多,请求顺序也会凌乱,可能出现后面发出的请求在前面发出的请求之前回来。
若是处理不当,请求还会被浏览器 canceled 掉,具体原因查看:
[What does status=canceled for a resource mean in Chrome Developer Tools]
解决的方式是自己维护一个排队请求的队列,先同时发送6个请求,当一个请求回来后继续发送下一个请求,直到本次所有请求回来后,再进行下一个周期的发送。
还有另一种解决方式,询问视频部童鞋思路,大致是更换为多个域名。假如有5个域名,这样就可以能同时发送30个请求,而不需要排队,这也是一种思路,就是有点费域名。
依次发送请求<一>
采用 Promise.race() 实现:
function limitLoad(urls, handler, limit){
const arr = [].concat(urls)
let promises = []
let n = 0;
let len = arr.length
promises = arr.splice(0, limit).map((url, index) => {
return handler(url).then(() => {
return index
}).finally(() => {
n++
console.log(1, n ,len, index, url);
if(n === len){
console.log('请求结束1');
}
})
})
let p = Promise.race(promises)
for(let i = 0; i < arr.length; i++){
p = p.then((index) => {
promises[index] = handler(arr[i]).then(() =>{
return index
})
return Promise.race(promises)
}).finally(() => {
n++
console.log(2, n ,len, i ,arr[i]);
if(n === len){
console.log('请求结束2');
}
})
}
}
具体可以查看这里:点击查看
先用 map 生成一个指定数量的并发列表,在浏览器中建议最多是6个,放在 Promise.race() 中,目的是有一个返回了就执行 then 函数,在 then 函数中指定的请求完成后,返回当前的下标,目的是之后再往该返回的下标中添加请求,始终保持这个列表中只有 6 个在同时发送。
for 循环中对 p 重置了多次,每次其实都是 Promise.race() 执行后要执行的 then 函数,这样就形成了链式调用 Promise.race().then()、Promise.race().then()、Promise.race().then() 的形式。
在 finally 中计数完成后的个数,当与队列中的长度相同时,则说明发送完了请求。
但有个问题,finally 是当本次 Promise.race() 只要有一个完成了便会执行,如果是刚加入这个队列中,请求还没返回来,于此同时另一个请求回来了,finally 也就执行了,计数器 n 会累加,导致请求并未完全结束就可能和 len 相同,可以看上面的例子,比较明显。
本次我是把这一次算作一个周期,用了定时器 setTimeout 再开启下一个周期,所以这个时间差还能接受。如果是精准的控制必须所有的请求都回来了才能发送下一个周期的请求,则还需要另外的方案。
依次发送请求<二>
上面的方式是把一个队列中的请求看做一组,等到本次这一组发送完成后,再递归一次,进行下一组依次请求。
如果不用分成组进行,而是循环进行,不间断,也就是一个请求结束后,立即放回到队列中,进行下一次请求。
思路维护一个同时并发为6个请求的队列,一个请求结束后,该位置就重置为另一个请求,继续执行,把已经执行过的请求再放回到队列中,以便下一个继续能取到。
这里使用了 Proxy 来监控对应下标被赋值,代码如下:
function limitLoad(urls, handler, limit){
const stack = [].concat(urls) // 存储所有请求的队列,后面可以取和存
let queue = stack.splice(0, limit) // 指定个数的队列
// 代理请求
let proxyQueue = new Proxy(queue, {
set(target, prop, value){
if(!value) return
handler(value).then(() => {
stack.push(value)
let item = stack.shift()
if(item){
proxyQueue[prop] = item
}
})
}
})
for(let i = 0; i < proxyQueue.length; i++){
handler(proxyQueue[i]).then(() => {
proxyQueue[i] = stack.shift()
stack.push(proxyQueue[i])
})
}
}
详细代码请点这里:点击查看
以上代码实现了大概,没有用在现在的业务中,而是采用了第一种方案。考虑到 Proxy 的兼容问题。可以再延伸思考,给每一个请求都标记上时间,当到了规定的时间再加入到队列,触发了加入队列便开始执行,这样就类似 Vue 中响应式的原理,也用劫持的方式实现。
渲染到 Cavas
在业务中,每一秒请求一张图片,需要判断请求图片是否已返回,可以继续下一个图片的请求,用的是 new Image 监控 load 事件。
function getImgByImage(url: string, img?: any){
return new Promise((resolve,reject) => {
let img: any= null
if(!img){
img = new Image()
img.onload = () => {
resolve({type: 'success', img})
}
img.onerror = (e: any) => {
resolve({type: 'error', error: e, img})
}
}
img.src = url + '&' + 'mt=' + Math.random();
})
}
其中 img 如果传了的话,说明重用上一次创建的 Image 对象,这样节省创建对象的内存。
无论是 load 还是 error 都调用了 resolve,通过自定义 type 来区分成功还是失败,因为上述依次请求第一种用 Promise.race() 实现要拿到请求后的下标,当使用 catch 捕获时要在 catch 中定义返回的下标,此时 finally 不执行了。
拿到图片后,一开始使用 img 标签渲染,当设置了 src 时又会发一次请求,因为后端不设置缓存,所有又会发送一次请求。便考虑使用 cavas 渲染,并且 canvas 渲染要比 img 渲染更少的内存占有。(这个还需要实测一下)。
使用 drawImage,会出现模糊问题,解决方案可以参考这里:点击查看
大致代码如下:
function drawImage(roomid: string, img: any) {
var {canvas}= this.getCanvas(roomid);
if(!canvas) return;
// 判断图片宽高比例
const imgScale = img.width / img.height;
canvas[0].width =img.width;
canvas[0].height =img.height;
canvas[1].clearRect(0,0,canvas[0].width,canvas[0].height);
try{
if(imgScale <= 1){
canvas[1].drawImage(img, 0, 0, img.width, img.height);
}else {
canvas[1].drawImage(img, 0, 0, img.width, img.height, 0 , img.height/4, img.width, img.height/2);
}
}catch (e) {
// 上报错误
console.log('e: ', e);
}
}
canvas 作为 dom 节点在 Vue 中使用 $refs 取元素,每秒都会执行一次,为了不频繁的取值,通过 getCanvas 方法做了缓存,canvas[0] 代表 canvas 元素本身,canvas[1] 是 context 。
获取宽高比,在渲染时,会分不同的情况,当宽大于高度时,需要居中显示,而不能拉变形。
canvas的用法参考MDN:点击查看
可视区内发送请求
只有在可是区内才发送图片的请求,减少不必要的请求,采用 getBoundingClientRect 进行判断:
function isInViewPort(el: any) {
if(!el) return false
const viewPortHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight
const getBoundingClientRect = el.getBoundingClientRect()
const top = getBoundingClientRect && getBoundingClientRect.top
const bottom = getBoundingClientRect && el.getBoundingClientRect().bottom
return top <= viewPortHeight + 20 && bottom >=0
}
如果每一个周期都要把所有的元素都判断一次是否在可视区,那每一秒都要判断一次,有点浪费。
初次先把每一个元素判断一次是否在可视区内,随后监听滚动 scroll 事件和 resize 事件,当正在触发这两个事件时,不再请求任何图片,当不再触发时,则再次判断是否在可视区内,发送在可视区内的图片请求。
监听 scroll 事件,resize 事件同理:
window.addEventListener('scroll', () => {
// 清除要发送的图片队列递归
clearTimeout(this.imgUrlTimer)
// 清除要开启进行排队的定时器
clearTimeout(this.scrollTimer)
// 排队的定时器
this.scrollTimer = setTimeout(() => {
this.isScrollOrResize = true // 已经动过scroll了,下次要判断那些图片在可视区内
this.control(false) // 准备执行请求,false 代表不是第一次请求了
}, 300)
})
control 中简略代码如下:
function control(isFrist: boolean = true){ // 是否是第一次请求,第一次全部图片请求一次
const {roomidAndUrlMap} = this;
// 所有房间
const roomidArr: string[] = Object.keys(roomidAndUrlMap)
// 存放在可视区的roomid
let arr: string[] = []
// 如果是第一次请求,全部走一遍
if(isFrist){
arr = arr.concat(roomidArr)
}else { // 不是第一次请求了
if(this.isScrollOrResize){ // 动了才判断是否在可视区
roomidArr.forEach((roomid) => {
if(this.$refs[roomid]){
const canvas = (this.$refs[roomid] as any)[0]
// // 判断在不在可视区
if(this.isInViewPortOfTwo(canvas)){
// 并且在线
if(this.transOnline(this.updateInfo[this.roomidAndStaridMap[roomid]],{}) !== 0){
arr.push(roomid)
}
}
}
})
this.preRoomids = [...arr] // 缓存一下
this.isScrollOrResize = false // 下次不再判断是否在可视区了
}else {
// 直接走上次缓存的roomid
arr = [...this.preRoomids]
}
// 依次请求代码忽略。。。。
}
以上是记录本次视频流转图片请求的过程。