使用 ReadableStream 控制 fetch API 响应以实现下载进度条
起因
一般前端业务在用户点击下载远程服务器文件,只需要浏览器新开一个 url 下载即可。今天在业务实现中遇到一个比较棘手的问题,由于数据保密的需求,后台OSS服务器需要实现私有读写,这就意味着前端获取远程文件时需要通过秘钥访问SDK,然后回调一个临时下载链接,这又造成了下载遇到中文文件名会乱码的问题。
初步解决方案
- 将下载的链接通过 fetch API 获取并转为 blob
- 使用第三方库 file-saver 做文件转存,这样可以保留原有的中文名
进阶需求
现在有个新的问题,如果用户下载的文件很大,浏览器没有任何进度提示(实测>10MB 后,就需要等待漫长的时间完成 fetch API 的 blob() 方法转换),从用户体验的角度出发我们必须实时获得转换的进度,给用户一个进度条提示,这样才会更加 ui friendly。
ReadableStream
- MDN 的解释
ReadableStream() 构造函数创建并从给定的处理程序返回一个可读的流对象。
-
咋一看让人一头雾水,我们引入业务代码进一步讨论:
-
原有的代码逻辑:
const downloadFileToBlob = async (url: string) => {
const res = await fetch(url);
if (res.status !== 200) return;
const blob = await res.blob();
return blob;
};
const download = async (key: string, fileName?: string) => {
// 从存储桶中获得临时秘钥 并生成临时下载链接
const url = await getDecodeUrl(key, fileName);
// 生成 blob
const blob = await downloadFileToBlob(url);
if (!blob) {
return 404; // 让上层函数返回 404 页面
}
saveAs(blob, fileName); // file-saver 另存为
};
可以看到在 downloadFileToBlob
方法之中使用 fetch API 获得这个文件的二进制流,然后返回,但这个方法是一个黑盒,我们无从得知转换的过程是怎么样的,自然没法读取转换的进度了。
但是, fetch API 返回的 res.body 是一个 ReadableStream<Uint8Array>
示例,而且有一个 getReader() 方法获取这个文件可读取流,那么我们再回到 ReadableStream() 构造函数上。
- 实例
start()
方法,继续附 MDN 解释
start (controller) 可选
这是一个当对象被构造时立刻调用的方法。此方法的内容由开发人员定义,并应着眼于访问流,并执行其他任何必需的设置流功能。如果这个过程是异步完成的,它可以返回一个 promise,表明成功或失败。传递给这个方法的 controller 是一个 ReadableStreamDefaultController 或 ReadableByteStreamController (en-US),具体取决于 type 属性的值。开发人员可以使用此方法在设立期间控制流。
说人话就是,你可以把上面的 res.body 可读取流放进去,并手动控制这个进程了,是不是很意外地发现了解决办法?
我们改造一下 downloadFileToBlob
代码:
type DownloadProgressCb = (current: number, total: number, done?: boolean) => void;
const downloadFileToBlob = async (url: string, cb?: DownloadProgressCb) => {
const res = await fetch(url);
if (res.status !== 200 || !res.body) {
return false;
}
// 从 Content-Length 中得到文件的大小
const size = Number(res.headers.get('Content-Length'));
// 已读取的大小
let receivedSize = 0;
// 获得 ReadableSteam
const reader = res.body.getReader();
// 手动控制读取流以便回传下载进度
const stream = new ReadableStream({
start(controller) {
// 声明一个 pumpRead 方法读取片段
// 因为reader.read() 方法是 Promise 异步的,所以这里是在进行链式调用
const pumpRead = (): any => reader.read().then(({ done, value }) => {
// done - 当 stream 传完所有数据时则变成 true
// value - 数据片段 - done 为 true 时为 undefined
if (done) {
// 结束读取 关闭读取流
controller.close();
cb?.(receivedSize, size, true);
return;
}
// 累加进度
receivedSize += value?.length || 0;
// 回调进度
cb?.(receivedSize, size);
// 每次推入读取队列 并链式执行
controller.enqueue(value);
return pumpRead();
});
// 开始读取
return pumpRead();
},
});
// 最后我们通过 Response 函数接收这个文件流 并转为 blob
const blob = await new Response(stream).blob();
return blob;
};
效果
配合回调实时回传的进度,我们就可以实现一个下载进度效果了: