为什么要学 electron?作为一个 web 前端工程师,一定不会主动去学习这个东西的,真的没必要,做 web 不香吗!嗯,当然,前面的论述是基于你在你的团队中有威望、独立自主(也就是团队大佬级别),否则,乖乖就范吧!像我,两年前,刚入职,web 都没有玩得明白,就被我导师安排去一个人做一个 electron 项目,极其无助和迷茫(有生之年如果我导师看到这篇文章可以微微忏悔一下,好吗!)。
Electron 是使用 JavaScript,HTML 和 CSS 构建跨平台的桌面应用程序。本文是基于 windows 系统讲述的。
如下图所示,Electron 由 chromium、nodejs、native api 构成。其中,nodejs 是一个基于 Chrome V8 引擎的 JavaScript 运行时,提供了不同系统平台的支持,chromium 是谷歌公司开源的浏览器引擎,同样的也提供了不同系统平台的支持。Electron 开发团队通过继承不同系统的 chromium 和 nodejs,提供一些桌面应用依赖的系统级别 api,实现了 Electron 的跨平台。
互联网时代,效率至上。随着互联网的发展,特别是前端技术的发展,前端开发效率、质量等方面取得了非常非常大的进步,同时前端拥有了世界上最大的最好的包管理工具 NPM,为前端开发工程师提供了数不胜数的开箱即用的优质的开发资源,这一切都是其他开发语言所不拥有的。
传统桌面客户端开发基本上是使用 C++和 C#,像 C++的 MFC 和 QT、C#的 winform 都是开发桌面 UI 客户端的利器。虽说是利器,但也会有弊端。众所周知,C++和 C#进入的门槛都很高,没个三五年都不能融会贯通,而且这些语言不单单能开发客户端,还能研究实现更加底层的东西如驱动,专注于 UI 开发的人才就更加少了。不像前端,门槛低,成效快。同时,传统的客户端技术开发 UI 界面的周期是很长的,像一个简单的 loading 动画,前端只需要一个 CSS3 动画就能实现,而使用 C++技术开发就要非常久了(除非使用 gif)。所以效率低的事务必将被效率高的事务所取代。
Electron 开发继承了 web 前端开发几乎全部的优点:高效、完善的生态、优美的界面...只要你对 Javascript 能无障碍使用,就可以在短时间内上手 Electron。Electron 还可以渲染线上资源,界面资源存于服务器可以实现高效便捷的更新,这对于一些 web 转客户端的需求是非常不错的选择。毫不夸张的讲:Electron 是一个真正面向互联网时代的框架,让客户端开发像 web 开发一样快速地迭代开发、快速地部署更新,香甜可口!
npm install --save-dev electron --arch=ia32|x64|arm64|armv7l 复制代码
因为安装 Electron 需要下载整个 electron 的资源压缩文件(70MB 左右),第一次安装速度会很慢,建议使用淘宝 npm 仓库镜像:--register=https://registry.npm.taobao.org
arch: ia32 | x64 | arm64 | armv7l
很明显如果项目是多人开发的话,这个--arch 在另外一个人的设备上就丢失了,除非又重新 install 一次 electron,这里可以将这个参数保存在 package.json 中。
"config": { "arch": "ia32" } 复制代码
配置入口、配置启动脚本
//package.json { "main": "index.js", "scripts": { "start": "electron ." } } 复制代码
Electron 运行时将 package.json 作为其入口文件。下面创建一个窗口:
const { BrowserWindow } = require('electron'); let win = new BrowserWindow({ width: 800, height: 600 }); win.loadURL('https://google.com'); 复制代码
具体的参数含义可以去官方文档查询,这里就不做过多的罗列了!
BrowserView 被用来让 BrowserWindow 嵌入更多的 web 内容。这里涉及到一个概念 webview。
BrowserView 就是 webview 的替代工具,能让 BrowserWindow 在主进程中动态引入 webview。
这两者最大的区别是 loadURL 可以加载线上的资源地址而 loadFile 不能,两者都能加载本地的文件地址。
windows 系统负责实现窗口透明状态的进程是 dwm.exe 进程,在 win7 非 aero 主体下,该进程处于不工作或停止状态,无法实现窗口透明。规避方式:systemPreferences.isAeroGlassEnabled()
Electron 在创建窗口的时候会返回窗口的实例对象,让开发在任何时候都可以去操作已经创建的窗口。然而窗口会因为各种原因被销毁关闭,如下表:
正常关闭 | 非正常关闭 |
---|---|
通过窗口 api |
任务栏图标右键“关闭窗口”按钮 |
任务管理器进程列表中关闭对应窗口进程 | |
窗口被一些安全软件关闭拦截 | |
窗口自身的崩溃 |
无论是正常或者非正常的关闭,如果程序在没有去同步销毁窗口对应的实例对象的情况再次调用窗口的功能属性或功能方法,程序就会抛出 Object has been destroyed 异常,嗯,这时候测试小姐姐就会送你一个严重属性的 bug!防止这个异常的出现有两种方式:
1、马后炮式:在调用窗口属性的时候先判断窗口是否被销毁
//Electron BrowserWindow 实例对象提供有isDestroyed的方法查询窗口是否被销毁 if(!window.isDestroyed()){ window.hide(); } //或 try { window.hide(); } catch(error => {}) 复制代码
除非你的应用只有一个窗口,用这种方式就没什么问题,但如果你的程序有很多窗口,这种方式就很笨重了,完全没有编程的愉悦感,甚至容易漏写。
2、预防式:监听窗口被销毁,同步将窗口实例对象销毁。BrowserWindow 窗口实例中有监听窗口关闭的事件回调,通过该回调可以进行窗口实例的销毁。下面根据 BrowserWindow 提供的方式实现的代码:
window.on('closed', () => { delete window; }) 复制代码
这种代码实现方式看起来就很别扭。
更好的实现方式是依据工厂模式和中介者模式设计一个窗口类,实现窗口的创建、控制窗口实例的访问、窗口实例的同步销毁等功能。
//WindowController class WindowController { constructor() { this.windowInstances = {} } //创建窗口,从配置中获取窗口配置 createWindow(wname) { let window = new BrowserWindow(config[wname]); //窗口实例保存 this.windowInstances[wname] = window; //监听窗口被销毁 window.on('closed', ((_wname) => { return () => { delete this.windowInstances[_wname]; } })(wname)); //监听窗口内容崩溃 window.webContents.on('crashed', ((_wname) => { return ()=> { this.windowInstances[_wname].close(); this.createWindow(_wname) } })(wname)) } //获取窗口实例 getWindow(wname) { return this.windowInstances[_wname]; } } 复制代码
创建窗口的时候可以通过设置窗口属性 alwaysOnTop:true 或通过调用窗口实例防范 setAlwaysOnTop 实现窗口的置顶。这种置顶方式实质上是设置窗口的 topMost 属性(详情),一般来说,这个置顶属性已经够用了,但是如果你想让自己的窗口置于所有窗口之上,那这个属性是完全不能支持的。
如 Windows10 系统中的任务管理器中设置的置于顶层,其他 topMost 窗口就不可能跑到它的上面。要实现完全置顶,要引入一个很多 C++大佬都不知道的属性:用户界面特权隔离。这个属性对于窗口的最大作用就是当窗口设置 topMost 时,窗口置于最顶层,在 windows10 下甚至比系统锁屏界面还置顶。但是在 win7 下就不是那回事了(几乎没用),win7 下只能循环置顶,而且效果不好。至于用户界面特权隔离请看:UIAccess
Electron 没有提供有相应的窗口置底的方法或 api,但是像一些桌面的常驻窗口实现就需要窗口置底。怎么去实现? 依靠 Electron 自身的窗口配置是无法完全实现的,只能做个简单的样子。 窗口置底有两个现象,一个是不可激活,所以不能调用窗口的 show 方法,应该使用 showInactive;
另外一个是窗口不能获取焦点,如果窗口能获取焦点将会跑到其他的窗口之上。
focusable: false 复制代码
有了这两个属性,窗口创建之后就会慢慢沉到底部(因为窗口创建之后不会自动跑到底部,只会在其他窗口激活或获取焦点之后位置替换)。那如何完全置顶?
需要借助第三方势力!系统的桌面也是一个窗口,它就是一个完全置底的窗口,只要把 Electron 中的窗口挂在桌面窗口之下成为它的子窗口,并且设置上面所述的两个属性方法,就实现了完全置底。Electron 中有一个设置父窗口的方法
/** * parent BrowserWindow | null * 设置 parent 为当前窗口的父窗口. 为null时表示将当前窗口转为顶级窗口 / win.setParentWindow(parent) 复制代码
然而桌面窗口不属于 BrowserWindow 的实例。所以需要借助 C++编写的动态链接库实现该功能。此处@C++ 大佬。
一般来说,窗口创建出来之后在任务栏会有窗口对应的窗口图标,该图标在不设置的情况下是默认提取当前窗口所在可执行程序的图标,也就是对应 exe 的图标。
当然我们可以自定义窗口图标
win.setIcon(iconPath) 复制代码
但是,仅仅这样设置会有问题
任务栏图标右键菜单上应用名显示不正确以及启用固定到任务栏功能时存留在任务栏的图标不正确(点击这个图标启动的窗口也不知正确)
这时需要使用窗口 api setAppDetails
win.setAppDetails(options) · options Object · appId String (可选) - 窗口的 App User Model ID. 该项必须设置, 否则其他选项将没有效果. · appIconPath String (可选) -窗口的 Relaunch Icon. · appIconIndex Integer (optional) - Index of the icon in appIconPath. Ignored when appIconPath is not set. Default is 0. · relaunchCommand String (可选) - 窗口的 重新启动命令. · relaunchDisplayName String (可选) - 窗口的重新启动显示名称. 复制代码
其中:
例子:
let exePath = app.getPath('exe'); window.setAppDetails({ appId: 'Juejin.qianduanshaokaotan.v20190118001', appIconPath: iconPath, appIconIndex: 0, relaunchDisplayName: '前端烧烤摊', relaunchCommand: '"' + exePath + '"', //加参数的意义是C盘program files文件夹有空格风险,导致路由错误 }); 复制代码
当程序调用 setAppDetails 之后,会在 C:\Users\xx\AppData\Roaming\Microsoft\Internet Explorer\Quick Launch\User Pinned\ImplicitAppShortcuts 地址下生成相应的包含快捷方式的文件夹,文件夹名是随 appId 变化而变的。
这个快捷方式是指向可执行程序地址,如桌面快捷方式一样,当可执行程序丢失或卸载时,相对应的跨界方式就会失效,需要手动删除,所以 setAppDetails 生成的快捷方式最好在程序卸载的时候删除,防止带来不好的体验!
有边框窗口对比无边框窗口多了一个工具栏和标题栏,并且标题栏让窗口拥有鼠标拖拽移动的能力。但是无边框窗口没有标题栏,所以其无法拖拽移动,需要代码实现。
第一种方式 ****将要模拟标题栏区域的 Dom 节点样式设置-webkit-app-region:drag,设置该属性之后,该节点内部节点(包好本身)将不响应点击事件,如果想要内部节点响应点击事件,则在内部节点样式设置-webkit-app-region: no-drag。
<div style="-webkit-app-region: drag;"> <span>标题栏</span> <button style=": no-drag;">按钮</button> </div> 复制代码
但是这种方式实现不了像 360 安全卫士在桌面上便捷功能球,无法做到可拖拽和可点击快速切换。
第二种方式 使用 html 的原生事件实现。这里推介使用的是 mousedomn + mousemove + mouseup 组合,HTML 拖放(Drag and Drop)当然也是可以,但是相对比来说没那么好控制!
index.html
<div id="header">我是标题栏</div> <script> let headerDom = document.getElementById('header'); let isMoving = false, isClick = false, startX = 0, startY = 0; headerDom.onmousedown = (e) => { isMoving = false; isClick = true; startX = e.clientX; startY = e.clientY; }; document.onmousemove = (e) => { let distanceX = e.clientX - startX; let distanceY = e.clientY - startY; if (isClick) { //窗口移动 //发送窗口位置到主进程进行处理 ipcRenderer.send('windowMove', {distanceX, distanceY}); isMoving = true; } }; headerDom.onmouseup = () => { isClick = false; if (!isMoving) { //单纯点击 //处理点击事件 } else { isMoving = false; } }; </script> 复制代码
ipcMain.js
ipcMain.on('windowMove', (event, position) => { let wx = Math.ceil(position.distanceX + window.getPosition()[0]); let wy = Math.ceil(position.distanceY + window.getPosition()[1]); if (wx > 0 && wy > 0) { window.setPosition(wx, wy); } }) 复制代码
Electron 中的 chromium 内核支撑了窗口的创建和内容的渲染,而 chromium 内核对网页的渲染运行有很多默认设置,比如处于网页内容调用摄像头需要用户授权、音视频自动播放。
Electron 官网上只提供了一些命令行,然而 chromium 提供了非常多的命令(看这里)。
那么怎么知道自己什么时候需要去找这些命令行参数来辅助开发呢?
只要是窗口页面的功能不符合浏览器的设置或标准,都可以尝试去找相对应的 chromium 命令开关来解决实际问题。
//触摸屏上会出现点击范围扩大的问题,这个可以解决 app.commandLine.appendSwitch('disable-touch-adjustment', true); //chrome 66以上版本屏蔽自动播放限制设置,音视频随心放 app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); //触摸屏上禁用双指缩放 app.commandLine.appendSwitch('disable-pinch', true); //关闭网络代理 app.commandLine.appendSwitch('no-proxy-server', true); //关闭浏览器的证书拦截 app.commandLine.appendSwitch('ignore-certificate-errors', true); 复制代码
由于 Electron 是一个专注于 UI 展示的框架,所以对于一些更加底层的功能就无法支持了。这时就要使用到 node-ffi。
node-ffi 是一个 nodejs 调用动态链接库的第三方 npm 模块。
那么啥是动态链接库呢?动态链接库是一种可执行代码的二进制文件,可以被操作系统载入内存执行,像 Windows 系统中的.dll 文件、Linux 系统中的.so 文件。
像上面说过的实现窗口置底的终极方式是让 C++来实现,nodejs 与 C++功能交互就可以通过动态链接库这种形式去实现。当然,最重要的媒介就是 node-ffi。
$ npm install ffi 复制代码
因为 node-ffi 的源码是一些 C 语言的文件,安装时需要 node-gyp 进行同步编译,所以安装 node-ffi 之前需要先正确安装 node-fyp。
node-gyp 安装教程看这里
npm install --save-dev electron-rebuild 复制代码
安装 electron-rebuild 的原因是通过 node-gyp 编译的 node-ffi 模块有可能和项目中使用的 Electron 中的 nodejs 在版本、arch 上存在差异,需要 electron-rebuild 对 node-ffi 进行重新编译方能使用。
ref、ref-array、ref-struct 这三个模块都是对 C++一些参数类型的包装,可以在 ffi 定义 dll 的导出函数是充当参数类型使用。
具体参数定义可以看官方 github
简单调用
#use-ffi.js ffi.Library('D://code//myProgram/demo.dll', { MyFunction: [ref.types.int, []] }) 复制代码
函数参数为回调函数
记住回调函数是一种指针类型,所以初始化导出函数参数的时候需要填入 “pointer” 类型
//定义导出函数 let dllFunc = ffi.Library('D://code//myProgram/demo.dll', { MyFunction: [ref.types.int, ['pointer']] }); //使用ffi.Callback初始化回调函数 const newCallback = (func) => { /** * ffi.Callback的第一参数是回调函数的返回类型,第二个是回调函数中的参数类型,第三个是回调函数 */ return ffi.Callback(ref.types.void, [], func) } // 保存回调函数,防止内存回收 let callbackStore = []; let callBack = newCallback(() => { console.log('函数回调')}); callbackStore.push(callBack); process.on('exit', () => { delete callbackStore }); //调用导出函数 dllFunc.MyFunction(callBack); 复制代码
指针作为参数传入
//定义导出函数,ref.refType将基础类型转成指针类型 let dllFunc = ffi.Library('D://code//myProgram/demo.dll', { MyFunction: [ref.types.int, [ref.refType(ref.types.int)]] }); //调用导出函数,使用Buffer.alloc开辟内存 let myParams = new Buffer.alloc(100); dllFunc.MyFunction(myParams); 复制代码
结构体作为参数传入
//ref-struct初始化结构体 const myStruct = Struct({ id: ref.types.int }); //定义导出函数,ref.refType将结构体类型转成指针结构体类型 let dllFunc = ffi.Library('D://code//myProgram/demo.dll', { MyFunction: [ref.types.int, [ref.refType(myStruct)]] }); //调用导出函数,通过.ref()获取结构体内存首地址,方便dll内函数复写 dll.MyFunction(myStruct.ref()); 复制代码
数组作为参数传入
//ref-array定义数组 const MyArray = RefArray(ref.types.int); //定义导出函数,不需要传入数组定义,只需要传入数组成员定义 let dllFunc = ffi.Library('D://code//myProgram/demo.dll', { MyFunction: [ref.types.int, [ref.refType(ref.types.int)]] }); //初始化数组 let myArray = new MyArray(10) for(let i = 0; i < 10; i++) { myArray[i] = 0; } //调用导出函数,传入第一个成员内存地址 dllFunc.MyFunction(myArray[0].ref()); 复制代码
小技巧:获取 Electron 窗口句柄并转成整数
//deref方法获取指针对应的值 let windowHandle = window.getNativeWindowHandle(); let _handle = Buffer.from(windowHandle); _handle.type = ref.types.int; dllFunc.MyFunction(handle.deref()) 复制代码
这里所说的 Electron 打包分为两部分,一部分是代码打包,另一部分是程序打包。
代码打包的意义在于:
这里使用 webpack 对代码打包。
webpack 对 Electron 项目提供了两种代码打包方式:target: 'electron-renderer' | 'electron-main',分别为渲染进程打包和主进程打包,都会自动忽略引入的 nodejs 模块和 electron 模块。
其中主进程代码打包没那么简单,像主进程中会使用的 nodejs 的原生模块如上面所说的 node-ffi、ref 模块,需要对这些模块被编译之后产生的.node 文件进行特殊处理。这里使用 node-loader 对.node 文件进行处理。
//webpack.config.js module.exports = { entry: './main.js', mode: 'production', target: 'electron-main', //防止打包之后__dirname改变 node: { __dirname: false, }, output: { path: path.resolve(__dirname, 'dist'), filename: 'main.js', }, module: { rules: [ { test: /\.node$/, use: [ { loader: 'node-loader', options: { name: '/[path][name].[ext]', }, }, ], }, ], }, } 复制代码
执行打包之后没有报错,看似成功了,但是运行打包代码后发现报错了,咋回事?
同时发现,dist 文件夹中没有导出.node 文件。
查找原因发现 ffi 中引入 ffi_bindings.node 不是常规的方式,bindings 模块中通过便利地址获取.node 文件。
//node_modules/ffi/lib/bindings.js module.exports = require('bindings')('ffi_bindings.node') //node_modules/bindings/bindings.js ... try: [ // node-gyp's linked version in the "build" dir [ 'module_root', 'build', 'bindings' ] // node-waf and gyp_addon (a.k.a node-gyp) , [ 'module_root', 'build', 'Debug', 'bindings' ] , [ 'module_root', 'build', 'Release', 'bindings' ] // Debug files, for development (legacy behavior, remove for node v0.9) , [ 'module_root', 'out', 'Debug', 'bindings' ] , [ 'module_root', 'Debug', 'bindings' ] // Release files, but manually compiled (legacy behavior, remove for node v0.9) , [ 'module_root', 'out', 'Release', 'bindings' ] , [ 'module_root', 'Release', 'bindings' ] // Legacy from node-waf, node <= 0.4.x , [ 'module_root', 'build', 'default', 'bindings' ] // Production "Release" buildtype binary (meh...) , [ 'module_root', 'compiled', 'version', 'platform', 'arch', 'bindings' ] ] 复制代码
因为 node-loader 模块只会去处理 require/import 形式的文件,像 ffi 这种引入文件的形式就无法处理了,所以就报错了。
解决方式是重写 bindings 模块,webpack 中有一个配置 resolve.alias 设置引用模块的别名,能够更改引用模块的应用地址。
//webpack.config.js ... resolve: { alias: { bindings: path.resolve(__dirname, 'replaceBindings.js') } } //replaceBindings.js modules.exports = function(filename){ if(filename === "ffi_bindings.node") { return require('ffi/build/Release/ffi_bindings.node') } } 复制代码
重新打包,程序运行无异常,问题得以解决!
Electron 程序打包是指将代码进一步打包成可分发、下载、安装、快速启动运行的程序。
比较常用的 Electron 程序打包模块是 electron-packager 和 electron-builder。这里推介使用 electron-builder,因为对比 electron-packager,electron-builder 功能更完善,文档也跟清晰一些。
electron-builder 打包流程:
使用 electron-builder api 形式进行打包实例:
const electronBuilder = require('electron-builder'); const child_process = require('child_process'); const path = require('path'); //获取git提交数作为版本号 child_process.execSync('rm -rf dist'); const version = child_process.execSync('git rev-list HEAD | wc -l').toString().trim(); const baseVersion = require('./package.json').version; const buildVersion = baseVersion + '.' + version; const semverVersion = baseVersion + '-' + version; electronBuilder.argv = 'x64'; const targetDist = path.resolve(__dirname, 'dist'); const baseOptions = { //应用唯一标识 appId: 'shaokaotan.demo.demo', buildVersion: buildVersion, //更替根目录下package.json中对应信息 extraMetadata: { //应用产品版本号,用于 productVerion: buildVersion, author: { name: 'Shaokaotan', email: 'www.Shaokaotan.com', url: 'www.Shaokaotan.com', }, version: semverVersion, }, productName: 'MyProgram', copyright: 'Copyright (C) 2020. Shaokaotan Electronics. All Rights Reserved.', directories: { buildResources: path.resolve(__dirname, '.'), output: targetDist, }, nsis: { oneClick: false, perMachine: true, allowElevation: false, allowToChangeInstallationDirectory: true, installerIcon: path.resolve(__dirname, './icons/favicon.ico'), uninstallerIcon: path.resolve(__dirname, './icons/favicon.ico'), installerHeaderIcon: path.resolve(__dirname, './icons/favicon.ico'), createDesktopShortcut: true, createStartMenuShortcut: true, shortcutName: 'MyProgram', artifactName: 'MyProgram' + buildVersion + '.${ext}', uninstallDisplayName: 'MyProgram', include: path.resolve(__dirname, 'install.nsh'), }, win: { icon: path.resolve(__dirname, './icons/favicon.ico'), target: { target: 'nsis', arch: 'x64', }, //需要打包的文件列表 files: ['!build.js'], extraResources: [path.resolve(__dirname, 'icons/favicon.ico')], publish: { provider: 'generic', channel: 'winLatest_' + buildVersion, url: 'www.Shaokaotan.com', }, //复制文件 extraFiles: [ { from: path.resolve(__dirname, 'icons/favicon.ico'), to: './resources/icons/favicon.ico', }, ], }, }; electronBuilder.createTargets(['--win'], null, 'x64'); process.env.BUILD_NUMBER = version; electronBuilder.build({ config: baseOptions, }); 复制代码
以上部分信息在实际应用中的对应
buildVersion和semverVersion对比
所以可以看到配置中 extraMetadata 的 version 字段使用的是 semverVersion 而不是 buildVersion。
应用安装之后在 windows 系统控制面板的程序与功能中也需要正确的显示(除非你们测试不 care)。
electron-builder 的配置中没有提供这些信息的配置项,经过一番追寻,这些信息来自于系统的注册表,所以需要在注册表的指定位置保存这些信息。注册标储存这些信息的地址是
计算机\HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall 复制代码
如何设置?这里涉及到 NSIS,上面配置中 nsis.include 可以加入自定义 nsis 文件:
!include "FileFunc.nsh" #获取版本号 !getdllversion "${BUILD_RESOURCES_DIR}\dist\win-unpacked\MyProgram.exe" expv_ !macro customInstall #写入icon地址 WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}" "DisplayIcon" "$INSTDIR\resources\icons\favicon.ico" #写入支持链接地址 WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}" "URLInfoAbout" "www.Shaokaotan.com" #写入版本信息 WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}" "DisplayVersion" "${expv_1}.${expv_2}.${expv_3}.${expv_4}" !macroend !macro customUnInstall !macroend 复制代码
本文只是把本人踩过的坑重点总结了一番,其实 electron 还有很多地方可以让我们去研究,如插件化、工程化、性能优化等等。后面前端烧烤摊也会继续分享 electron 相关的探索,敬请期待!
关注「前端烧烤摊」, 第一时间获取优质文章。