>> 博客原文连接javascript
上一篇文章《基于Electron的smb客户端开发记录》,大体描述了整个SMB客户端开发的核心功能、实现难点、项目打包这些内容,这篇文章呢单独把其中的文件分片上传模块
拿出来进行分享,说起一些与Electron主进程、渲染进程和文件上传优化相关的功能点。html
项目精简版 DEMO地址,删除了smb处理的多余逻辑,使用文件复制模拟上传流程,可直接运行体验。前端
demo运行时须要分别开启两个开发环境view -> service,而后才能预览界面,因为没有后端,文件默认上传(复制)到electron数据目录(在Ubuntu上是
~/.config/FileSliceUpload/runtime/upload
)
# 进入view目录 $: npm install $: npm start # 进入service目录 $: npm install $: npm start
Electron 运行 package.json 的 main 脚本的进程被称为主进程。在主进程中运行的脚本经过建立web页面来展现用户界面,一个 Electron 应用老是有且只有一个主进程。
主进程使用 BrowserWindow 实例建立页面,每一个 BrowserWindow 实例都在本身的渲染进程里运行页面,当一个 BrowserWindow 实例被销毁后,相应的渲染进程也会被终止。
主进程管理全部的web页面和它们对应的渲染进程,每一个渲染进程都是独立的,它只关心它所运行的 web 页面。 java
在普通的浏览器中,web页面一般在沙盒环境中运行,而且没法访问操做系统的原生资源。 然而 Electron 的用户在 Node.js 的 API 支持下能够在页面中和操做系统进行一些底层交互。
在页面中调用与 GUI 相关的原生 API 是不被容许的,由于在 web 页面里操做原生的 GUI 资源是很是危险的,并且容易形成资源泄露。 若是你想在 web 页面里使用 GUI 操做,其对应的渲染进程必须与主进程进行通信,请求主进程进行相关的 GUI 操做。node
1/2-自带方法,3-外部扩展方法
1.使用remote远程调用react
remote模块为渲染进程和主进程通讯提供了一种简单方法,使用remote模块, 你能够调用main进程对象的方法, 而没必要显式发送进程间消息。示例以下,代码经过remote远程调用主进程的BrowserWindows建立了一个渲染进程,并加载了一个网页地址:git
/* 渲染进程中(web端代码) */ const { BrowserWindow } = require('electron').remote let win = new BrowserWindow({ width: 800, height: 600 }) win.loadURL('https://github.com')
注意:remote底层是基于ipc的同步进程通讯(同步=阻塞页面),都知道Node.js的最大特性就是异步调用,非阻塞IO,所以remote调用不适用于主进程和渲染进程频繁通讯以及耗时请求的状况,不然会引发严重的程序性能问题。github
2.使用ipc信号通讯web
基于事件触发的ipc双向信号通讯,渲染进程中的ipcRenderer能够监听一个事件通道,也能向主进程或其它渲染进程直接发送消息(须要知道其它渲染进程的webContentsId),同理主进程中的ipcMain也能监听某个事件通道和向任意一个渲染进程发送消息。算法
/* 主进程 */ ipcMain.on(channel, listener) // 监听信道 - 异步触发 ipcMain.once(channel, listener) // 监听一次信道,监听器触发后即删除 - 异步触发 ipcMain.handle(channel, listener) // 为渲染进程的invoke函数设置对应信道的监听器 ipcMain.handleOnce(channel, listener) // 为渲染进程的invoke函数设置对应信道的监听器,触发后即删除监听 browserWindow.webContents.send(channel, args); // 显式地向某个渲染进程发送信息 - 异步触发 /* 渲染进程 */ ipcRenderer.on(channel, listener); // 监听信道 - 异步触发 ipcRenderer.once(channel, listener); // 监听一次信道,监听器触发后即删除 - 异步触发 ipcRenderer.sendSync(channel, args); // 向主进程一个信道发送信息 - 同步触发 ipcRenderer.invoke(channel, args); // 向主进程一个信道发送信息 - 返回Promise对象等待触发 ipcRenderer.sendTo(webContentsId, channel, ...args); // 向某个渲染进程发送消息 - 异步触发 ipcRenderer.sendToHost(channel, ...args) // 向host页面的webview发送消息 - 异步触发
3. 使用electron-re
进行多向通讯
electron-re 是以前开发的一个处理electron进程间通讯的工具,已经发布为npm组件。主要功能是在Electron已有的Main Process
主进程 和 Renderer Process
渲染进程概念的基础上独立出一个单独的service逻辑。service即不须要显示界面的后台进程,它不参与UI交互,单独为主进程或其它渲染进程提供服务,它的底层实现为一个容许node注入
和remote调用
的渲染窗口进程。
好比在你看过一些Electron最佳实践
中,耗费cpu的操做是不建议被放到主进程中处理的,这时候咱们就能够将这部分耗费cpu的操做编写成一个单独的js文件,而后使用service构造函数以这个js文件的地址path
为参数构造一个service实例,并经过electron-re
提供的MessageChannel
通讯工具在主进程、渲染进程、service进程之间任意发送消息,能够参考如下示例代码:
const { BrowserService, MessageChannel // must required in main.js even if you don't use it } = require('electron-re'); const isInDev = process.env.NODE_ENV === 'dev'; ... // after app is ready in main process app.whenReady().then(async () => { const myService = new BrowserService('app', 'path/to/app.service.js'); const myService2 = new BrowserService('app2', 'path/to/app2.service.js'); await myService.connected(); await myService2.connected(); // open devtools in dev mode for debugging if (isInDev) myService.openDevTools(); // send data to a service - like the build-in ipcMain.send MessageChannel.send('app', 'channel1', { value: 'test1' }); // send data to a service and return a Promise - extension method MessageChannel.invoke('app', 'channel2', { value: 'test1' }).then((response) => { console.log(response); }); // listen a channel, same as ipcMain.on MessageChannel.on('channel3', (event, response) => { console.log(response); }); // handle a channel signal, same as ipcMain.handle // you can return data directly or return a Promise instance MessageChannel.handle('channel4', (event, response) => { console.log(response); return { res: 'channel4-res' }; }); });
const { ipcRenderer } = require('electron'); const { MessageChannel } = require('electron-re'); // listen a channel, same as ipcRenderer.on MessageChannel.on('channel1', (event, result) => { console.log(result); }); // handle a channel signal, just like ipcMain.handle MessageChannel.handle('channel2', (event, result) => { console.log(result); return { response: 'channel2-response' } }); // send data to another service and return a promise , just like ipcRenderer.invoke MessageChannel.invoke('app2', 'channel3', { value: 'channel3' }).then((event, result) => { console.log(result); }); // send data to a service - like the build-in ipcRenderer.send MessageChannel.send('app', 'channel4', { value: 'channel4' });
// handle a channel signal, just like ipcMain.handle MessageChannel.handle('channel3', (event, result) => { console.log(result); return { response: 'channel3-response' } }); // listen a channel, same as ipcRenderer.once MessageChannel.once('channel4', (event, result) => { console.log(result); }); // send data to main process, just like ipcRenderer.send MessageChannel.send('main', 'channel3', { value: 'channel3' }); // send data to main process and return a Promise, just like ipcRenderer.invoke MessageChannel.invoke('main', 'channel4', { value: 'channel4' });
const { ipcRenderer } = require('electron'); const { MessageChannel } = require('electron-re'); // send data to a service MessageChannel.send('app', ....); MessageChannel.invoke('app2', ....); // send data to main process MessageChannel.send('main', ....); MessageChannel.invoke('main', ....);
文件上传主要逻辑控制部分是前端的JS脚本代码,位于主窗口所在的render渲染进程,负责用户获取系统目录文件、生成上传任务队列、动态展现上传任务列表详情、任务列表的增删查改等;主进程Electron端的Node.js代码主要负责响应render进程的控制命令进行文件上传任务队列数据的增删查改、上传任务在内存和磁盘的同步、文件系统的交互、系统原生组件调用等。
Input
组件获取到的FileList(HTML5 API,用于web端的简单文件操做)即为上传源;\\[host]\[sharename]\file1
上的file1在执行了unc链接后就能够经过Node.js FS API进行操做,跟操做本地文件彻底一致。整个必须依赖smb协议的上传流程即精简为将本地拿到的文件数据复制到能够在本地访问的另外一个smb共享路径这一流程,而这一切都得益于Windows UNC
命令。/* 使用unc命令链接远程smb共享 */ _uncCommandConnect_Windows_NT({ host, username, pwd }) { const { isThirdUser, nickname, isLocalUser } = global.ipcMainProcess.userModel.info; const commandUse = `net use \\\\${host}\\ipc$ "${pwd}" /user:"${username}"`; return new Promise((resolve) => { this.sudo.exec(commandUse).then((res) => { resolve({ code: 200, }); }).catch((err) => { resolve({ code: 600, result: global.lang.upload.unc_connection_failed }); }); }); }
下图描述了整个前端部分的控制逻辑:
<Input />
组件拿到FileList对象(Electron环境下拿到的File对象会额外附加一个path
属性指明文件位于系统的绝对路径)open
方法临时存储文件描述符和文件绝对路径的映射关系;read
方法根据文件读取位置、读取容量大小进行分片切割;close
关闭文件描述符;三个方法均经过文件绝对路径path
参数创建关联:
/** * readFileBlock [读取文件块] */ exports.readFileBlock = () => { const fdStore = {}; const smallFileMap = {}; return { /* 打开文件描述符 */ open: (path, size, minSize=1024*2) => { return new Promise((resolve) => { try { // 小文件不打开文件描述符,直接读取写入 if (size <= minSize) { smallFileMap[path] = true; return resolve({ code: 200, result: { fd: null } }); } // 打开文件描述符,建议绝对路径和fd的映射关系 fs.open(path, 'r', (err, fd) => { if (err) { console.trace(err); resolve({ code: 601, result: err.toString() }); } else { fdStore[path] = fd; resolve({ code: 200, result: { fd: fdStore[path] } }); } }); } catch (err) { console.trace(err); resolve({ code: 600, result: err.toString() }); } }) }, /* 读取文件块 */ read: (path, position, length) => { return new Promise((resolve, reject) => { const callback = (err, data) => { if (err) { resolve({ code: 600, result: err.toString() }); } else { resolve({ code: 200, result: data }); } }; try { // 小文件直接读取,大文件使用文件描述符和偏移量读取 if (smallFileMap[path]) { fs.readFile(path, (err, buffer) => { callback(err, buffer); }); } else { // 空文件处理 if (length === 0) return callback(null, ''); fs.read(fdStore[path], Buffer.alloc(length), 0, length, position, function(err, readByte, readResult){ callback(err, readResult); }); } } catch (err) { console.trace(err); resolve({ code: 600, result: err.toString() }); } }); }, /* 关闭文件描述符 */ close: (path) => { return new Promise((resolve) => { try { if (smallFileMap[path]) { delete smallFileMap[path]; resolve({ code: 200 }); } else { fs.close(fdStore[path], () => { resolve({code: 200}); delete fdStore[path]; }); } } catch (err) { console.trace(err); resolve({ code: 600, result: err.toString() }); } }); }, fdStore } }
优化是一件头大的事儿,由于你须要先经过不少测试手法找到现有代码的性能瓶颈,而后编写优化解决方案。我以为找到性能瓶颈这一点就特别难,由于是本身写的代码因此容易陷入一些先入为主的刻板思考模式。不过最最主要的一点仍是你若是本身都弄不清楚你使用的技术栈的话,那就无从谈起优化,因此前面有很大篇幅分析了Electron进程方面的知识以及梳理了整个上传流程。
在文件上传过程当中打开性能检测工具Performance
进行录制,分析整个流程:
在文件上传过程当中打开内存工具Memory
进行快照截取分析一个时刻的内存占用状况:
在编写完成文件上传模块后,初步进行了压力测试,结果发现添加1000个文件上传任务到任务队列,且同时上传的文件上传任务数量为6时,上下滑动查看文件上传列表时出现了卡顿的状况,这种卡顿不局限于某个界面组件的卡顿,并且当前窗口的全部操做都卡了起来,初步怀疑是Antd Table组件引发的卡顿,由于Antd Table组件是个很复杂的高阶组件,在处理大量的数据时可能会有性能问题,遂我将Antd Table组件换成了原生的table组件,且Table列表只显示每一个上传任务的任务名,其他的诸如上传进度这些都不予显示,从而想避开这个问题。使人吃惊的是测试结果是即便换用了原生Table组件,卡顿状况仍然毫无改善!
先看下chromium的架构图,每一个渲染进程都有一个全局对象RenderProcess,用来管理与父浏览器进程的通讯,同时维护着一份全局状态。浏览器进程为每一个渲染进程维护一个RenderProcessHost对象,用来管理浏览器状态和与渲染进程的通讯。浏览器进程和渲染进程使用Chromium的IPC系统进行通讯。在chromium中,页面渲染时,UI进程须要和main process不断的进行IPC同步,若此时main process忙,则UIprocess就会在IPC时阻塞。
综上所述:若是主进程持续进行消耗CPU时间的任务或阻塞同步IO的任务的话,主进程就会在必定程度上阻塞,从而影响主进程和各个渲染进程之间的IPC通讯,IPC通讯有延迟或是受阻,天然渲染界面的UI绘制和更新就会呈现卡顿的状态。
我分析了一下Node.js端的文件任务管理的代码逻辑,把一些操做诸如获取文件大小、获取文件类型和删除文件这类的同步阻塞IO调用都换成了Node.js提倡的异步调用模式,即FS callback或Fs Promise链式调用。改动后发现卡顿状况改善不明显,遂进行了第三次尝试。
此次是大改😕
1. 简单实现了node.js进程池
源码:ChildProcessPool.class.js,主要逻辑是使用Node.js的child_process
模块(具体使用请看文档) 建立指定数量的多个子进程,外部经过进程池获取一个可用的进程,在进程中执行须要的代码逻辑,而在进程池内部其实就是按照顺序依次将已经建立的多个子进程中的某一个返回给外部调用便可,从而避免了其中某个进程被过分使用,省略代码以下:
... class ChildProcessPool { constructor({ path, max=6, cwd, env }) { this.cwd = cwd || process.cwd(); this.env = env || process.env; this.inspectStartIndex = 5858; this.callbacks = {}; this.pidMap = new Map(); this.collaborationMap = new Map(); this.forked = []; this.forkedPath = path; this.forkIndex = 0; this.forkMaxIndex = max; } /* Received data from a child process */ dataRespond = (data, id) => { ... } /* Received data from all child processes */ dataRespondAll = (data, id) => { ... } /* Get a process instance from the pool */ getForkedFromPool(id="default") { let forked; if (!this.pidMap.get(id)) { // create new process if (this.forked.length < this.forkMaxIndex) { this.inspectStartIndex ++; forked = fork( this.forkedPath, this.env.NODE_ENV === "development" ? [`--inspect=${this.inspectStartIndex}`] : [], { cwd: this.cwd, env: { ...this.env, id }, } ); this.forked.push(forked); forked.on('message', (data) => { const id = data.id; delete data.id; delete data.action; this.onMessage({ data, id }); }); } else { this.forkIndex = this.forkIndex % this.forkMaxIndex; forked = this.forked[this.forkIndex]; } if(id !== 'default') this.pidMap.set(id, forked.pid); if(this.pidMap.values.length === 1000) console.warn('ChildProcessPool: The count of pidMap is over than 1000, suggest to use unique id!'); this.forkIndex += 1; } else { // use existing processes forked = this.forked.filter(f => f.pid === this.pidMap.get(id))[0]; if (!forked) throw new Error(`Get forked process from pool failed! the process pid: ${this.pidMap.get(id)}.`); } return forked; } /** * onMessage [Received data from a process] * @param {[Any]} data [response data] * @param {[String]} id [process tmp id] */ onMessage({ data, id }) {...} /* Send request to a process */ send(taskName, params, givenId="default") { if (givenId === 'default') throw new Error('ChildProcessPool: Prohibit the use of this id value: [default] !') const id = getRandomString(); const forked = this.getForkedFromPool(givenId); return new Promise(resolve => { this.callbacks[id] = resolve; forked.send({action: taskName, params, id }); }); } /* Send requests to all processes */ sendToAll(taskName, params) {...} }
send
和sendToAll
方法向子进程发送消息,前者是向某个进程发送,若是请求参数指定了id则代表须要明确使用以前与此id创建过映射的某个进程,并指望拿到此进程的回应结果;后者是向进程池中的全部进程发送信号,并指望拿到全部进程返回的结果(供调用者外部调用)。dataRespond
和dataRespondAll
方法对应上面的两个信号发送方法的进程返回数据回调函数,前者拿到进程池中指定的某个进程的回调结果,后者拿到进程池中全部进程的回调结果(进程池内部方法,调用者无需关注)。getForkedFromPool
方法是从进程池中拿到一个进程,若是进程池尚未一个子进程或是已经建立的子进程数量小于设置的可建立子进程数最大值,那么会优先新建立一个子进程放入进程池,而后返回这个子进程以供调用(进程池内部方法,调用者无需关注)。getForkedFromPool
方法中值得注意的是这行代码:this.env.NODE_ENV === "development" ? [`--inspect=${this.inspectStartIndex}`] : []
,使用Node.js运行js脚本时加上- -inspect=端口号
参数能够开启所运行进程的远程调试端口,多进程程序状态追踪每每比较困难,因此采起这种方式后可使用浏览器Devtools单独调试每一个进程(具体能够在浏览器输入地址:chrome://inspect/#devices
而后打开调试配置项,配置咱们这边指定的调试端口号,最后点击蓝字Open dedicated DevTools for Node
就能打开一个调试窗口,能够对代码进程断点调试、单步调试、步进步出、运行变量查看等操做,十分便利!)。2. 分离子进程通讯逻辑和业务逻辑
另外被做为子进程执行文件载入的js文件中可使用我封装的ProcessHost.class.js,我把它称为进程事务管理中心
,主要功能是使用api诸如 - ProcessHost.registry(taskName, func)
来注册多种任务
,而后在主进程中能够直接使用进程池获取某个进程后向某个任务
发送请求并取得Promise
对象以拿到进程回调返回的数据,从而避免在咱们的子进程执行文件中编写代码时过分关注进程之间数据的通讯。
若是不使用进程事务管理中心
的话咱们就须要使用process.send
来向一个进程发送消息并在另外一个进程中使用process.on('message', processor)
处理消息。须要注意的是若是注册的task
任务是异步的则须要返回一个Promise对象而不是直接return
数据,简略代码以下:
action
参数调用某个任务class ProcessHost { constructor() { this.tasks = { }; this.handleEvents(); process.on('message', this.handleMessage.bind(this)); } /* events listener */ handleEvents() {...} /* received message */ handleMessage({ action, params, id }) { if (this.tasks[action]) { this.tasks[action](params) .then(rsp => { process.send({ action, error: null, result: rsp || {}, id }); }) .catch(error => { process.send({ action, error, result: error || {}, id }); }); } else { process.send({ action, error: new Error(`ProcessHost: processor for action-[${action}] is not found!`), result: null, id, }); } } /* registry a task */ registry(taskName, processor) { if (this.tasks[taskName]) console.warn(`ProcesHost: the task-${taskName} is registered!`); if (typeof processor !== 'function') throw new Error('ProcessHost: the processor must be a function!'); this.tasks[taskName] = function(params) { return new Promise((resolve, reject) => { Promise.resolve(processor(params)) .then(rsp => { resolve(rsp); }) .catch(error => { reject(error); }); }) } return this; }; /* unregistry a task */ unregistry(taskName) {...}; /* disconnect */ disconnect() { process.disconnect(); } /* exit */ exit() { process.exit(); } } global.processHost = global.processHost || new ProcessHost(); module.exports = global.processHost;
3. ChildProcessPool和ProcessHost的配合使用
具体使用请查看上文完整demo
1)main.js (in main process)
主进程中引入进程池类,并建立进程池实例
path
参数为可执行文件路径max
指明进程池建立的最大子进程实例数量env
为传递给子进程的环境变量/* main.js */ ... const ChildProcessPool = require('path/to/ChildProcessPool.class'); global.ipcUploadProcess = new ChildProcessPool({ path: path.join(app.getAppPath(), 'app/services/child/upload.js'), max: 3, // process instance env: { lang: global.lang, NODE_ENV: nodeEnv } }); ...
2)service.js (in main processs) 例子:使用进程池来发送初始化分片上传
请求
/** * init [初始化上传] * @param {[String]} host [主机名] * @param {[String]} username [用户名] * @param {[Object]} file [文件描述对象] * @param {[String]} abspath [绝对路径] * @param {[String]} sharename [共享名] * @param {[String]} fragsize [分片大小] * @param {[String]} prefix [目标上传地址前缀] */ init({ username, host, file, abspath, sharename, fragsize, prefix = '' }) { const date = Date.now(); const uploadId = getStringMd5(date + file.name + file.type + file.size); let size = 0; return new Promise((resolve) => { this.getUploadPrepath .then((pre) => { /* 看这里看这里!look here! */ return global.ipcUploadProcess.send( /* 进程事务名 */ 'init-works', /* 携带的参数 */ { username, host, sharename, pre, prefix, size: file.size, name: file.name, abspath, fragsize, record: { host, // 主机 filename: path.join(prefix, file.name), // 文件名 size, // 文件大小 fragsize, // 分片大小 abspath, // 绝对路径 startime: getTime(new Date().getTime()), // 上传日期 endtime: '', // 上传日期 uploadId, // 任务id index: 0, total: Math.ceil(size / fragsize), status: 'uploading' // 上传状态 } }, /* 指定一个进程调用id */ uploadId ) }) .then((rsp) => { resolve({ code: rsp.error ? 600 : 200, result: rsp.result, }); }).catch(err => { resolve({ code: 600, result: err.toString() }); }); }); }
3)child.js (in child process) 使用事务管理中心处理消息 child.js
即为建立进程池时传入的path
参数所在的nodejs脚本代码,在此脚本中咱们注册多个任务来处理从进程池发送过来的消息。
这段代码逻辑被单独分离到子进程中处理,其中:
const fs = require('fs'); const fsPromise = fs.promises; const path = require('path'); const utils = require('./child.utils'); const { readFileBlock, uploadRecordStore, unlink } = utils; const ProcessHost = require('./libs/ProcessHost.class'); // read a file block from a path const fileBlock = readFileBlock(); // maintain a shards upload queue const uploadStore = uploadRecordStore(); global.lang = process.env.lang; /* *************** registry all tasks *************** */ ProcessHost .registry('init-works', (params) => { return initWorks(params); }) .registry('upload-works', (params) => { return uploadWorks(params); }) .registry('close', (params) => { return close(params); }) .registry('record-set', (params) => { uploadStore.set(params); return { result: null }; }) .registry('record-get', (params) => { return uploadStore.get(params); }) .registry('record-get-all', (params) => { return (uploadStore.getAll(params)); }) .registry('record-update', (params) => { uploadStore.update(params); return ({result: null}); }) .registry('record-remove', (params) => { uploadStore.remove(params); return { result: null }; }) .registry('record-reset', (params) => { uploadStore.reset(params); return { result: null }; }) .registry('unlink', (params) => { return unlink(params); }); /* *************** upload logic *************** */ /* 上传初始化工做 */ function initWorks({username, host, sharename, pre, prefix, name, abspath, size, fragsize, record }) { const remotePath = path.join(pre, prefix, name); return new Promise((resolve, reject) => { new Promise((reso) => fsPromise.unlink(remotePath).then(reso).catch(reso)) .then(() => { const dirs = utils.getFileDirs([path.join(prefix, name)]); return utils.mkdirs(pre, dirs); }) .then(() => fileBlock.open(abspath, size)) .then((rsp) => { if (rsp.code === 200) { const newRecord = { ...record, size, // 文件大小 remotePath, username, host, sharename, startime: utils.getTime(new Date().getTime()), // 上传日期 total: Math.ceil(size / fragsize), }; uploadStore.set(newRecord); return newRecord; } else { throw new Error(rsp.result); } }) .then(resolve) .catch(error => { reject(error.toString()); }); }) } ...
refreshTasks
,主要逻辑是遍历全部未经上传文件原始对象数组,而后选取固定某个数量的文件(数量取决于设置的同时上传任务个数)放入待上传文件列表中,我发现若是待上传文件列表的文件数量 = 设置的同时上传任务个数
的状况下就不用继续遍历剩下的文件原始对象数组了。就是少写了这个判断条件致使refreshTasks
这个频繁操做的函数在每次执行时可能多执行数千遍for循环内层判断逻辑(具体执行次数呈O(n)次增加,n为当前任务列表任务数量)。第一次把Electron技术应用到实际项目中,踩了挺多坑:render进程和主进程通讯的问题、跨平台兼容的问题、多平台打包的问题、窗口管理的问题... 总之得到了不少经验,也整理出了一些通用解决方法。 Electron如今应用的项目仍是挺多的,是前端同窗跨足桌面软件开发领域的又一里程碑,不过须要转换一下思惟模式,单纯写前端代码可能是处理一些简单的界面逻辑和少许的数据,涉及到文件、系统操做、进程线程、原生交互方面的知识比较少,能够多了解一下计算机操做系统方面的知识、掌握代码设计模式和一些基本的算法优化方面的知识能让你更加胜任Electron桌面软件开发任务!