人人都会AI编程

13.5 断点续传、大文件下载实现

更新时间:2026-07-11

在一个桌面应用中,文件下载几乎是必备的基础功能。Electron 让这件事变得既简单又强大:你可以在主进程里利用 Node.js 的全套 I/O 能力,配合 HTTP 的 Range 请求头,轻松实现断点续传和大文件的稳定下载。与此同时,通过 IPC 将进度、速度、状态实时推送到渲染进程,前端就能展示一个友好的下载管理器界面。

13.5.1 整体方案设计

下载功能的核心逻辑放在 主进程 中,因为:

  • 只有主进程能够无限制地访问文件系统,把文件写入任意用户指定的目录。
  • 只有主进程能够保持 DownloadItem 会话(如果使用 Electron 原生的 will-download 事件)或者长时间运行后台任务,不受窗口关闭的影响。
  • 通过主进程集中管理下载任务,可以轻松实现队列、暂停、取消、断点续传等复杂操作。

渲染进程只负责展示和发起指令:用户点击“下载”按钮 → 渲染进程通过 IPC 向主进程发送 start-download 消息 → 主进程根据消息中的 URL、保存路径等参数开始下载,并在过程中持续向渲染进程发送 download-progress 更新。

13.5.2 断点续传的原理

断点续传的核心在于 HTTP/1.1 协议中定义的 Range 请求头。当服务器支持部分内容请求时(响应头中包含 Accept-Ranges: bytes),客户端就可以在请求中加上:

Range: bytes=已下载字节数-

服务器会返回 206 Partial Content 状态码,并从指定位置开始传输数据。我们只需要在本地记录已下载的文件大小,重连时从该大小处继续请求,并将新接收的数据追加写入到同一个文件中。

要实现这一点,主进程的逻辑分为三步:

  1. 检查本地临时文件:如果目标文件已经存在了一部分,读取其大小作为已下载量。
  2. 发送带 Range 头的请求:如果已下载量大于 0,带上 Range 请求头;否则正常从头下载。
  3. 流式追加写入:将服务器返回的数据流以追加模式写入文件,并同步更新已下载大小。

这样即便网络中断、应用崩溃,已下载的部分也不会丢失,下次启动下载时就能自动续传。

13.5.3 核心实现代码

下面展示一个简化但可直接运行的主进程下载模块。它使用 Node.js 内置的 http/https 模块和 fs 模块,不依赖任何第三方下载库。

// main-process/downloadManager.js
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const { EventEmitter } = require('events');

class DownloadManager extends EventEmitter {
  constructor() {
    super();
    this.activeDownloads = new Map(); // 管理当前下载任务
  }

  /**
   * 开始或恢复一个下载任务
   * @param {string} url 下载地址
   * @param {string} savePath 完整保存路径
   * @param {object} options 可选配置
   */
  startDownload(url, savePath, options = {}) {
    const taskId = `${url}@${savePath}`;
    if (this.activeDownloads.has(taskId)) {
      throw new Error('该下载任务已存在');
    }

    // 确保保存目录存在
    fs.mkdirSync(path.dirname(savePath), { recursive: true });

    const task = {
      url,
      savePath,
      tempPath: savePath + '.tmp',  // 临时下载文件
      downloaded: 0,                // 已下载字节
      totalSize: 0,                 // 总大小(可能未知)
      status: 'starting',
      abortController: new AbortController(),
    };

    // 如果临时文件已存在,获取已下载大小
    if (fs.existsSync(task.tempPath)) {
      task.downloaded = fs.statSync(task.tempPath).size;
    }

    this.activeDownloads.set(taskId, task);
    this._performDownload(taskId);
    return taskId;
  }

  // 执行下载请求
  async _performDownload(taskId) {
    const task = this.activeDownloads.get(taskId);
    if (!task) return;

    const { url, tempPath, downloaded, abortController } = task;
    const parsedUrl = new URL(url);
    const httpModule = parsedUrl.protocol === 'https:' ? https : http;

    const headers = {};
    if (downloaded > 0) {
      headers.Range = `bytes=${downloaded}-`;
    }

    const requestOptions = {
      method: 'GET',
      hostname: parsedUrl.hostname,
      port: parsedUrl.port,
      path: parsedUrl.pathname + parsedUrl.search,
      headers,
      signal: abortController.signal,
    };

    const req = httpModule.request(requestOptions, (res) => {
      // 处理重定向(简化版,仅处理一次)
      if (res.statusCode === 301 || res.statusCode === 302) {
        const redirectUrl = res.headers.location;
        task.url = redirectUrl; // 更新task中的url,实际应递归处理
        this.activeDownloads.set(taskId, task);
        this._performDownload(taskId);
        return;
      }

      // 检查服务器是否支持断点续传
      if (downloaded > 0 && res.statusCode !== 206) {
        // 服务器不支持续传,从头下载
        task.downloaded = 0;
        fs.unlinkSync(tempPath); // 删除旧文件
        this._performDownload(taskId); // 重新请求不带Range
        return;
      }

      if (res.statusCode !== 200 && res.statusCode !== 206) {
        task.status = 'error';
        this.emit('download-error', taskId, `服务器返回状态码: ${res.statusCode}`);
        return;
      }

      // 读取文件总大小
      const contentRange = res.headers['content-range'];
      if (contentRange) {
        // 格式: bytes 0-100/1001
        const total = parseInt(contentRange.split('/')[1], 10);
        if (!isNaN(total)) task.totalSize = total;
      } else if (res.headers['content-length']) {
        task.totalSize = parseInt(res.headers['content-length'], 10) + downloaded;
      }

      task.status = 'downloading';
      this.emit('download-start', taskId, {
        totalSize: task.totalSize,
        downloaded: task.downloaded,
      });

      // 创建写入流(追加模式)
      const writeStream = fs.createWriteStream(tempPath, { flags: 'a' });

      res.pipe(writeStream);

      res.on('data', (chunk) => {
        task.downloaded += chunk.length;
        this.emit('download-progress', taskId, {
          downloaded: task.downloaded,
          totalSize: task.totalSize,
          speed: this._getSpeed(task), // 可自行计算瞬时速度
        });
      });

      writeStream.on('finish', () => {
        // 下载完成,将临时文件重命名为正式文件名
        fs.renameSync(tempPath, task.savePath);
        task.status = 'completed';
        this.emit('download-complete', taskId);
        this.activeDownloads.delete(taskId);
      });

      writeStream.on('error', (err) => {
        task.status = 'error';
        this.emit('download-error', taskId, err.message);
      });
    });

    req.on('error', (err) => {
      if (err.name === 'AbortError') {
        task.status = 'paused'; // 由外部暂停触发
        this.emit('download-paused', taskId);
      } else {
        task.status = 'error';
        this.emit('download-error', taskId, err.message);
      }
    });

    req.end();
  }

  // 暂停下载
  pauseDownload(taskId) {
    const task = this.activeDownloads.get(taskId);
    if (task && task.abortController) {
      task.abortController.abort();
    }
  }

  // 取消下载并删除临时文件
  cancelDownload(taskId) {
    const task = this.activeDownloads.get(taskId);
    if (task) {
      if (task.abortController) task.abortController.abort();
      if (fs.existsSync(task.tempPath)) fs.unlinkSync(task.tempPath);
      this.activeDownloads.delete(taskId);
      this.emit('download-canceled', taskId);
    }
  }
}

13.5.4 主进程与渲染进程通信

在 Electron 的主进程入口文件中,实例化 DownloadManager,并注册 IPC 处理函数,将下载事件桥接到渲染进程。

// main.js (主进程)
const { ipcMain } = require('electron');
const DownloadManager = require('./downloadManager');
const downloadManager = new DownloadManager();

// 渲染进程发起下载
ipcMain.handle('download:start', async (event, { url, savePath }) => {
  try {
    const taskId = downloadManager.startDownload(url, savePath);
    return { success: true, taskId };
  } catch (err) {
    return { success: false, error: err.message };
  }
});

ipcMain.on('download:pause', (event, taskId) => {
  downloadManager.pauseDownload(taskId);
});

ipcMain.on('download:cancel', (event, taskId) => {
  downloadManager.cancelDownload(taskId);
});

// 将下载事件转发给所有渲染进程
downloadManager.on('download-progress', (taskId, info) => {
  BrowserWindow.getAllWindows().forEach(win => {
    win.webContents.send('download:progress', taskId, info);
  });
});
downloadManager.on('download-complete', (taskId) => {
  BrowserWindow.getAllWindows().forEach(win => {
    win.webContents.send('download:complete', taskId);
  });
});
// 其他事件类似处理

渲染进程(前端)的使用方式:

// renderer.js (渲染进程)
const { ipcRenderer } = window.require('electron');

// 开始下载
async function startDownload(url, savePath) {
  const result = await ipcRenderer.invoke('download:start', { url, savePath });
  if (result.success) {
    console.log('下载任务已创建:', result.taskId);
  }
}

// 监听进度更新
ipcRenderer.on('download:progress', (event, taskId, { downloaded, totalSize, speed }) => {
  const percent = totalSize > 0 ? Math.round((downloaded / totalSize) * 100) : 0;
  console.log(`任务 ${taskId}: ${percent}% (${downloaded}/${totalSize})`);
  // 更新前端进度条
});

13.5.5 大文件下载的稳定性技巧

在实现大文件(数 GB 级别)下载时,有几个工程经验值得注意:

  • 使用流(Stream)处理数据:千万不要把整个文件读入内存再写入磁盘,那样会直接撑爆内存。上面的代码中使用了 res.pipe(writeStream),数据会以块的形式边接收边写入,内存占用极小。
  • 临时文件机制:下载过程中写入 .tmp 后缀的临时文件,完成后才重命名为正式文件。这样可以防止下载中断时留下损坏文件,也便于恢复下载时判断已下载量。
  • 错误重试:网络抖动很常见,可以在 req.on('error') 中添加重试逻辑(比如最多重试 3 次,间隔 2 秒)。注意使用指数退避避免频繁重连。
  • 限速与并发控制:如果应用需要同时下载多个大文件,可以通过 stream.pause() / stream.resume() 或自定义 Transform 流来控制下载速度,避免占满用户带宽。
  • 校验完整性:如果服务器提供文件的 MD5/SHA256 值,下载完成后应当对文件进行校验,确保数据无误。这可以在 finish 事件中用 Node.js 的 crypto 模块计算哈希对比。
  • 内存与 CPU 管理:默认的 Node.js 流在高带宽下载时可能产生大量 data 事件,导致微任务队列压力过大。可以通过调整 highWaterMark 或使用 stream.pipeline 优化。在大规模应用中,也可以考虑使用 C++ 插件或 Electron 的 DownloadItem API(下文介绍)来处理下载本身,但在我们自己实现的方案中注意这些细节就足够了。

13.5.6 使用 Electron 原生的下载功能

除了自己实现下载管理器,Electron 也内置了会话模块的下载功能。通过监听 sessionwill-download 事件,可以获取一个 DownloadItem 对象,它封装了暂停、恢复、取消、进度追踪等能力,并且支持断点续传。

// 主进程
const { session } = require('electron');

session.defaultSession.on('will-download', (event, item, webContents) => {
  // 设置保存路径
  item.setSavePath('/path/to/save/file.zip');

  item.on('updated', (event, state) => {
    if (state === 'progressing') {
      const progress = item.getReceivedBytes() / item.getTotalBytes();
      console.log(`下载进度: ${Math.round(progress * 100)}%`);
      // 可以通过 webContents.send 推送到渲染进程
    } else if (state === 'completed') {
      console.log('下载完成');
    }
  });

  // 允许暂停恢复
  item.on('done', (event, state) => {
    if (state === 'completed') {
      // 完成
    } else if (state === 'cancelled') {
      // 取消
    } else if (state === 'interrupted') {
      // 中断,可以稍后调用 item.resume() 恢复
    }
  });
});

这种方式更简单,但缺点是灵活性不如自己实现:你无法在中间加入自定义的数据处理(如解密、解压),也无法完全控制重试策略。对于大多数常规下载需求,已经足够。

13.5.7 总结

在 Electron 中实现稳健的断点续传、大文件下载,核心是善用 Node.js 的流处理能力和 HTTP Range 请求。我们将下载逻辑封装在主进程的专用模块里,通过事件机制向前端推送状态,既保证了功能的可靠性,又让界面层保持简洁。根据项目需要,可以选择自行实现精细控制,或者直接使用 Electron 内置的下载 API 快速搭建。无论哪种方式,把下载与 UI 解耦、采用临时文件和追加写入、处理网络异常等设计原则,都是保障用户体验的关键。