前端流式传输播放音视频

4,603 阅读7分钟

前言

在 JavaScript 之外的后端语言中,流是一个很普遍的概念,通过流我们可以将数据分段传输,如此一来我们不必等到数据全部传输完毕再处理,当第一批数据传输到我们手上的时候,我们就可以开始使用了。

在前端领域,流则可以在网络请求中可以发挥巨大的作用,当传输一些音视频大文件时,请求的时间是很长的。如果用户是下载文件那长耗时没什么问题,但如果用户是想点播音视频呢,通过流我们不必等到传输完毕,可以在传输过程中处理数据并展示给用户,大大优化了体验。

在 JS 早期,想要实现流我们需要自己造轮子,如今 JS 也有了 Stream API 用于处理流,且 Fetch 更是全面支持了 Stream API,其response.body中负责携带数据的ReadableSteam就是一个二进制数据流。搭配 专为流媒体播放而生的MediaSource API,使得前端也有了很强的流媒体能力

ReadableStream 的处理

Fetch方法的response响应对象中,数据都以ReadableStream的二进制流存储,response 对象提供了json()formData()text()等多种转换方法,通常情况下根据响应的ContentType,转换成我们想要的数据类型进行处理,一些简单的网络请求我们便会这样做,由于数据量较小,转换时间往往很短。

除此以外我们还能通过Stream API,直接从ReadableStream中读取数据。当 HTTP(s)连接建立,我们拿到response的那一刻,数据传输就开始了,数据会以二进制流的形式不断添加到ReadableStream中。我们可以通过ReadableStream.getReader()获取一个流的读取器,此时该流会被锁定,只允许我们获得的这个读取器读取。再通过读取器的read()方法,循环读取流,分段拿取数据,实现流式传输,看一段示例代码:

window.fetch(url, fetchOption).then((response) => {
  if(!response.ok){
    throw new Error('网络连接失败')
  }else{
    const reader = response.body.getReader(); // 获取读取器

    // 声明处理函数
    const processRead = async (params) => {
      const {done, value} = params;
      if(!done){
        // 此时value就是读取到的数据,在此进行处理
        // 读取完毕后递归进行下一次读取
        await reader.read().then(processRead);
      }else{
        console.log('可读流读取完毕')
      }
    }

    // 开始读取
    reader.read().then(processRead);
  }
})

上面的代码中,我们声明了一个processRead函数用来处理reader.read()产生的数据,每一次 read 都会返回一个{done:boolean, value: Uint8Array}对象。done标识了可读流是否读取完成,value则是当前读取到的分块数据,为Unit8Array的数组,接下来我们需要做的就是将其传递给我们的流媒体播放器消费。

MediaSource 接收音视频数据

从前面的操作中,我们得到了多个Unit8Array的音视频数据,该如何使用它呢?通常都会转化为Object URL来供HTMLMediaElement消费:

// value即为我们的Unit8Array数据
const blob = new Blob([value], { type: 'audio/mpeg' });

const url = window.URL.createObjectURL(blob);

<audi src={url} />

但问题是我们有多个ArrayBuffer数据需要处理,很显然不可能将他们转换为多个url,然后传递给audiovedio标签消费掉。不过好在我们有专门处理流媒体播放的MediaSource API

MediaSource 的使用很简单,其内部创建SourceBuffer用于接收数据,且自身可以被传递到HTMLMediaElement对象上被消费,一段简单的例子:

// 创建mediaSource并将其传递到audio标签上
const mediaSource = new MediaSource();
const url = window.URL.createObjectURL(mediaSource);

<audio src={url}/>

// 内部创建SourceBuffer接收数据
const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
sourceBuffer.appendBuffer(chunk1);
sourceBuffer.appendBuffer(chunk2);
sourceBuffer.appendBuffer(chunk3); // 可以流式接收数据

上面的流程,我可以用这样一张图来表示:

MediaSource.jpg

这里的SourceBuffer是数据的主要接收者,一个MeidaSource中包含多个SourceBuffer实例,可以通过MeidaSource.sourceBuffers这个只读属性访问。一个SourceBuffer代表一个媒体块,就像一个视频包含音频,影像,字母等多个媒体块,此时你可以通过多个SourceBuffer来分开操纵他们,你也可以通过其来控制媒体流的质量,切换清晰度音质等。援引 W3C 官方文档的一张图,可以清晰地显示他们的关系:

MSE.png

当然这些都是MediaSource API一些比较高级的功能,这里我们只用到了其流式处理数据的特性,接下来我们重点关注如何创建 SourceBuffer 以及如何为其传递数据流

首先是创建的时机,MediaSource存在readyState只读属性,其包含以下三种情况:

  • closed:当前没有附着到任何 media 元素
  • open:已经附着到 media 元素且准备好接收 SourceBuffer 元素
  • ended:已附着但目前流已经被 endOdStream()关闭

我们只能在MediaSource创建后的open阶段进行SourceBuffer的创建,通过addSourceBuffer()创建一个带有给定 MIME 类型的新的SourceBuffer并添加到自身的SourceBuffers列表中。拿到SourceBuffer后再通过SourceBuffer.appendBuffer()将分块数据喂给它,就实现了简单的流式传输:

// 检测是否为open状态,否则添加监听器,等到open状态就绪后再传输
if (mediaSource.readyState === 'open') {
  createSourceBuffer();
} else {
  mediaSource.addEventListener('sourceopen', () => {
    createSourceBuffer();
  });
}


const createSourceBuffer = () => {
  const sourcebuffer = mediaSource.addSourceBuffer('audio/mpeg');

  window.fetch(url, fetchOption).then((response) => {
  if(!response.ok){
    throw new Error('网络连接失败')
  }else{
    const reader = response.body.getReader(); // 获取读取器

    // 声明处理函数
    const processRead = async (params) => {
      const {done, value} = params;
      if(!done){
        // 传输数据
        sourcebuffer.appendBuffer(value);
        // 读取完毕后递归进行下一次读取
        await reader.read().then(processRead);
      }else{
        console.log('可读流读取完毕')
        // 关闭流式传输
        sourceBuffer.abort();
        mediaSource.endOfStream();
      }
    }

    // 开始读取
    reader.read().then(processRead);
  }
})
}

需要注意的是在创建SourceBuffer时传入了一个'audio/mpeg'字符串,这是 MIME 类型字符串,标识了SourceBuffer所接收的数据类型,此外我们还可以额外指定解码方式,格式为'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',不具体指明的话SourceBuffer会自动帮你选择。具体的 MIME 类型查询和解码方式可以参考MIME 常见类型codesc 查询

添加缓冲区

在上面的例子中,每当reader读取到新的分块value时,我们都直接执行了SourceBuffer.appendBuffer()来加载数据,但是SourceBuffer的读取是异步的,我们不能在读取的过程中加载数据,否则当传输速度大于读取速度时会产生报错。

所以在流式传输的生产者和消费者之间,添加一个缓冲区是很有必要的,能帮助我们解决异步冲突的问题,现在升级一下我们之前的示例,通过SourceBufferupdateend读取结束事件,配合缓冲区实现完整的流式传输:

let isDone = false; // 是否传输结束
let isReady = true; // 是否准备好下一次加载
let bufferList = []; // 缓冲区数组

const mediaSource = new MediaSource();
const url = window.URL.createObjectURL(mediaSource);

// 检测是否为open状态,否则添加监听器,等到open状态就绪后再传输
if (mediaSource.readyState === 'open') {
  createSourceBuffer();
} else {
  mediaSource.addEventListener('sourceopen', () => {
    createSourceBuffer();
  });
}

// 发送请求并开始传输
const createSourceBuffer = () => {
  const sourcebuffer = mediaSource.addSourceBuffer('audio/mpeg');

  // 传输完毕事件监听器
  sourceBuffer.addEventLisener('updateend', () => {
    if(bufferList.length !== 0){
      // 读取完毕后缓冲区有数据,从缓冲区读取
      sourceBuffer.appendBuffer(bufferList.shift());
    }else{
      // 缓冲区无数据
      if(isDone){
        // 传输结束,关闭
        sourceBuffer.abort();
        mediaSource.endOfStream();
      }else{
        isReady = true; // 准备好进行下一次传输
      }
    }
  })

  window.fetch(url, fetchOption).then((response) => {
  if(!response.ok){
    throw new Error('网络连接失败')
  }else{
    const reader = response.body.getReader(); // 获取读取器

    // 声明处理函数
    const processRead = async (params) => {
      const {done, value} = params;
      if(!done){
        if(isReady){
          // 如果已准备好则直接读取
          sourceBuffer.appendBuffer(value);
          this.isReady = false;
        }else{
          // 否则加入缓冲区
          bufferList = [...bufferList, value];
        }
        // 读取完毕后递归进行下一次读取
        await reader.read().then(processRead);
      }else{
        console.log('可读流读取完毕');
        isDone = true;
      }
    }

    // 开始读取
    reader.read().then(processRead);
  }
})
}

总结

我们通过Stream APIMedia Source Extention (MSE)的配合,实现了一个简单的流式传输,可以让用户无需等待,下载的同时一边播放音视频,优化了用户体验。但以上两个 API 在前端流媒体的应用,还有很多玩法,值得我们去探索