实现前端大文件的分片下载与断点续传,核心在于两大技术的结合:HTTP Range 请求与客户端本地存储(如 IndexedDB) 。
- HTTP Range 请求:允许前端只请求文件的某一部分(例如第 0 到 10MB 字节),而不是整个文件。服务器如果支持,会返回状态码
206 Partial Content。 - IndexedDB:浏览器内置的 NoSQL 数据库,可以将下载好的文件分片(Blob)持久化存储在本地。即使页面刷新或浏览器意外关闭,数据也不会丢失,从而实现真正的断点续传。
下面为你梳理完整的实现思路与核心代码逻辑:
⚙️ 核心实现步骤
- 获取文件总大小:通过发送
HEAD请求,从响应头的Content-Length中获取文件总大小。 - 计算分片信息:设定好分片大小(如 2MB),计算出总分片数,并规划好每个分片的字节范围(
start-end)。 - 检查本地缓存(断点续传的关键) :在开始下载前,先查询 IndexedDB,看哪些分片已经下载过了。已下载的直接跳过,只请求未下载的分片。
- 并发分片请求:将未下载的分片请求放入任务队列,控制一定的并发数(如 3 个),依次发送带有
Range请求头的fetch请求。 - 存储与合并:每个分片下载成功后,立即存入 IndexedDB。当所有分片都下载完毕后,从数据库中按顺序取出所有分片,利用
new Blob(chunks)合并,最后生成下载链接触发下载。
💻 核心代码逻辑示例
为了让你更直观地理解,这里提供一个简化的核心逻辑封装:
class ResumableDownloader {
constructor(url, fileName, chunkSize = 2 * 1024 * 1024) { // 默认分片大小 2MB
this.url = url;
this.fileName = fileName;
this.chunkSize = chunkSize;
this.dbName = `DownloadDB_${fileName}`;
this.storeName = 'chunks';
this.db = null;
}
// 1. 初始化 IndexedDB
async initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = (e) => {
this.db = e.target.result;
if (!this.db.objectStoreNames.contains(this.storeName)) {
this.db.createObjectStore(this.storeName, { keyPath: 'index' });
}
};
request.onsuccess = (e) => { this.db = e.target.result; resolve(this.db); };
request.onerror = (e) => reject(e);
});
}
// 2. 获取文件总大小
async getFileSize() {
const res = await fetch(this.url, { method: 'HEAD' });
return parseInt(res.headers.get('Content-Length'));
}
// 3. 下载单个分片
async downloadChunk(start, end, index) {
const res = await fetch(this.url, {
headers: { 'Range': `bytes=${start}-${end}` }
});
if (res.status === 206) {
const blob = await res.blob();
// 存入 IndexedDB
const tx = this.db.transaction(this.storeName, 'readwrite');
tx.objectStore(this.storeName).put({ index, blob });
return new Promise(resolve => tx.oncomplete = resolve);
}
throw new Error(`分片 ${index} 下载失败`);
}
// 4. 启动下载任务
async start() {
await this.initDB();
const fileSize = await this.getFileSize();
const totalChunks = Math.ceil(fileSize / this.chunkSize);
// 获取已下载的分片索引
const downloadedIndexes = await new Promise(resolve => {
const tx = this.db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const request = store.getAllKeys();
request.onsuccess = () => resolve(request.result);
});
// 生成待下载任务队列
const tasks = [];
for (let i = 0; i < totalChunks; i++) {
if (!downloadedIndexes.includes(i)) {
const start = i * this.chunkSize;
const end = Math.min(start + this.chunkSize, fileSize) - 1;
tasks.push(() => this.downloadChunk(start, end, i));
}
}
// 控制并发下载 (此处简化为串行,实际可用 Promise.all 或 p-limit 控制并发)
for (const task of tasks) {
await task();
console.log(`完成一个分片,当前进度: ${((totalChunks - tasks.length + tasks.indexOf(task) + 1) / totalChunks * 100).toFixed(2)}%`);
}
// 5. 所有分片下载完毕,合并文件
this.mergeChunks(totalChunks);
}
// 6. 合并分片并触发下载
async mergeChunks(totalChunks) {
const chunks = [];
for (let i = 0; i < totalChunks; i++) {
const blob = await new Promise(resolve => {
const tx = this.db.transaction(this.storeName, 'readonly');
const request = tx.objectStore(this.storeName).get(i);
request.onsuccess = () => resolve(request.result.blob);
});
chunks.push(blob);
}
const fileBlob = new Blob(chunks);
const link = document.createElement('a');
link.href = URL.createObjectURL(fileBlob);
link.download = this.fileName;
link.click();
// 下载完成后可选:清空 IndexedDB 释放空间
// indexedDB.deleteDatabase(this.dbName);
}
}
// 使用示例
// const downloader = new ResumableDownloader('你的文件URL', '大文件.zip');
// downloader.start();
💡 关键注意事项
- 后端必须支持 Range 请求:这是前提条件。你可以用
curl -I 你的文件地址测试,如果响应头包含Accept-Ranges: bytes,说明支持。目前主流的 Nginx、Apache 以及阿里云 OSS、腾讯云 COS 等云存储都原生支持。 - 并发控制:上面的示例为了简化逻辑使用了串行下载。在实际生产环境中,建议使用
p-limit等库或手写并发控制器,限制同时进行的fetch请求数量(通常 3-5 个为宜),避免浏览器请求阻塞或内存溢出。 - 内存管理:千万不要把所有分片都先放在内存数组里最后再存数据库。大文件会导致浏览器直接崩溃。正确的做法是下载一片,存一片进 IndexedDB。
- 暂停与取消:利用
AbortController可以轻松实现下载任务的暂停和取消。在发起fetch时传入{ signal: abortController.signal },调用abortController.abort()即可中断请求。