我们之前分了两个篇幅的文章分别讲解了:
两篇文章讲解的都是自动启动浏览器下载。下载的进度浏览器进行反馈,文件小的时候浏览器会很快下载完并提示,但是文件很大的话,那么下载就很慢了,准确来说数据拉取很慢,点击之后页面很久才会响应。这个时候,我们加个 loading
转圈圈提示就行了,但是不友好,是否让用户知道数据加载到哪里了呢?加载完后浏览器吊起下载。
那么,我们如何获取到文件加载的进度呢?
带着这个问题,展开本文的案例讲解。
本文演示的项目是个 SSR
的应用。
案例的环境(Main)
mac m1
node version - v14.18.1
Google Chrome: 版本 116.0.5845.187(正式版本) (arm64)
在开始前,我们生成一个大文件,比如 1GB
的 test.zip
文件。
bash
$ 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 的信息和响应状态 status ;3 -> 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 / onreadystatechange |
当 readyState 值更改时触发 |
timeout / ontimeout |
当接口请求超时情况触发 |
loadend / onloadend |
当接口请求完成后触发,不管接口是成功请求还是失败请求 |
abort / onabort |
当接口请求被中断时触发 |
progress / onprogress |
当请求接收更多的数据时,定期 触发。"定期触发" 的时间间隔是由浏览器决定的,并且取决于网络传输速度和其他因素。常常用来展示数据拉取进度 |
案例
下面是案例,我们设定服务端的代码如下:
javascript
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 等文件的展示比较常用。 |
我们在前端模版文件中触发文件下载:
html
<!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>
初始效果如下:
触发按钮,发起请求,我们监听了 onprogress
钩子事件 progress event,对返回的已加载数据 e.loaded
和 e.total
进行处理。计算出拉取文件的速度和剩余时间,并在页面中展示出来。当文件流拉取完后,到了我们的老朋友 a
标签上场,处理该 blob
二进制对象数据,吊起浏览器下载。
上面也提到了,e.total 需要后端服务配合
Content-Length
触发的动图效果如下:
总结
本文我们通过使用原生的 xhr
来拉取数据,需要注意点如下:
- 服务端要配合
Content-Length
- 客户端需要在钩子函数
onprogress
中处理数据 - 调接口拉取数据后,自动唤起浏览器下载