如何实现前端分片下载与断点续传?

2 阅读3分钟

实现前端大文件的分片下载与断点续传,核心在于两大技术的结合:HTTP Range 请求客户端本地存储(如 IndexedDB)

  • HTTP Range 请求:允许前端只请求文件的某一部分(例如第 0 到 10MB 字节),而不是整个文件。服务器如果支持,会返回状态码 206 Partial Content
  • IndexedDB:浏览器内置的 NoSQL 数据库,可以将下载好的文件分片(Blob)持久化存储在本地。即使页面刷新或浏览器意外关闭,数据也不会丢失,从而实现真正的断点续传。

下面为你梳理完整的实现思路与核心代码逻辑:

⚙️ 核心实现步骤

  1. 获取文件总大小:通过发送 HEAD 请求,从响应头的 Content-Length 中获取文件总大小。
  2. 计算分片信息:设定好分片大小(如 2MB),计算出总分片数,并规划好每个分片的字节范围(start-end)。
  3. 检查本地缓存(断点续传的关键) :在开始下载前,先查询 IndexedDB,看哪些分片已经下载过了。已下载的直接跳过,只请求未下载的分片。
  4. 并发分片请求:将未下载的分片请求放入任务队列,控制一定的并发数(如 3 个),依次发送带有 Range 请求头的 fetch 请求。
  5. 存储与合并:每个分片下载成功后,立即存入 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();

💡 关键注意事项

  1. 后端必须支持 Range 请求:这是前提条件。你可以用 curl -I 你的文件地址 测试,如果响应头包含 Accept-Ranges: bytes,说明支持。目前主流的 Nginx、Apache 以及阿里云 OSS、腾讯云 COS 等云存储都原生支持。
  2. 并发控制:上面的示例为了简化逻辑使用了串行下载。在实际生产环境中,建议使用 p-limit 等库或手写并发控制器,限制同时进行的 fetch 请求数量(通常 3-5 个为宜),避免浏览器请求阻塞或内存溢出。
  3. 内存管理:千万不要把所有分片都先放在内存数组里最后再存数据库。大文件会导致浏览器直接崩溃。正确的做法是下载一片,存一片进 IndexedDB
  4. 暂停与取消:利用 AbortController 可以轻松实现下载任务的暂停和取消。在发起 fetch 时传入 { signal: abortController.signal },调用 abortController.abort() 即可中断请求。