低版本(Chrome18-64)浏览器同域并发请求限制优化方案

2,304 阅读8分钟

实际遇到的问题

浏览器同域并发请求数量限制

不同厂商的浏览器,对同一域名下不同请求的并发数量都有限制(见下图),以chrome为例,chrome同域名的并发请求限制数量为6个,也就是说,当前同一个个域名下,一旦存在等待响应的请求数量达到6个,那么后面该域名的其他请求,必须进入等待队列,待前面的请求完成后,才会被执行

BrowserHTTP 1.1
IE6,74
IE86
IE910
IE10,IE116
Firefox6
Safari4
Chrome6
Opera4

同域并发请求数量限制,不只针对接口请求,页面上的静态资源请求,包括图片,音视频,css/js文件等,都会适用同样的策略限制。

项目中存在多个音视频同时展示的场景

在一些业务场景中,系统需要兼容旧版本的浏览器内核,且根据业务的不同场景,有可能会出现一个页面同时需要展示多个音视频的场景,在此业务场景下,有一定概率会触发浏览器同域并发请求数量限制策略。

当触发浏览器同域并发请求数量限制时,前面6个音视频未完成请求前,后面的音视频请求均在等待队列,都无法加载元数据(音频无法获取metaData得到时长信息,视频无法获取metaData得到时长和视频首图信息),要前面的请求完成以后,才能获取到上述信息,在此场景下,用户无法通过音视频在页面上展示的元数据来判断音视频文件是否正常,超过限制数量的音视频无法被加载元数据。

低版本浏览器对音视频加载策略存在缺陷

经过实验,发现(Chrome18-64)版本内核对音视频加载存在以下缺陷:

  1. HTMLMediaElement preload 默认设置为auto

H5中,和可配置preload属性,大部分低版本浏览器默认值是auto,MDN规范建议的值则是metadata,表示浏览器只会请求元数据,不会请求整个音视频, preload可配置的值如下:

枚举值说明
none示意用户可能不会播放该音视频,或者服务器希望节省带宽;换句话说,该音视频不会被缓存
metadata示意即使用户可能不会播放该音视频,但获取元数据 (例如音视频长度) 还是有必要的
auto示意用户可能会播放音视频;换句话说,如果有必要,整个音视频都将被加载,即使用户不期望使用

因此,Chrome18-64等低版本浏览器,遇到每个音视频会默认尝试加载整个音视频,当触发浏览器同域并发请求数量限制策略时,且加载整个音视频数据比较耗时,就会明显影响到后续音视频的加载。 2. preload枚举值配置为metadata无效

经过实验测试,Chrome18-64将或的preload值配置为metadata时,并不会在获取到metadata后停止请求,而是会继续尝试加载整个音视频,因此,低版本浏览器,设置preload为metadata无效,metadata和auto在低版本浏览器上的表现一致(尝试加载整个音视频),metadata配置无法解决浏览器同域并发请求限制对页面音视频加载的影响。 3. 低版本浏览器media请求挂起策略+同域并发请求限制,导致请求一直pending

chrome39版本下,当音视频资源请求超过2.2M,请求会在获取到2.2M数据后,主动挂起,且不会释放请求,因此改请求会一直处于未完成状态,并一直占用该域名的请求数,当类似请求达到浏览器同域并发请求限制数量时,后面的请求则永远在排队,导致类卡死现象,除非手动触发下前面音视频的播放,让挂起的请求继续完成请求,才能释放该请求,后续请求才能执行。举例:当用户上传了6个超过2.2M的音频后,上传第7个音频时,就会一直卡住,无法上传,出现这种情况,后面相同域名的请求就会一直卡在等待中,无法发送请求。

image.png

小结

为了解决低版本浏览器的同域并发请求限制策略和多媒体请求挂起策略导致的资源加载和请求卡死问题,本文介绍一种通用、低耦合、高兼容、可配置、即插即用的浏览器同域并发请求优化方案

优化方案介绍

概要

本文介绍的是一种通用、低耦合、高兼容、可配置、即插即用的处理低版本浏览器同域并发请求优化方案,接入项目只需要在页面上引入media.js文件(大小572 B),即可实现低版本浏览器同域并发请求的优化。

行业一般解决方案

行业中,一般采用多域名/cdn模式来解决浏览器同域并发请求限制。因为浏览器并发请求限制只针对同一个域名,因此,不同域名的并发请求数量不会受限,举个例子,在浏览器同域并发请求显示数为6的前提下,页面需要同时展示7个图片,那么7个图片资源url可以分配两个不同的域名,这样,同一个域名的并发请求不超过6个,则这7个图片资源都可以同时加载出来。

举例:京东有多个静态资源域名,如京东首页上的商品图片,某个商品图片的地址为:img11.360buyimg.com/jdcms/s230x… 我们把域名img11改成img12、img13、img14都能请求到相同的图片,因此当img11并发请求达到6个的时候,其他图片资源可以通过其他域名(img12、img13、img14)来获取,域名不同,则不会受到浏览器同域并发请求限制。

image.png

image.png

实际方案评估

作者评估了域名申请,CDN,修改本地host文件来控制域名等方案,这些方案或无法同时满足这三种作者所在项目的部署场景,或会额外带来成本,或会额外带来失效风险,因此,评估后,决定使用纯前端的方案来处理。

前端浏览器同域并发请求优化方案整体思路

监听dom变化,如果媒体对象被创建,则给媒体对象添加suspend监听**事件。当浏览器加载媒体资源时过程中触发主动挂起(上面提到的超过2.2M音频文件加载到2.2M会被浏览器挂起,无法释放请求),会触发suspend事件,因此,我们在suspend事件中,我们通过代码逻辑触发,让资源继续加载(防止出现挂起后占用浏览器同域并发请求数量)。

image.png

image.png

具体实现

chrome版本配置

const VERSION = 39; // 配置需要优化的最高版本
const userAgent = navigator.userAgent;
const chromeVersionRegex = /Chrome/([0-9]+)./;
const match = userAgent.match(chromeVersionRegex);

if (match) {
    const chromeVersion = match[1];
    if (Number(chromeVersion) <= VERSION) {
        // 执行浏览器同域并发请求优化逻辑
        // ...
    }
}

dom树变化监听

使用Web API MutationObserver监听dom树更新,当dom 有子节点(childList)变化时,执行callback回调,在回调中对音视频加载进行处理。

const targetNode = document.querySelector('body');
const config = {
    childList: true
};
const callback = (mutationsList, observer) => {
   // dom更新回调
   // ...
};
const observer = new MutationObserver(callback);
observer.observe(targetNode, config);

媒体挂起监听

遍历音视频dom节点,给所有音视频节点添加suspend的监听事件,当音视频被挂起时(Chrome39下超过2.2M的音视频资源会在2.2M处主动触发挂起),此时调用function bufferMedia 主动触发音视频继续缓冲,可以释放出当前音视频占用的同域并发请求数量。

const medias = document.querySelectorAll('audio, video');
if (medias.length > 0) {
    const _loop = (i) => {
        if (!medias[i].onsuspend && (medias[i].preload !== 'matedata' && medias[i].preload !== 'none')) {
            medias[i].onsuspend = function () {
                bufferMedia(medias[i]); // 监听suspend事件逻辑
            };
        }
    };
    for (let i = 0; i < medias.length; i++) {
        _loop(i);
    }
}

触发继续下载

通过对比音视频总时长duration和音视频缓冲区结束点时间,判断音视频是否已经完成下载,低版本浏览器(Chrome39)在音视频缓冲完成(视频时长=缓冲区结束点时间)后,才能释放占用的同域并发请求限制数量。

function bufferMedia(media) {
  if (media.duration > media.buffered.end(0)) {
      if (media.paused) {
          media.currentTime = media.buffered.end(0) + 0.01; // 通过修改当前时间到缓冲结束时间后触发继续下载
      }
  }
  if (media.duration === media.buffered.end(0)) {
      // 音频/视频已缓冲完毕, 更新时间控制标记
      media.paused() && media.attributes['data-timechange'].value = '1'
  }
}

多媒体组件封装

封装原h5默认样式,将h5的播放时间,播放进度,播放状态等信息的展示和操作进行封装,提升UI和用户体验的同时,解决优化后,音频currentTime修改后在原h5上跳动的问题。

image.png

<template>
  <div class="page-main">
    <div class="audio-player flex ac" ref="audioPlayer">
      <div class="audio__btn-wrap">
        <AudioBtn :isPlaying="isPlaying" />
      </div>
      <div class="audio_content item flex column">
        <div class="flex ac">
          <ProgressBar
            :showProgressBar="showProgressBar"
            @panstart="handleProgressPanstart"
            @panend="handleProgressPanend"
            @panmove="handleProgressPanmove"
          />
        </div>
      </div>
      <audio
        ref="audio"
        class="audio-player__audio"
        :src="url"
        @ended="onEnded"
        @timeupdate="onTimeUpdate"
        @loadedmetadata="onLoadedmetadata"
        v-bind="$attrs"
        data-timechange="0"
      ></audio>
    </div>
    <slot name="tools"></slot>
  </div>
</template>

<script>
// ...
export default {
  methods: {
   // ...
   // 开始播放
    play() {
      this.isLoading = true;
      if(this.$refs.audio.dataset.timechange && this.$refs.audio.dataset.timechange == '1') {
        this.$refs.audio.currentTime = 0;
        this.$refs.audio.dataset.timechange = '0';
      }
  }
  // ...
}
// ...
</script>

Media.js

<script src="media_mini.js" type="text/javascript"></script>
"use strict";var targetNode=document.querySelector("body"),config={childList:!0},callback=function(e,o){console.log("dom 修改了");var n=document.querySelectorAll("audio");if(n&&n.length>0)for(var r=function(e){n[e].onsuspend||(n[e].onsuspend=function(){console.log("音频/视频频挂起"),n[e].duration>n[e].buffered.end(0)&&n[e].paused&&(n[e].currentTime=n[e].buffered.end(0)+.01),n[e].duration==n[e].buffered.end(0)&&console.log("音频/视频已缓冲完毕")})},t=0;t<n.length;t++)r(t)},observer=new MutationObserver(callback);observer.observe(targetNode,config);