背景
打开酷家乐客户端, 可以在左下角的更多菜单中找到下载管理这个功能, 今天我们就来看看在 Electron 中如何实现一个下载管理器.
如何触发下载行为
由于 Electron 渲染层是基于 chromium 的, 触发下载的逻辑和 chromium 是一致的, 页面中的 a 标签或者 JS 跳转等等行为都可能触发下载, 具体视访问的资源而定. 什么样的资源会触发浏览器的下载行为呢?
response header 中的 Content-Disposition 为 attachment. 参考 MDN Content-Disposition
response header 中的 Content-Type , 是浏览器无法直接打开的文件类型, 例如
application/octet-stream
, 此时取决于浏览器的具体实现了. 例子: IE 无法打开 PDF 文件, Chrome 可以直接打开 PDF 文件, 因此 PDF 类型的 url 在 Chrome 上可以直接打开, 而在 IE 下会触发下载行为.
在 Electron 中还有一种方法可以触发下载: webContents.download . 相当于直接调用 chromium 底层的下载逻辑, 忽略 headers 中的那些判断, 直接下载.
上述两种下载行为, 都会触发 session 的 事件, 在这里可以获取到关键的 downloadItem 对象
整体流程
设置文件路径
如果不做任何处理的话, 触发下载行为时 Electron 会弹出一个系统 dialog, 让用户来选择文件存放的目录. 这个体验并不好, 因此我们首先需要把这个系统 dialog 去掉. 使用 downloadItem.savePath 即可.
- // Set the save path, making Electron not to prompt a save dialog.
- downloadItem.setSavePath('/tmp/save.pdf');
为文件设置默认下载路径, 就需要考虑文件名重复的情况, 一般来说会使用文件名自增的逻辑, 例如: test.jpg,test.jpg(1) 这种格式. 文件默认存放目录, 也是一个问题, 我们统一使用 App.getPath('downloads') 作为文件下载目录. 为了用户体验, 后续提供修改文件下载目录功能即可.
- // in main.JS 主进程中
- const { session } = require('electron');
- session.defaultSession.on('will-download', async (event, item) => {
- const fileName = item.getFilename();
- const url = item.getURL();
- const startTime = item.getStartTime();
- const initialState = item.getState();
- const downloadPath = App.getPath('downloads');
- let fileNum = 0;
- let savePath = path.join(downloadPath, fileName);
- // savePath 基础信息
- const ext = path.extname(savePath);
- const name = path.basename(savePath, ext);
- const dir = path.dirname(savePath);
- // 文件名自增逻辑
- while (fs.pathExistsSync(savePath)) {
- fileNum += 1;
- savePath = path.format({
- dir,
- ext,
- name: `${name}(${fileNum})`,
- });
- }
- // 设置下载目录, 阻止系统 dialog 的出现
- item.setSavePath(savePath);
- // 通知渲染进程, 有一个新的下载任务
- win.webContents.send('new-download-item', {
- savePath,
- url,
- startTime,
- state: initialState,
- paused: item.isPaused(),
- totalBytes: item.getTotalBytes(),
- receivedBytes: item.getReceivedBytes(),
- });
- // 下载任务更新
- item.on('updated', (e, state) => { // eslint-disable-line
- win.webContents.send('download-item-updated', {
- startTime,
- state,
- totalBytes: item.getTotalBytes(),
- receivedBytes: item.getReceivedBytes(),
- paused: item.isPaused(),
- });
- });
- // 下载任务完成
- item.on('done', (e, state) => { // eslint-disable-line
- win.webContents.send('download-item-done', {
- startTime,
- state,
- });
- });
- });
现在触发下载行为, 文件就已经会下载到 Downloads 目录了, 文件名带有自增逻辑. 同时, 对下载窗口发送了关键事件, 下载窗口可以根据这些事件和数据, 创建, 更新下载任务 .
上述步骤在渲染进程使用 remote 实现会有问题, 无法获取到实时的下载数据. 因此建议在主进程实现.
下载记录
下载功能需要缓存下载历史在本地, 下载历史的数据比较多, 因此我们使用 https://github.com/louischatriot/nedb 作为本地数据库.
- // 初始化 nedb 数据库
- const db = nedbStore({ filename, autoload: true });
- ipcRenderer.on('new-download-item', (e, item) => {
- // 数据库新增一条新纪录
- db.insert(item);
- // UI 中新增一条下载任务
- this.addItem(item);
- })
- // 更新下载窗口的任务进度
- ipcRenderer.on('download-item-updated', (e, item) => {
- this.updateItem(item)
- })
- // 下载结束, 更新数据
- ipcRenderer.on('download-item-done', (e, item) => {
- // 更新数据库
- db.update(item);
- // 更新 UI 中下载任务状态
- this.updateItem(item);
- });
此时本地数据库中的数据, 是这样的:
- {
- "paused":false,"receivedBytes":0,"savePath":"/Users/ww/Downloads / 酷家乐装修网 - 保利金色佳苑 - 户型图. jpg","startTime":1560415098.731598,"state":"completed","totalBytes":236568,"url":"https://qhtbdoss.kujiale.com/fpimgnew/prod/3FO4EGX11S9S/op/LUBAVDQKN4BE6AABAAAAACY8.jpg?kpm=9V8.32a74ad82d44e7d0.3dba44f.1560415094020","_id":"6AorFZvpI0N8Yzw9"
- }
- {
- "paused":false,"receivedBytes":0,"savePath":"/Users/ww/Downloads/Kujiale-12.0.2-stable(1).dmg","startTime":1560415129.488072,"state":"progressing","totalBytes":80762523,"url":"https://qhstatiCSSl.kujiale.com/download/kjl-software12/Kujiale-12.0.2-stable.dmg?timestamp=1560415129351","_id":"YAeWIy2xoeWTw0Ht"
- }
- {
- "paused":false,"receivedBytes":0,"savePath":"/Users/ww/Downloads / 酷家乐装修网 - 保利金色佳苑 - 户型图 (1).jpg","startTime":1560418413.240669,"state":"progressing","totalBytes":236568,"url":"https://qhtbdoss.kujiale.com/fpimgnew/prod/3FO4EGX11S9S/op/LUBBLFYKN4BE6AABAAAAADY8.jpg?kpm=9V8.32a74ad82d44e7d0.3dba44f.1560418409875","_id":"obFLotKillhzTw09"
- }
- {
- "paused":false,"receivedBytes":0,"savePath":"/Users/ww/Downloads / 酷家乐装修网 - 保利金色佳苑 - 户型图 (1).jpg","startTime":1560418413.240669,"state":"completed","totalBytes":236568,"url":"https://qhtbdoss.kujiale.com/fpimgnew/prod/3FO4EGX11S9S/op/LUBBLFYKN4BE6AABAAAAADY8.jpg?kpm=9V8.32a74ad82d44e7d0.3dba44f.1560418409875","_id":"obFLotKillhzTw09"
- }
在渲染进程初始化的时候, 需要读取下载记录, 数据按下载时间倒序. 读取数量需要做一下限制, 否则会影响性能, 暂时限制 50 条.
- // 渲染进程中
- const db = nedbStore({ filename, autoload: true });
- // 读取历史数据
- const downloadHistory = await db.cfind({}).sort({
- startTime: -1,
- }).limit(50).exec()
- .catch(err => logger.error(err));
- if (downloadHistory) {
- this.setList(downloadHistory.map((d) => {
- const item = d;
- // 历史记录中, 只有需要未完成和完成两个状态
- if (item.state !== 'completed') {
- item.state = 'cancelled';
- }
- return item;
- }));
- }
自定义下载目录
默认下载目录在 Electron 默认为本机上的 Downloads 目录, 提供用户设置下载目录的功能, 就需要在本地缓存用户自定义的下载目录. 这种基础配置我们使用 https://github.com/sindresorhus/electron-store 来实现
- // in config.JSON
- {
- "downloadsPath": "/Users/ww/Downloads / 归档"
- }
在窗口初始化的时候, 检查缓存中是否有自定义下载目录, 如果有则更改 App 的默认下载目录
- componentDidMount() {
- const downloadsPath = store.get('downloadsPath');
- if (downloadsPath) {
- App.setPath('downloads', downloadsPath);
- // App.getPath('downloads'); -> /Users/ww/Downloads / 归档
- }
- }
用户点击更换下载目录, 此时需要以下步骤:
- dialog.showOpenDialog
- // 用户点击更改下载目录的回调
- changeDoiwnloadHandler = () => {
- const paths = dialog.showOpenDialog({
- title: '选择文件存放目录',
- properties: ['openDirectory'],
- });
- if (paths && paths.length) {
- // 先更新一下本地缓存
- store.set('downloadsPath', paths[0]);
- // 更新当前的下载目录
- App.setPath('downloads', paths[0]);
- // 更新下载目录文案
- this.updateDownloadsPath();
- }
- }
计算下载进度
拿到 downloadItem https://electronjs.org/docs/api/download-item 之后, 可以获取到已下载的字节数和文件的总字节数, 以此来计算下载进度.
const percent = item.getReceivedBytes() / item.getTotalBytes();
操作文件
在下载管理窗口中, 双击下载任务可以打开该文件, 点击查看按钮可以打开文件所在目录. 我们统一使用 Electron 的 https://electronjs.org/docs/api/shell 模块来实现.
- openFile = (path) => {
- if (!fs.pathExistsSync) return; // 文件不存在的情况
- shell.openItem(path); // 打开文件
- }
- openFileFolder = async (path) => {
- if (!fs.pathExistsSync(path)) { // 文件不存在
- return;
- }
- shell.showItemInFolder(path); // 打开文件所在文件夹
- }
获取文件关联图标
仔细观察下载管理窗口我们可以发现, 文件的图标都是从系统获取的, 和我们在文件管理器中看到的文件图标一致.
上图中 dmg,jpg 文件都展示了系统关联的文件图标, 用户体验很好. 我们可以使用 getFileIcon 来获取系统图标, 以下是具体实现代码.
- const { App } = require('electron').remote;
- // 封装一个函数
- const getFileIcon = (path) => {
- return new Promise((resolve) => {
- const defaultIcon = 'some-default.jpg';
- if (!path) return resolve(defaultIcon);
- return App.getFileIcon(path, (err, nativeImage) => {
- if (err) {
- return resolve(defaultIcon);
- }
- return resolve(nativeImage.toDataURL()); // 使用 base64 展示图标
- });
- });
- };
- // 获取图标
- const imgSrc = await getFileIcon('./test.jpg');
最后
欢迎大家在评论区讨论, 技术交流 & 内推 -> mailto:zhongli@qunhemail.com
来源: http://www.tuicool.com/articles/qYfuAjJ