做者:钟离,酷家乐PC客户端负责人
原文地址:https://webfe.kujiale.com/electron-ku-jia-le-ke-hu-duan-kai-fa-shi-jian-fen-xiang-jin-cheng-tong-xin/
酷家乐客户端:下载地址 https://www.kujiale.com/activity/136
文章背景:在酷家乐客户端在V12改版成功后,咱们积累了许多的宝贵的经验和最佳实践。前端社区里关于Electron知识相对较少,所以但愿将这些内容以系列文章的形式分享出来。
系列文章:css
打开酷家乐客户端,能够在左下角的更多菜单中找到下载管理这个功能,今天咱们就来看看在Electron中如何实现一个下载管理器。前端
因为Electron渲染层是基于chromium的,触发下载的逻辑和chromium是一致的,页面中的a标签或者js跳转等等行为均可能触发下载,具体视访问的资源而定。什么样的资源会触发浏览器的下载行为呢?git
Content-Disposition
为attachment。参考MDN Content-Disposition Content-Type
,是浏览器没法直接打开的文件类型,例如application/octet-stream
,此时取决于浏览器的具体实现了。例子: IE没法打开pdf文件,chrome能够直接打开pdf文件,所以pdf类型的url在chrome上能够直接打开,而在IE下会触发下载行为。在Electron中还有一种方法能够触发下载: webContents.download。至关于直接调用chromium底层的下载逻辑,忽略headers中的那些判断,直接下载。github
上述两种下载行为,都会触发session的will-download事件,在这里能够获取到关键的downloadItem对象web
若是不作任何处理的话,触发下载行为时Electron会弹出一个系统dialog,让用户来选择文件存放的目录。这个体验并很差,所以咱们首先须要把这个系统dialog去掉。使用downloadItem.savePath
便可。chrome
// 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')
做为文件下载目录。为了用户体验,后续提供修改文件下载目录功能便可。shell
// 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实现会有问题,没法获取到实时的下载数据。所以建议在主进程实现。
下载功能须要缓存下载历史在本地,下载历史的数据比较多,所以咱们使用nedb做为本地数据库。json
// 初始化 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); });
此时本地数据库中的数据,是这样的:api
{"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
目录,提供用户设置下载目录的功能,就须要在本地缓存用户自定义的下载目录。这种基础配置咱们使用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以后,能够获取到已下载的字节数和文件的总字节数,以此来计算下载进度。
const percent = item.getReceivedBytes() / item.getTotalBytes();
在下载管理窗口中,双击下载任务能够打开该文件,点击查看按钮能够打开文件所在目录。咱们统一使用Electron的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');
欢迎你们在评论区讨论,技术交流 & 内推 -> zhongli@qunhemail.com