likes
comments
collection
share

前端文件下载(三)

作者站长头像
站长
· 阅读数 25

我们之前分了两个篇幅的文章分别讲解了:

两篇文章讲解的都是自动启动浏览器下载。下载的进度浏览器进行反馈,文件小的时候浏览器会很快下载完并提示,但是文件很大的话,那么下载就很慢了,准确来说数据拉取很慢,点击之后页面很久才会响应。这个时候,我们加个 loading 转圈圈提示就行了,但是不友好,是否让用户知道数据加载到哪里了呢?加载完后浏览器吊起下载。

那么,我们如何获取到文件加载的进度呢?

带着这个问题,展开本文的案例讲解。

本文演示的项目是个 SSR 的应用。

案例的环境(Main)

mac m1

node version - v14.18.1

Google Chrome: 版本 116.0.5845.187(正式版本) (arm64)

在开始前,我们生成一个大文件,比如 1GBtest.zip 文件。

$ cd path/to/project/public
# 从 /dev/zero 中创建大小为 1GB 的 test.zip 空文件
$ dd if=/dev/zero of=test.zip bs=1m count=1024 

前端文件下载(三)

XMLHttpRequest

本文通过 XMLHttpRequest 原生控制文件下载。我们先来认识 XMLHttpRequest

XMLHttpRequest (XHR) 对象用来和服务端进行任何数据交互。

XMLHttpRequest 实例关键属性:

属性名说明
readyState「只读属性」表示接口请求的状态。有值:0 -> UNSENT 表示客户端已经创 XHR 对象,但是 open() 方法没有调用;1 -> OPENED 表示 open() 方法被调用;2 -> HEADERS_RECEIVED 表示 send() 方法被调用,此时可以获取到相应头 headers 的信息和响应状态 status3 -> LOADING 表示数据下载中,responseText 中保存部分数据;4 -> DONE 表示请求操作完成,可以获取响应数据。状态 4 常用
response「只读属性」表示返回的数据。数据的类型可以是 ArrayBuffer, Blob, Document, JS 对象,字符串等,这取决于 responseType 设置什么值
responseType指定响应的类型。
status「只读属性」响应状态码
timeout请求接口自动取消的时间设定(毫秒)
withCredentials带凭证。跨域时候使用值 true

XMLHttpRequest 实例关键方法:

方法名说明
open()初始化一个请求。open(method, url, async「optional」, user「optional」, password「optional」)
send()发送一个请求。send(body「optional」)
setRequestHeader()设置请求头。setRequestHeader(header, value)
abort()请求发送过程中,中断请求。

XMLHttpRequest 事件,这里我理解为钩子函数,关键的有:

钩子函数说明
readystatechange / onreadystatechangereadyState 值更改时触发
timeout / ontimeout当接口请求超时情况触发
loadend / onloadend当接口请求完成后触发,不管接口是成功请求还是失败请求
abort / onabort当接口请求被中断时触发
progress / onprogress当请求接收更多的数据时,定期触发。“定期触发” 的时间间隔是由浏览器决定的,并且取决于网络传输速度和其他因素。常常用来展示数据拉取进度

案例

下面是案例,我们设定服务端的代码如下:

const Koa = require('koa');
const Router = require('koa-router');
const views = require('koa-views');
const path = require('path');
const fs = require('fs');

const app = new Koa();
const router = new Router();


// ejs template
app.use(
  views(path.join(__dirname, 'views'), {
    extension: 'ejs'
  })
);

router.get('/', async (ctx) => {
  await ctx.render('index'); // template
});

router.get('/download/file', async (ctx) => {
  const filePath = path.join(__dirname, 'public', 'test.zip');

  const stats = fs.statSync(filePath);
  const fileSize = stats.size; // file size
  ctx.set('Content-Length', fileSize.toString()); // set

  ctx.set('Content-disposition', 'attachment; filename=test.zip'); // disposition: attachment -> file download not preview
  ctx.set('Content-type', 'application/octet-stream');
  ctx.body = fs.createReadStream(filePath); // create read stream
});

app.use(router.routes());

app.listen(3000, () => {
  console.log('Server is running on http://localhost:3000');
});

上面,我们设定了文件下载的接口 /download/file。⚠️请注意,为了能够触发 onprogress 事件。服务器必须支持分块传输或者提供 Content-Length 头部信息。我们还设定了 Content-Disposition

Content-Disposition 内容配置有以下的值:

备注
attachment控制文件下载。告诉浏览器将响应体作为附件下载,而不是在浏览器中直接打开。同时,可以设置 filename 参数指定下载文件的名称,如上示例
inline控制内联显示。告诉浏览器在页面中直接内联现实响应体,而不是下载。一些图片,PDF 等文件的展示比较常用。

我们在前端模版文件中触发文件下载:

<!DOCTYPE html>
<html>
<head>
  <title>SSR Download File</title>
</head>
<body>
  <h1>Hello, Jimmy!</h1>
  <button id="download">Download File</button>
  <div style="display: flex; align-items: center;">
    <span>Downloading progress:</span>
    <progress id="progress" value="0" max="100"></progress>
    <span id="progressVal">0%</span>
  </div>
  <p id="hint"></p>
  <script>
    (function(){

      let downloadBtn = document.getElementById("download");
      let progressDom = document.getElementById("progress");
      let progressValDom = document.getElementById("progressVal");
      let hintDom = document.getElementById('hint');

      downloadBtn.addEventListener('click', function(){
        const startTime = new Date().getTime(); // start time

        const request = new XMLHttpRequest();
        request.responseType = 'blob'; // response type
        request.open('get', '/download/file');
        request.send();
        
        request.onreadystatechange = function() {
          if(this.readyState == 4 && this.status == 200) { // is download ok
            const downloadLink = document.createElement('a');
            downloadLink.href = URL.createObjectURL(this.response); // createObjectURL
            downloadLink.download = 'demo.zip'; // should add, or filename extension not work
            downloadLink.click();
            URL.revokeObjectURL(downloadLink.href); // revoke
          }
        }

        request.onprogress = function(e) {
          const percent_complete = Math.floor((e.loaded / e.total) * 100);

          const duration = (new Date().getTime() - startTime) / 1000;
          const bps = e.loaded / duration; // bits per second
          const kbps = Math.floor(bps / 1024); // kilobits per second

          const remain_time = Math.ceil((e.total - e.loaded) / bps); // seconds

          progressDom.value = percent_complete;
          progressVal.innerText = percent_complete + '%';
          hintDom.innerText = `${kbps} Kbps => ${remain_time} seconds remaining.`;
        }
      })
    })()
  </script>
</body>
</html>

初始效果如下:

前端文件下载(三)

我们设置了request.responseType = 'blob' 的接口。触发按钮,发起接口请求,我们监听了 onprogress 钩子事件 progress event,对返回的已加载数据 e.loadede.total 进行处理。计算出拉取文件的速度和剩余时间,并在页面中展示出来。当文件流拉取完后,到了我们的老朋友 a 标签上场,处理该 blob 二进制对象数据,吊起浏览器下载。

上面也提到了,e.total 需要后端服务配合 Content-Length

触发的动图效果如下:

前端文件下载(三)

总结

本文我们通过使用原生的 xhr 来拉取数据,需要注意点如下:

  • 服务端要配合 Content-Length
  • 客户端需要在钩子函数 onprogress 中处理数据
  • 调接口拉取数据后,自动唤起浏览器下载