最近项目中遇到了下载,需要处理单文件下载和批量文件的下载,然后借此机会总结一下前端下载方案吧,顺便复习一下。
下载方式
- 原生标签下载
- GET请求直接下载
- 分片下载
- 压缩下载
- 流式下载
- 批量下载
原生标签下载
原生标签下载是最简单的下载方案了,可用的标签有a、iframe、window.open(window.open本质是get)方式等进行下载,这类下载方式较简单,但是存在弊端。
标签下载缺陷
- 标签的下载存在缺陷,连续/并发多个下载的时候会被浏览器拦截只下载最后一个文件(safari浏览器)。(ps:当然如果你项目不兼容safari,那这个方式还可以)
- 下载进度未知,不知道何时下载结束的。(ps:不能做下载进度条展示)
好处
- 下载会默认触发浏览器的下载弹窗,并展示进度,直接操作浏览器的控制项进行下载进度控制。
- 代码实现起来简单
- 下载顺序队列可以用这个(兼容浏览器交少时可用)
GET请求直接下载
这个比较简单,一般的下载都是服务提供oss的链接地址,然后我们直接通过get请求这个地址即可。这种适合小文件,大文件的话会等很久。
分片下载
分片下载核心:前端限制每个chunk的大小然后一块一块的请求直到请求完所有数据。请求的内容可以存到内存(大文件占浏览器内存,可能会卡)也可以直接流式下载写入到磁盘中(占用小部分内存)
服务返回的响应头中应包含内容
Accept-Ranges: bytes;服务器是否支持范围请求(range requests),也就是是否允许客户端只请求资源的一部分(如断点续传、分片下载)。Content-Length: [文件总大小];消息体(body)有多少字节。
前端操作
首先发送一个只请求响应头的请求。拿到上述两个内容中的**Content-Length,根据这个字段啊设置分片的个数,然后再进行请求数据,但是在后续分片请求中需要设置请求头内容Range:bytes={end}``** ,请求成功的响应头是这样的。
206表示进行的部分下载,在多个并发下载之后就拿到了所有的chunks洛。
需要注意一下,我们前端发送请求是可以一次性发送很多的请求的,然后浏览器下载机制会进行限制(下载队列),限制并发下载的个数,比如chrome限制一次性执行6个请求,然后剩余的发出去的请求就排队等待,但是代码在前端中是都执行的,都在await等待结果,这是浏览器自己的限制。
前端下载方案
咱们在进行分片的同时是可以进行流式读取到磁盘,也可以直接存在内存中(blob)。
流式下载
// 仅依赖浏览器原生 API
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB / 片
const POOL = 6; // 同域并发上限
async function shardedDownload(
url: string,
fileName: string
): Promise<void> {
// 1. 拿大小
const { size, acceptRanges } = await head(url);
if (!acceptRanges) throw new Error('server no range');
// 2. 创建可写流
const root = await navigator.storage.getDirectory();
const handle = await root.getFileHandle(fileName, { create: true });
const w = await handle.createWritable();
// 3. 构造任务队列
const tasks: Array<() => Promise<void>> = [];
for (let start = 0; start < size; start += CHUNK_SIZE) {
const end = Math.min(start + CHUNK_SIZE - 1, size - 1);
tasks.push(() => pumpChunk(url, start, end, w));
}
// 4. 并发池执行
await pool(tasks, POOL);
// 5. 落盘
await w.close();
console.log('✅ 下载完成');
}
/* ------------ 子逻辑 ------------ */
async function head(url: string) {
const r = await fetch(url, { method: 'HEAD' });
return {
size: Number(r.headers.get('content-length')),
acceptRanges: r.headers.get('accept-ranges') === 'bytes'
};
}
// 单 chunk 流式写入
async function pumpChunk(
url: string,
start: number,
end: number,
writable: FileSystemWritableFileStream
) {
const res = await fetch(url, {
headers: { Range: `bytes=${start}-${end}` }
});
if (!res.ok || res.status !== 206)
throw new Error(`chunk ${start}-${end} 失败 status=${res.status}`);
const reader = res.body!.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// value 是 Uint8Array,直接写
await writable.write({ type: 'write', position: start, data: value });
start += value.length;
}
}
// 并发池
async function pool(tasks: Array<() => Promise<void>>, concurrency: number) {
const exec = async () => {
while (tasks.length) {
const job = tasks.shift()!;
await job();
}
};
const workers = Array.from({ length: concurrency }, exec);
await Promise.all(workers);
}
上述代码是AI生成的,具体方案自己去看看吧。
这种方式对于大文件的下载优势还是很大的,结合了分片下载和流式下载的优点。推荐用这个方案进行下载。
Blob存储内容
对于这种方案,优点多余哈哈哈哈,但是还是说一下具体内容,就是直接将分片的内容存储到Blob中然后将所有Blob合并生成对应的url,然后通过js 调用a标签的下载进行下载。适合不大不小的文件(我也没招了)。太小的话直接用前面的方案就能做,太大的话直接下载到浏览器的内存,会卡!不建议用这种。
const CHUNK = 2 * 1024 * 1024; // 2MB
const POOL = 6; // 同域并发上限
async function shardBlob(url: string): Promise<Blob> {
const { size } = await head(url);
const tasks: Array<() => Promise<Blob>> = [];
for (let s = 0; s < size; s += CHUNK) {
const e = Math.min(s + CHUNK - 1, size - 1);
tasks.push(() => fetch(url, { headers: { Range: `bytes=${s}-${e}` } })
.then(r => r.blob()));
}
const blobs = await pool(tasks, POOL);
return new Blob(blobs);
}
/* 工具 */
async function head(url: string) {
const r = await fetch(url, { method: 'HEAD' });
return { size: Number(r.headers.get('content-length')) };
}
async function pool<T>(tasks: Array<() => Promise<T>>, c: number): Promise<T[]> {
const ret: T[] = [];
const exec = async () => {
while (tasks.length) ret.push(await tasks.shift()!());
};
await Promise.all(Array.from({ length: c }, exec));
return ret;
}
/* 使用 */
shardBlob('/big.iso').then(blob => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'big.iso';
a.click();
});
总结:对于上述两种,推荐第一种,第二种不怎么推荐
压缩下载
核心就是在前面的基础上将内容写入到压缩包中,一般这种都是后端来做的,后端直接提供zip的下载链接,不推荐前端做。核心做法就是结合前几种方式,然后将读取的内容写入到zip中。
流式下载
流式下载就是创建读出流和写入流,占用浏览器内存很小,推荐这种。一般不支持做下载进度条,但是用来做大文件的下载还是挺好用的。
const downloadWithStreamSaver = async (media, onComplete) => {
const fileKey = `${media.mediaId}_${media.fileName}`;
const url = media.xxx
if (!media.resultNosUrl || downloadingMap.value.has(fileKey)) {
console.log('下载资源不存在或文件正在下载中,忽略重复请求');
return;
}
downloadingMap.value.set(fileKey, true);
// 存储完成回调
if (onComplete) {
downloadCallbacks.value.set(fileKey, onComplete);
}
try {
// 获取文件信息
const headResponse = await fetch(url, { method: 'HEAD' });
// 创建可写流
const fileStream = streamSaver.createWriteStream(media.fileName);
const writer = fileStream.getWriter();
// 开始fetch请求
const response = await fetch(url);
if (!response.ok) {
throw new Error(`下载失败: ${response.statusText}`);
}
const reader = response.body.getReader();
let downloadedBytes = 0;
try {
// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
const { done, value } = await reader.read();
if (done) break;
// eslint-disable-next-line no-await-in-loop
await writer.write(value);
// 累计下载字节数
downloadedBytes += value.length;
}
// 完成下载
await writer.close();
// 清理状态
downloadingMap.value.delete(fileKey);
// 调用完成回调
const callback = downloadCallbacks.value.get(fileKey);
if (callback) {
callback({
success: true,
fileName: media.fileName,
fileSize: downloadedBytes,
media,
});
downloadCallbacks.value.delete(fileKey);
}
} catch (streamError) {
// 流写入错误
await writer.abort();
throw streamError;
}
} catch (error) {
// 清理状态
downloadingMap.value.delete(fileKey);
// 调用完成回调(失败)
const callback = downloadCallbacks.value.get(fileKey);
if (callback) {
callback({
success: false,
error: error.message,
fileName: media.fileName,
media,
});
downloadCallbacks.value.delete(fileKey);
}
}
};
上述代码仅供参考,实际是一坨。总的来说下载大文件不分片的情况下也推荐使用这种方式进行下载。
批量下载
其实没有批量下载这种说法,实际就是维护一个下载队列,然后并发设置在浏览器限制的并发下载个数内就行。你可以综合前面的下载方案,核心就是维护下载队列。这里没什么可提供给大家的了,请自由发挥。
总结
| # | 方案 | 适用场景 | 内存峰值 | 单文件上限 | 进度提示 | 兼容性 | 备注 |
|---|---|---|---|---|---|---|---|
| 1 | 原生标签 <a> / <iframe> / window.open | 小文件、单文件、无进度需求 | – | – | ❌ 无进度 | 全平台(含 IE) | 最简单;多文件串行触发易被拦截 |
| 2 | GET 直链跳转 | OSS / CDN 直链、小文件 | – | – | ❌ 无进度 | 全平台 | 大文件可先 HEAD 看大小再决定分片 |
| 3 | 分片 → Blob 合并 | 中文件 < 500 MB | 峰值 ≈ 文件大小 | ~2 GB(32-bit Blob) | ✅ 已下片数 | 全平台 | 内存占用高,不推荐大文件 |
| 4 | 分片 + 流式写入(File System Access API) | 大文件、移动端 | 常数内存 < 10 MB | 仅受磁盘剩余空间限制 | ✅ 已下字节/总字节 | Chrome 86+、Edge、Opera | 最优解;Safari 不支持时回退 Blob |
| 5 | 单流直写(StreamSaver 等) | 服务端不支持 Range 的大文件 | 常数内存 | 磁盘限制 | ✅ 已写字节 | 需 Safari polyfill | 不分片也能跑,带宽利用率低 |
| 6 | 压缩下载(ZIP) | 多文件一次交付 | 0(后端完成) | 后端决定 | ✅ 后端打包进度 | 推荐后端生成 | 前端 ZIP 组装仅 <100 MB 且无后端场景 |
| 7 | 批量队列 | 任意 N 个文件 | 取决于所选方案 | 取决于所选方案 | ✅ 自定义队列 UI | 同域并发 ≤6(HTTP/1.1) | 用方案 4 做单文件引擎,上层加队列池 |