介绍两种大文件下载方式,并分析其中的优缺点
1.传统的大文件下载方式
- 介绍
后端进行文件的异步下载,前端需要做一个类似的下载中心的页面,点击下载文件,然后等待后端异步下载后返回一个下载链接,前端通过这个链接下载文件。
- 缺点 这种方式最简单直接,但是有一个致命的问题是非常消耗服务器资源,所以不推荐这样做
2.前端后端做 流 + 分片下载
思路
这里实现的思路就是 咱们大文件通常存在存储服务器,比如 minio,会提供一个 url 的地址,如果咱们的文件是一个超大文件,那么我们如果直接拿到 url 地址进行前端创建创建 Blob 对象,然后a标签下载,那么出现前端页面点击了下载按钮,但是长时间浏览器没有反应,因为这种情况,浏览器会将 blob 下载到浏览器的内存中,所以会有很大问题是,可能会造成浏览器卡顿和崩溃。
那么我们需要借鉴分片上传的思路,用分片的思维去进行下载:
后端提供响应头content-range
返回分片范围,响应体返回当前分片的可读流
前端请求头发送Range字段
,提供当前分片的范围,然后读取可读流,然后创建 blob,利用 URL.createObjectURL(blob)用 a 标签下载
后端实现,这里使用 koa 进行
步骤
- 后端需要拿到文件路径然后通过前端发来的分片请求头中的
Range
字段,去获取每次分片的开始和结束位置 - 如果分片的范围正确 就返回
状态码206
并 创建响应头的content-range
,如 content-range:bytes 0-10485759/60020262 将文件的分片范围和文件总大小返回 - 紧接着将分片范围的文件利用可读流
createReadStream
的形式返回到响应体body
中
关键代码
javascript
// 下载文件分片
router.get("/down/:name", async (ctx) => {
try {
const fileName = ctx.params.name;
// 获取文件的路径和文件的大小
const filePath = path.resolve(__dirname, DOWNLOAD_DIR, fileName);
const size = fs.statSync(filePath).size || 0;
// 获取请求头的 Range 字段
//range:bytes=0-10485759
const range = ctx.headers["range"];
console.log({ range });
//没有 Range 字段, 则不使用分片下载, 直接传输文件
if (!range) {
ctx.set({
"Content-Disposition": `attachment; filename=${fileName}`,
});
ctx.response.type = "text/xml";
// https://developer.mozilla.org/zh-CN/docs/Web/API/ReadableStream
ctx.response.body = fs.createReadStream(filePath);
} else {
// 获取分片的开始和结束位置
const bytesRange = range.split("=")[1];
let [start, end] = bytesRange.split("-");
start = Number(start);
end = Number(end);
// 分片范围错误
if (start > size || end > size) {
ctx.set({ "Content-Range": `bytes */${size}` });
ctx.status = 416;
ctx.body = {
code: 416,
msg: "Range 参数错误",
};
return;
}
// 开始下载分片
// content-range:bytes 0-10485759/60020262
ctx.status = 206;
ctx.set({
"Accept-Ranges": "bytes",
"Content-Range": `bytes ${start}-${end ? end : size}/${size}`,
});
ctx.response.type = "text/xml";
ctx.response.body = fs.createReadStream(filePath, { start, end });
}
} catch (error) {
console.log({ error });
ctx.body = {
code: 500,
msg: error.message,
};
}
});
前端实现 vue
步骤
- 前端首先需要明确分片
CHUNK_SIZE
的大小,这里以 1MB 为例。 - 前端需要从后端拿到文件总大小,然后根据总大小和分片大小计算出分片数量
chunksCount
。 - 紧接着需要思考,如果分片量大,需要做一个请求队列,限制请求的并发数量,然后发送请求,拿到每一个分片的可读流,最后组装成 Blob 对象,然后创建临时 url 标签,利用 a 标签进行下载
部分代码
javascript
const CHUNK_SIZE = 10 * 1024 * 1024; // 一个分片10MB
async function asyncPool(poolLimit, array, iteratorFn) {
const allTask = []; // 存储所有的异步任务
const executing = []; // 存储正在执行的异步任务
for (const item of array) {
// 调用 iteratorFn 函数创建异步任务
const p = Promise.resolve().then(() => iteratorFn(item, array));
allTask.push(p); // 保存新的异步任务
// 当 poolLimit 值小于或等于总任务个数时,进行并发控制
if (poolLimit <= array.length) {
// 当任务完成后,从正在执行的任务数组中移除已完成的任务
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e); // 保存正在执行的异步任务
if (executing.length >= poolLimit) {
await Promise.race(executing); // 等待较快的任务执行完成
}
}
}
return Promise.all(allTask);
}
const saveFile = (name, buffers, mime = "application/octet-stream") => {
const blob = new Blob([buffers], { type: mime });
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.download = name;
a.href = blobUrl;
a.click();
URL.revokeObjectURL(blob);
};
// 获取待下载文件的大小
async getFileSize(name = this.fileName) {
try {
const res = await http.get(`/size/${name}`);
this.fileSize = res.data.data;
return res.data.data;
} catch (error) {
console.log({ error });
}
},
/**
* 下载分片内容
* @param {*} start
* @param {*} end
* @param {*} i
* @param {*} ifRange
*/
async getBinaryContent(start, end, i, ifRange = true) {
try {
let options = {
responseType: "blob",
};
if (ifRange) {
options.headers = {
Range: `bytes=${start}-${end}`,
};
}
const result = await http.get(`/down/${this.fileName}`, options);
return { index: i, data: result };
} catch (error) {
return {};
}
},
// 分片下载
async onDownload() {
try {
const fileSize = await this.getFileSize(this.fileName);
const chunksCount = Math.ceil(fileSize / CHUNK_SIZE);
const results = await asyncPool(
3,
[...new Array(chunksCount).keys()],
(i) => {
const start = i * CHUNK_SIZE;
const end =
i + 1 === chunksCount ? fileSize : (i + 1) * CHUNK_SIZE - 1;
return this.getBinaryContent(start, end, i);
}
);
results.sort((a, b) => a.index - b.index);
const buffers = new Blob(results.map((r) => r.data.data));
saveFile(this.fileName, buffers);
} catch (error) {
console.log({ error });
}
},
// 单文件直接下载
async onDownloadSingle() {
try {
const res = await this.getBinaryContent(0, 0, 0, false);
const buffers = new Blob([res.data.data]);
saveFile(this.fileName, buffers);
} catch (error) {
console.log({ error });
}
},
3.前端直接借助 streamsaver + fetch 实现大文件下载
思路:
利用streamsaver
创建一个可写流,然后利用fetch
流式读取的特性,拿到一点,然后写入磁盘,直到读取完成。
实现:
javascript
const handleDownLoad = (val: any) => {
const url = val.filePath;
const filename = `${val.filename}.las`; //文件名+格式
const fileStream = window.streamSaver.createWriteStream(filename); //可写流
// 获取数据流
fetch(url)
.then(async (res: any) => {
// 读取数据块
const writer = fileStream.getWriter();
const reader = res.body.getReader();
// 文件大小 注意转为数字
const contentLength = +res.headers.get("Content-Length");
let receivedLength = 0; // 已下载的字节数
// 在 body 下载时,一直为无限循环
while (true) {
// 当最后一块下载完成时,done 值为 true
// value 是块字节的 Uint8Array
const { done, value } = await reader.read();
if (done) break;
receivedLength += value.length; // 累加已下载的字节数
let progress = Math.round((receivedLength / contentLength) * 100);
console.log(`Progress: ${progress}%`, value.length);
await writer.write(value); //数据逐块写入文件
}
await writer.close(); // 关闭文件写入流 触发浏览器弹出文件保存对话框。
console.log("Download complete");
})
.catch(() => {});
};
各自的优缺点
- 后端异步下载:
优缺点:
-
耗费性能,相对粗暴,不适合并发量大的情况
-
前后端利用 流 + 分片下载的思路
优点:
- 能够做下载的暂停和继续和取消
- 能做下载的进度条
缺点:
-
相对复杂,需要前后端一起配合
-
这种方式也是前端截取流,然后拼接成 Blob,这个过程都是存在浏览器内存的,比较耗费内存
-
streamsaver + fetch 实现大文件下载
优点:
- 只需要前端即可实现,代码量小
- 可以做下载的取消
- 不占用浏览器内存
- 能做下载的进度条
缺点:
- 不能做下载的暂停和继续,因为流没办法做到