chunked 分片下载,用fetch判断是否在下载中

1,452 阅读5分钟

我正在参加「掘金·启航计划」。

问题场景

最近在项目中要下载一个大的文件,点击后未看见进度条,也没看到在下载中,经过排查,是因为文件比较大,然后下载又是chunked 分片下载,产品要求前端加个进度条。可是去研究发现这个分片下载后端没有返回content-length,所以前端也无法写进度条。最后商量至少要让用户知道是在下载,不然用户会一直点击按钮。接下来先说说chunked

chunked下载

分块传输编码主要应用于如下场景,即要传输大量的数据,但是在请求在没有被处理完之前响应的长度是无法获得的。例如,当需要用从数据库中查询获得的数据生成一个大的 HTML 表格的时候,或者需要传输大量的图片的时候。 要使用分块传输编码,则需要在响应头配置 Transfer-Encoding 字段,并设置它的值为 chunked 或 gzip, chunked:

Transfer-Encoding: chunked
Transfer-Encoding: gzip, chunked

响应头Transfer-Encoding的字段值为:chunked,表示数据以一系列分块的形式进行发送。需要注意的是Transfer-Encoding和 Content-Length 这两个字段是互斥的,也就是说响应报文中这两个字段不能同时出现。下面我们来看一下分块传输的编码规则:

每个分块包含分块长度和数据快两个部分;

  • 分块长度使用 16 进制数字表示,以 \r\n 结尾;
  • 数据块紧跟在分块长度后面,也使用 \r\n 结尾,但数据不包含 \r\n;
  • 终止块是一个常规的分块,表示块的结束。不同之处在于其长度为 0,即 0\r\n\r\n。
  • 了解完分块传输的编码规则,我们来看如何利用分块传输编码实现文件下载

前端代码

html5

<h3>chunked 下载示例</h3>
<button onclick="download()">下载</button>

js

let downFileName = "";//下载的文件名
function download() {
   fetch(
     "xxx/down?xxx=xxx",//下载接口地址
     {
       method:"get",
       headers:{
         Accept:"application/json",
         Authorization:"tokenkey" //token权限相关
       }
     }
   )
    .then(processChunkedResponse)
    .then(onChunkedResponseComplete)
    .catch(onChunkedResponseError);
}
// 分片下载中
function processChunkedResponse(response) {
  downFileName = "";//没戏点击下载前清空文件名
  let filename = response.headers.get("content-disposition");//注意这里要用get才可以获取到头部里面的东西,不然是空的
  if(filename.indexOf("attchment;filename") > -1){
    filename = filename.substr("attchment;filename*=UTF-8".length);
  }
  downFileName = filename;//把文件名存储下来,后面好用
  let text = "";
  let reader = response.body.getReader();
  let decoder = new TextDecoder();

  return readChunk();

  function readChunk() {
    return reader.read().then(appendChunks);
  }

  function appendChunks(result) {
    let chunk = decoder.decode(result.value || new Uint8Array(), {
      stream: !result.done,
    });
    console.log("已接收到的数据:", chunk);
    console.log("本次已成功接收", chunk.length, "bytes");
    text += chunk;
    console.log("目前为止共接收", text.length, "bytes\n");
    if (result.done) { //done是true,就表示下载完了,否则继续下载
      return text;
    } else {
      return readChunk();
    }
  }
}
下载完成浏览器显示提示
function onChunkedResponseComplete(result) {
  let blob = new Blob([result], {
    type: "application/zip",
  });
  let url = window.URL.createObjectURL(blob);
  let link = document.createElement("a");
  link.style.display = "none";
  link.href = url;
  link.download = downFileName;
  document.body.appendChild(link);
  link.click();
  link.remove();
  setTimeout(() =>{ //给个延迟,浏览器自带的下载显示晚
    alert("下载成功")
  },3000)
}
// 下载错误
function onChunkedResponseError(err) {
  console.error(err);
}

当用户点击 下载 按钮时,就会调用以上代码中的 download 函数。在该函数内部,我们会使用 Fetch API 来执行下载操作。因为服务端的数据是以一系列分块的形式进行发送,所以在浏览器端我们是通过流的形式进行接收。即通过 response.body 获取可读的 ReadableStream,然后用 ReadableStream.getReader() 创建一个读取器,最后调用 reader.read 方法来读取已返回的分块数据。

因为 file.txt 文件的内容是普通文本,且 result.value 的值是 Uint8Array 类型的数据,所以在处理返回的分块数据时,我们使用了 TextDecoder 文本解码器。一个解码器只支持一种特定文本编码,例如 utf-8、iso-8859-2、koi8、cp1261,gbk 等等。

如果收到的分块非 终止块,result.done 的值是 false,则会继续调用 readChunk 方法来读取分块数据。而当接收到 终止块 之后,表示分块数据已传输完成。此时,result.done 属性就会返回 true。从而会自动调用 onChunkedResponseComplete 函数,在该函数内部,我们以解码后的文本作为参数来创建 Blob 对象。 这样就可以解决得知是否还在下载文件了,这中间可以做各种处理,希望这篇文章也可以帮助到困惑的你!

后续

在后面下载的时候发现下载的文件大小和实际的文件大小不一致导致文件不可用的情况,经过排查是因为后端是流的方式分片的,所以必须用流解码器TextDecoderStream才可以,不可以用文本解码TextDecoder,存储分片的数据也必须是数组的方式存储下来然后再通过a标签下载,修改后的代码如下

let downFileName = "";//下载的文件名
function download() {
   fetch(
     "xxx/down?xxx=xxx",//下载接口地址
     {
       method:"get",
       headers:{
         Accept:"application/octet-stream",//接受流的方式下载
         Authorization:"tokenkey" //token权限相关
       }
     }
   )
    .then(processChunkedResponse)
    .then(onChunkedResponseComplete)
    .catch(onChunkedResponseError);
}
// 分片下载中
function processChunkedResponse(response) {
  downFileName = "";//没戏点击下载前清空文件名
  let filename = response.headers.get("content-disposition");//注意这里要用get才可以获取到头部里面的东西,不然是空的
  if(filename.indexOf("attchment;filename") > -1){
    filename = filename.substr("attchment;filename*=UTF-8".length);
  }
  downFileName = filename;//把文件名存储下来,后面好用
  let text = [];
  let reader = response.body.getReader();
  let decoder = new TextDecoderStream();//流解码器

  return readChunk();

  function readChunk() {
    return reader.read().then(appendChunks);
  }

  function appendChunks(result) {
    
    
    text.push(result.value);//这里的存储区别
   
    if (result.done) { //done是true,就表示下载完了,否则继续下载
      return text;
    } else {
      return readChunk();
    }
  }
}
下载完成浏览器显示提示
function onChunkedResponseComplete(result) {
  let blob = new Blob([...result], { //解析区别
    type: "application/zip",
  });
  let url = window.URL.createObjectURL(blob);
  let link = document.createElement("a");
  link.style.display = "none";
  link.href = url;
  link.download = downFileName;
  document.body.appendChild(link);
  link.click();
  link.remove();
  setTimeout(() =>{ //给个延迟,浏览器自带的下载显示晚
    alert("下载成功")
  },3000)
}
// 下载错误
function onChunkedResponseError(err) {
  console.error(err);
}