一、先搞懂我们要解决的核心痛点
做Electron桌面应用的人,大概率都遇过一个闹心的事:应用里点下载按钮,要么浏览器弹出来(不是自己应用里下),要么下载被浏览器的跨域、安全规则拦死,甚至连下载进度都没法在应用里显示。比如你做个视频剪辑工具,要给用户下素材;或者做个企业内部工具,要下审批附件,要是连下载都搞不定,整个应用的体验就废了。
今天说的这个方案,核心就是绕开浏览器自带的下载逻辑,完全自己掌控从“要下载”到“文件存到用户电脑”的全流程,再也不用看浏览器的脸色。
二、整个下载链路的拆解(从触发到存文件)
整个流程其实可以拆成5步,每一步都有具体的操作,我用大白话讲清楚: 第一步:用户点下载按钮,我们先拦截这个请求,不让浏览器自己处理; 第二步:拿到要下载的文件地址,判断是不是合法的(比如有没有权限、地址对不对); 第三步:用Electron自己的网络能力去拿文件数据,不是用浏览器的; 第四步:把拿到的文件数据,一点一点写到用户的电脑磁盘里(就是流式写入,不用等整个文件下完再存); 第五步:存完之后,给用户弹个提示,或者打开文件所在的文件夹。
2.1 第一步:拦截浏览器的下载请求
浏览器自带的下载逻辑,是没法在Electron里灵活控制的,所以第一步必须把这个请求拦下来。Electron里有个专门的事件叫will-download,只要浏览器里有下载请求,这个事件就会触发,我们可以在这个事件里完全接管。
这里要注意,will-download事件是在主进程里监听的,因为Electron分主进程(管系统、网络这些底层能力)和渲染进程(就是显示页面的进程),主进程才能操作系统级的能力。
2.2 第二步:主进程接管下载请求
主进程拿到被拦截的请求后,就可以不用浏览器的网络栈了,改用Node.js的网络能力去下载。Node.js的网络能力比浏览器灵活太多,没有跨域限制,也没有浏览器的安全规则,完全自己说了算。
这里还要做几个判断:比如要下载的文件地址是不是HTTPS的(避免安全问题),有没有登录权限(比如用户没登录,就不让下),文件大小是不是超过限制(比如企业工具里限制单个文件最大100M)。
2.3 第三步:流式写入文件(重点!)
什么是流式写入?就是不用等整个文件全部下载完,每拿到一点数据,就写到磁盘里一点。比如一个1G的视频,要是等整个1G都下完再写,那得占很大的内存,电脑卡得要死;但流式写入的话,每下1M就写1M,内存里只留当前的1M数据,对电脑的压力小太多了。
Node.js里有个模块叫fs(文件系统),专门用来操作磁盘文件,里面的createWriteStream方法就是用来创建一个可写流,专门做流式写入的。
三、完整代码示例(带注释)
先说明技术栈:Electron 28.x(主进程用Node.js能力,渲染进程用普通HTML/JS)
首先是主进程的代码(一般叫main.js):
const { app, BrowserWindow, dialog } = require('electron');
const https = require('https'); // 用来发HTTPS请求
const http = require('http'); // 用来发HTTP请求
const fs = require('fs'); // 用来操作磁盘文件
const path = require('path'); // 用来处理文件路径
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: false, // 为了安全,关闭渲染进程的Node能力
contextIsolation: true, // 开启上下文隔离,安全措施
preload: path.join(__dirname, 'preload.js') // 预加载脚本,用来主进程和渲染进程通信
}
});
// 加载渲染进程的页面
mainWindow.loadFile('index.html');
// 监听浏览器的will-download事件,拦截所有下载请求
mainWindow.webContents.session.on('will-download', (event, item, webContents) => {
// 第一步:阻止浏览器自己处理下载
event.preventDefault();
// 第二步:拿到要下载的文件信息
const downloadUrl = item.getURL(); // 拿到下载地址
const fileName = item.getFilename(); // 拿到文件名(比如test.mp4)
const fileSize = item.getTotalBytes(); // 拿到文件总大小(单位:字节)
// 第三步:判断下载地址是不是合法的(比如只允许HTTPS)
if (!downloadUrl.startsWith('https://')) {
dialog.showMessageBox(mainWindow, {
type: 'error',
title: '下载失败',
message: '只允许下载HTTPS的文件'
});
return;
}
// 第四步:让用户选择保存文件的位置
dialog.showSaveDialog(mainWindow, {
title: '选择保存位置',
defaultPath: path.join(app.getPath('downloads'), fileName) // 默认存到系统下载文件夹
}).then((result) => {
// 如果用户取消了保存,就终止下载
if (result.canceled) {
return;
}
// 拿到用户选择的保存路径
const savePath = result.filePath;
// 第五步:用Node.js的网络能力去下载文件
// 判断是HTTPS还是HTTP请求
const protocol = downloadUrl.startsWith('https') ? https : http;
// 发请求
protocol.get(downloadUrl, (response) => {
// 判断请求是不是成功(状态码200-299才是成功)
if (response.statusCode < 200 || response.statusCode >= 300) {
dialog.showMessageBox(mainWindow, {
type: 'error',
title: '下载失败',
message: `请求失败,状态码:${response.statusCode}`
});
return;
}
// 第六步:创建可写流,用来把数据写到磁盘
const writeStream = fs.createWriteStream(savePath);
// 监听响应的data事件:每拿到一点数据,就写到磁盘
response.on('data', (chunk) => {
writeStream.write(chunk);
});
// 监听响应的end事件:整个文件下载完了
response.on('end', () => {
// 关闭可写流,完成写入
writeStream.end();
// 给用户弹提示
dialog.showMessageBox(mainWindow, {
type: 'info',
title: '下载完成',
message: `文件已保存到:${savePath}`
});
});
// 监听响应的error事件:下载出错了
response.on('error', (err) => {
dialog.showMessageBox(mainWindow, {
type: 'error',
title: '下载失败',
message: `下载出错:${err.message}`
});
// 出错的话,要把已经写的文件删掉
fs.unlink(savePath, () => {});
});
// 监听可写流的error事件:写入磁盘出错(比如磁盘满了)
writeStream.on('error', (err) => {
dialog.showMessageBox(mainWindow, {
type: 'error',
title: '保存失败',
message: `保存文件出错:${err.message}`
});
// 出错的话,把已经写的文件删掉
fs.unlink(savePath, () => {});
});
});
});
});
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
然后是预加载脚本preload.js(用来主进程和渲染进程通信,因为渲染进程不能直接操作主进程的能力):
const { contextBridge, ipcRenderer } = require('electron');
// 给渲染进程暴露一个方法,用来触发下载
contextBridge.exposeInMainWorld('electronAPI', {
triggerDownload: (url) => {
// 渲染进程发消息给主进程,让主进程发起下载
ipcRenderer.send('trigger-download', url);
}
});
最后是渲染进程的页面index.html(就是用户看到的页面):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Electron下载示例</title>
<style>
/* 简单的样式,让页面好看点 */
.download-btn {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
}
.download-btn:hover {
background-color: #45a049;
}
</style>
</head>
<body>
<h1>Electron下载测试</h1>
<!-- 下载按钮,点击的时候触发下载 -->
<button class="download-btn" onclick="startDownload()">下载测试文件</button>
<script>
function startDownload() {
// 要下载的文件地址,这里用一个公开的测试图片
const downloadUrl = 'https://picsum.photos/200/300';
// 调用预加载脚本暴露的方法,触发下载
window.electronAPI.triggerDownload(downloadUrl);
}
</script>
</body>
</html>
四、应用场景、优缺点、注意事项
4.1 应用场景
这个方案适合所有需要在Electron应用里控制下载逻辑的场景,比如:
- 企业内部工具:比如审批系统、文档管理工具,需要下载内部的文档、附件,避免浏览器弹出来;
- 内容创作工具:比如视频剪辑、图片处理工具,需要下载素材、模板,并且要显示下载进度;
- 游戏客户端:比如单机游戏的更新包、DLC下载,需要自己控制下载逻辑,显示进度;
- 隐私类应用:比如加密聊天工具,需要下载加密的文件,不让浏览器记录下载地址。
4.2 技术优缺点
优点:
- 完全脱离浏览器的限制:没有跨域问题,没有浏览器的安全规则拦截,没有浏览器弹出来的问题;
- 性能好:流式写入,不会占用太多内存,大文件下载也不会卡;
- 灵活控制:可以自己控制下载进度、保存路径、下载状态,甚至可以实现断点续传;
- 安全:可以自己判断下载地址的合法性,避免用户下载恶意文件。
缺点:
- 增加开发量:需要自己处理下载的所有逻辑,比如错误处理、进度显示、断点续传,比直接用浏览器的下载逻辑麻烦;
- 主进程和渲染进程通信:需要处理主进程和渲染进程的通信,避免安全问题;
- 网络问题:自己处理网络请求,需要自己处理超时、重试等问题。
4.3 注意事项
- 安全问题:一定要关闭渲染进程的Node能力,开启上下文隔离,避免渲染进程被攻击;
- 错误处理:一定要处理所有可能的错误,比如网络错误、磁盘满了、用户取消下载,避免出现半拉子文件;
- 进度显示:可以在响应的data事件里计算已经下载的大小,然后发给渲染进程显示进度;
- 断点续传:如果要实现断点续传,需要在请求头里加Range字段,并且要记录已经下载的文件大小;
- 文件名处理:有些下载地址的文件名是乱码的,需要自己处理编码,拿到正确的文件名。
五、文章总结
Electron里的下载逻辑,核心就是绕开浏览器的自带逻辑,用主进程的Node.js能力来接管,从拦截请求、发起请求到流式写入,全流程自己控制。这个方案虽然比浏览器自带的下载逻辑麻烦一点,但解决了跨域、被拦截、体验差的问题,适合对下载逻辑有要求的应用。
只要把每个步骤的逻辑理清楚,处理好错误和安全问题,就能实现一个稳定、好用的下载功能。
Comments