前端ElectronElectron基础篇
Huang_ChunElectron官网地址
Electron简介
Electron是一个使用HTML、css和JavaScript构建跨平台桌面应用
程序的框架
前端技术
Electron嵌入了Chromium和Node.js,使web开发人员能够创建桌面应用程序。
跨平台
Electron应用程序与macOS,Windows和Linux兼容,可在所有支持的架构的三个平台上运行。
开源
Electron是一个开源项目,由OpenJS基金会和一个活跃的贡献者社区维护。
开发环境
- Node.js
- nvm
- 查看本机已安装版本列表: nvm list
- 查看当前可用版本列表: nvm list available
- 安装指定版本: nvm install <版本号>
- 切换指定版本: nvm use <版本>
- 卸载指定版本: nvm uninstall <版本号>
更多详情:point_right:
构建Electron工程
方法一:Quick Start
官网推荐
- 将项目克隆到本地
- 安装依赖:
npm i
- 启动程序:
npm run start
方法二:Electron Forge
npm方式——脚手架
npm init electron-app <项目名>
npx create-electron-app <项目名>
npm run start
方法三:手动创建
- 初始化项目:
- 安装依赖:
npm install electron --save-dev
- yarn add electron –dev
- 创建页面:index.html
- 程序入口:main.js
- 启动应用:
npm run start
main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| const { app, BrowserWindow } = require('electron') const path = require('node:path')
function createWindow () { const mainWindow = new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, 'preload.js') } })
mainWindow.loadFile('index.html')
}
app.whenReady().then(() => { createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow() }) })
app.on('window-all-closed', function () { if (process.platform !== 'darwin') app.quit() })
|
解决警告问题
1
| <meta http-equiv="Content-Security-width, initial-scale=1.0">
|
进程模型
Electron 继承了来自 Chromium 的多进程架构,这使得此框架在架构上非常相似于一个现代的网页浏览器。
为什么不是一个单一的进程?
网页浏览器是个极其复杂的应用程序。 除了显示网页内容的主要能力之外,他们还有许多次要的职责,例如:管理众多窗口 ( 或 标签页 ) 和加载第三方扩展。
在早期,浏览器通常使用单个进程来处理所有这些功能。 虽然这种模式意味着您打开每个标签页的开销较少,但也同时意味着一个网站的崩溃或无响应会影响到整个浏览器。
多进程模型
为了解决这个问题,Chrome 团队决定让每个标签页在自己的进程中渲染, 从而限制了一个网页上的有误或恶意代码可能导致的对整个应用程序造成的伤害。 然后用单个浏览器进程控制这些标签页进程,以及整个应用程序的生命周期。 下方来自 Chrome 漫画 的图表可视化了此模型:
Electron 应用程序的结构非常相似。 作为应用开发者,你将控制两种类型的进程:主进程 和 渲染器进程。 这类似于上文所述的 Chrome 的浏览器和渲染器进程。
主进程 Main Process
- 特点:程序入口、唯一
- 职责:负责控制、协调、Node.js API
- 事件:创建窗口、设置菜单、注册快捷键等系统级
- 销毁:应用结束
渲染进程 Renderer Process
- 特点:与BrowserWindow对应、彼此隔离
- 渲染页面、无权访问Node.js API
- 事件:窗口的显示、隐藏等
- 销毁:窗口销毁
预加载脚本
预加载(preload)脚本包含了那些执行于渲染器进程中,且先于网页内容开始加载的代码 。 这些脚本虽运行于渲染器的环境中,却因能访问 Node.js API 而拥有了更多的权限。
预加载脚本可以在 BrowserWindow
构造方法中的 webPreferences
选项里被附加到主进程。
main.js
1 2 3 4 5 6 7 8 9 10 11
| const { BrowserWindow } = require('electron')
const mainWindow = new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, 'preload.js') } })
|
因为预加载脚本与浏览器共享同一个全局 Window
接口,并且可以访问 Node.js API,所以它通过在全局 window
中暴露任意 API 来增强渲染器,以便你的网页内容使用。
虽然预加载脚本与其所附着的渲染器在共享着一个全局 window
对象,但您并不能从中直接附加任何变动到 window
之上,因为 contextIsolation
是默认的。
preload.js
1 2 3
| window.myAPI = { desktop: true }
|
上下文隔离
上下文隔离功能将确保您的 预加载
脚本 和 Electron的内部逻辑 运行在所加载的 webcontent
网页 之外的另一个独立的上下文环境里。 这对安全性很重要,因为它有助于阻止网站访问 Electron 的内部组件 和 您的预加载脚本可访问的高等级权限的API 。
这意味着,实际上,您的预加载脚本访问的 window
对象并不是网站所能访问的对象。 例如,如果您在预加载脚本中设置 window.hello = 'wave'
并且启用了上下文隔离,当网站尝试访问window.hello
对象时将返回 undefined。
上下文隔离默认开启。关闭上下文隔离,但是存在安全风险
1 2 3 4 5 6 7 8
| const mainWindow = new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: false, } })
|
:star:Electron 提供一种专门的模块来无阻地帮助您完成这项工作。 contextBridge
模块可以用来安全地从独立运行、上下文隔离的预加载脚本中暴露 API 给正在运行的渲染进程。 API 还可以像以前一样,从 window.myAPI
网站上访问。
preload.js
1 2 3 4 5 6
| const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('myAPI', { doAThing: () => {} })
|
进程通信
进程间通信 (IPC) 是在 Electron 中构建功能丰富的桌面应用程序的关键部分之一。 由于主进程和渲染器进程在 Electron 的进程模型具有不同的职责,因此 IPC 是执行许多常见任务的唯一方法,例如从 UI 调用原生 API 或从原生菜单触发 Web 内容的更改。
IPC通道
在 Electron 中,进程使用 ipcMain
和 ipcRenderer
模块,通过开发人员定义的“通道”传递消息来进行通信。 这些通道是 任意 (可以随意命名它们)和 双向 (可以在两个模块中使用相同的通道名称)的。
单向通信
- 渲染进程给主进程发送事件:
ipcRenderer.send('channel', args)
- 主进程监听渲染进程的事件:
ipcMain.on('channel', (event,args)=> {})
案例:修改标题
preload.js
1 2 3 4 5 6 7 8
| const {contextBridge, ipcRenderer} = require('electron')
contextBridge.exposeInMainWorld('electronAPI', { setTitle: (title) => { ipcRenderer.send('set-title', title) } })
|
index.html
1 2 3 4 5 6 7 8 9 10
| <input type="text" id="input"> <button id="btn">点击修改标题</button> <script> const btn = document.getElementById('btn'); const input = document.getElementById('input'); btn.addEventListener('click', () => { const title = input.value; window.electronAPI.setTitle(title); }); </script>
|
main.js
1 2 3
| ipcMain.on('set-title', (event, title) => { BrowserWindow.getFocusedWindow().setTitle(title) })
|
页面调用预加载的事件,传递当前输入框的内容作为标题的参数。然后有preload.js将信息(标题)传给主进程,主进程修改当前页面的标题。
案例2:页面的缩放
BrownserWindow API
- 最小化:win.minimize()
- 最大化: win.maximize()
- 还原:win.unmaximize()
- 关闭:win.close()
无边框:
1 2 3 4 5 6 7 8 9 10
| const mainWindow = new BrowserWindow({ width: 800, height: 600, ++ frame: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: true, contextIsolation: true } })
|
可拖动 CSS
- -webkit-app-region: drag;
- -webkit-app-region: no-drag;
双向通信
- ipcRenderer.invoke(‘channel’,…args)//进程通信桥梁
- ipcMain.handle(‘channel’,listener)//主进程监听渲染进程的事件
案例:打开文件
preload.js
1 2 3 4 5 6 7 8
| const {contextBridge, ipcRenderer} = require('electron')
contextBridge.exposeInMainWorld('electronAPI', { invoke: (channel, ...args) => { return ipcRenderer.invoke(channel, ...args) } })
|
main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
ipcMain.handle('dialog:openFile', async () => { const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), { properties: ['openFile'] })
return canceled ? undefined : filePaths[0] })
|
index.html
1 2 3 4
| btn2.addEventListener('click', async () => { const filePaths =await window.electronAPI.invoke('dialog:openFile'); console.log(filePaths); });
|
Dark Mode主题切换
nativeTheme API(本地主题API)
- themeSource // 当前的主题是什么?’dark’,’light’,’system’
- shouldUseDarkColors // 当前是否使用深色主题
CSS媒体查询
- @media(prefers-color-scheme:dark){}
- @media(prefers-color-scheme: light){}
preload.js
1 2 3 4 5 6 7 8
| const {contextBridge, ipcRenderer} = require('electron')
contextBridge.exposeInMainWorld('electronAPI', { invoke: (channel) => { return ipcRenderer.invoke(channel) } })
|
main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| const {app, BrowserWindow, ipcMain,nativeTheme} = require('electron')
ipcMain.handle('native-theme', async () => { if (nativeTheme.shouldUseDarkColors){ nativeTheme.themeSource = 'light' }else{ nativeTheme.themeSource = 'dark' } return nativeTheme.themeSource })
ipcMain.handle('native-theme-system', async (event, theme) => { nativeTheme.themeSource = 'system' return 'system' })
|
index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <body> <h1>主题切换</h1> <div> <button id="toggle">切换</button> <button id="system">跟随主题</button> </div> <script> const toggle = document.getElementById('toggle') const system = document.getElementById('system') toggle.addEventListener('click',async() => { const res = await window.electronAPI.invoke('native-theme') console.log(res) }) system.addEventListener('click', async () => { const res = await window.electronAPI.invoke('native-theme-system'); console.log(res) }) </script> </body>
|
style.css
1 2 3 4 5 6 7 8 9 10 11 12
| @media(prefers-color-scheme: dark){ body{ background: #333; color: #fff; } } @media(prefers-color-scheme: light){ body{ background: #fff; color: #333; } }
|
:point_right: 官网文档
应用菜单创建
main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| const {app, BrowserWindow, Menu, nativeTheme} = require('electron');
const createMenuApplication = () => { const template = [ {label: '首页',}, { label: '分类', submenu: [ {label: '系统',}, {label: '生活',}, {type: 'separator'}, {label: '技术',}, ], }, { label: '主题切换', submenu: [ { label: '暗黑', checked: nativeTheme.shouldUseDarkColors, click: () => nativeTheme.themeSource = 'dark' }, { label: '浅色', checked: !nativeTheme.shouldUseDarkColors, click: () => nativeTheme.themeSource = 'light' } ] } ] const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu) }
app.whenReady().then(() => { createWindow(); createMenuApplication(); }
|
效果:
右键菜单
preload.js
1 2 3 4 5 6 7 8 9 10
| const {contextBridge, ipcRenderer} = require('electron')
contextBridge.exposeInMainWorld('electronAPI', { send: (channel,args) => { ipcRenderer.send(channel, args) }, invoke: (channel, ...args) => { return ipcRenderer.invoke(channel, args) } })
|
index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| <body> <aside>侧边栏</aside> <main>主体</main>
<script> const aside = document.querySelector('aside'); const main = document.querySelector('main');
aside.addEventListener('contextmenu', (event) => { event.preventDefault(); console.log('侧边栏右键菜单'); window.electronAPI.send('contextmenu', [{ label: '分类', submenu: [ {label: '系统',}, {label: '生活',}, {type: 'separator'}, {label: '技术',}, ], },]) })
main.addEventListener('contextmenu', (event) => { event.preventDefault(); console.log('主体右键菜单'); window.electronAPI.send('contextmenu', [{ label: '感情', submenu: [ {label: '系统',}, {label: '生活',}, {type: 'separator'}, {label: '技术',}, ], },]) }) </script> </body>
|
main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| const {app, BrowserWindow, Menu, nativeTheme,ipcMain} = require('electron');
ipcMain.on('contextmenu',(event,arg)=>{ const template = [ ...arg, {label: '首页',},
] const menu = Menu.buildFromTemplate(template); const win = BrowserWindow.fromWebContents(event.sender) menu.popup({window:win}) })
|
Tray 托盘
main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
| const {app, BrowserWindow, Menu, nativeTheme, ipcMain, Tray, nativeImage} = require('electron');
let mainWindow = null;
const createWindow = () => { mainWindow = new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, 'preload.js'), }, });
mainWindow.loadFile(path.join(__dirname, 'index.html'));
mainWindow.on('close', (event) => { event.preventDefault(); mainWindow.hide(); });
mainWindow.on('closed', () => { mainWindow = null; }) };
function createTray() { const contextMenu = Menu.buildFromTemplate([ {label: '打开主窗口', click: () => { mainWindow.show() }}, {type: 'separator'}, {label: '退出', click: () => { mainWindow.destroy(); app.quit() }} ]) const icon = nativeImage.createFromPath('src/assets/tray.jpg'); const tray = new Tray(icon) tray.setToolTip('这是我的应用程序.') tray.setTitle('这是标题') tray.setContextMenu(contextMenu) }
|
效果:
Notification 通知
:point_right: 官方文档
主进程中创建通知
main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| const {app, BrowserWindow, Menu, nativeTheme, ipcMain, Tray, nativeImage,++ Notification} = require('electron');
function createNotification() { const notification = new Notification({ title: '通知标题', body: '通知内容', icon: 'src/assets/tray.jpg', })
notification.on('click', () => { console.log('通知被点击') })
notification.show() } app.whenReady().then(() => { createWindow(); createTray() ++ createNotification() }
|
在渲染进程中创建
index.html
1 2 3 4 5 6 7 8 9 10 11 12 13
| <script> document.getElementById('btn').addEventListener('click', () => { const options = { title: '发现新版本', body: '发现新版本,是否更新?', icon: './assets/tray.jpg', } const notify = new window.Notification('发现新版本', options) notify.onclick = () => { window.open('https://bilibili.com') } }) </script>
|
快捷键
:point_right:官网文档
渲染进程创建局部快捷键
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <script> window.onkeydown = (event) => { event.preventDefault(); const {key, ctrlKey, shiftKey, altKey, metaKey} = event; if (!(ctrlKey||shiftKey)){ return; } if (key === 's'){ console.info('保存') } if (key === 'z'){ console.info('撤销') } } </script>
|
主进程创建全局快捷键
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| const {app, BrowserWindow, Menu, nativeTheme, ipcMain, Tray, nativeImage,Notification, ++ globalShortcut } = require('electron');
app.whenReady().then(() => { createWindow(); createMenuApplication(); createTray() createNotification(); ++ registerShortcuts();})
function registerShortcuts() { globalShortcut.register('CommandOrControl+Shift+I', () => { BrowserWindow.getFocusedWindow().webContents.openDevTools(); }); globalShortcut.register('CommandOrControl+W', () => { BrowserWindow.getFocusedWindow().close(); }); }
|
同系统的快捷键的显示方式
关于 “注销全局快捷键” 的方式如下所示:
1 2 3
| app.on("will-quit", () => { globalShortcut.unregisterAll(); });
|
dialog 对话框
官方文档
Electron dialog API
- showMessageBox
- showErrorBox
- showOpenDialog
- showSaveDialog
Node.js fs API
消息提示
showMessageBox
showErrorBox
preload.js
1 2 3 4 5 6 7 8 9 10
| const {contextBridge, ipcRenderer} = require('electron')
contextBridge.exposeInMainWorld('electronAPI', { send: (channel,args) => { ipcRenderer.send(channel, args) }, invoke: (channel, args) => { return ipcRenderer.invoke(channel, args) } })
|
index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <script> const message = document.getElementById('message'); message.addEventListener('click', async () => { const option = { type: 'error', title: '标题', message: '提示信息', detail: '内容', checkboxLabel: '记住我', buttons: ['OK', 'Cancel'], } const result = await window.electronAPI.invoke('dialog:message-box', option) console.log(result) }) </script>
|
main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
ipcMain.handle('dialog:message-box', async (event, options) => { const win = BrowserWindow.fromWebContents(event.sender) return dialog.showMessageBox(win, options) })
|
选择文件
index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| <script>
const fileBtn = document.getElementById('file-btn'); fileBtn.addEventListener('click', async () => { const options = { title: '打开文件', buttonLabel: '就你了', filters: [ {name: 'Images', extensions: ['jpg', 'png', 'gif']}, {name: 'Movies', extensions: ['mkv', 'avi', 'mp4']}, {name: 'Custom File Type', extensions: ['as']}, {name: 'All Files', extensions: ['*']}, ], properties: ['openFile'], } const result = await window.electronAPI.invoke('dialog:open-file', options) if (result.canceled) { return; } console.log(result) textarea.value = await window.electronAPI.invoke('dialog:open-file-content', result.filePaths[0]) }) </script>
|
main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
|
ipcMain.handle('dialog:open-file', async (event, options) => { const win = BrowserWindow.fromWebContents(event.sender) return dialog.showOpenDialog(win, options) })
ipcMain.handle('dialog:open-file-content', async (event, filePath) => { return fs.promises.readFile(filePath, 'utf-8') })
|
index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| <script> const fileBtnSave = document.getElementById('file-btn-save');
fileBtnSave.addEventListener('click', async () => { const options = { title: '保存文件', buttonLabel: '保它', filters: [ {name: 'Images', extensions: ['jpg', 'png', 'gif']}, {name: 'Movies', extensions: ['mkv', 'avi', 'mp4']}, {name: 'Custom File Type', extensions: ['as']}, {name: 'All Files', extensions: ['*']}, ] } const result = await window.electronAPI.invoke('dialog:save-file', options) if (result.canceled) { return; } try{ await window.electronAPI.invoke('dialog:save-file-content', {filePath:result.filePath, content: textarea.value}) await window.electronAPI.invoke('dialog:message-box', { type: 'info', title: '保存成功', message: '保存成功', detail: '保存成功', buttons: ['OK'], }) }catch (e){ console.log(e) } }) </script>
|
main.js
1 2 3 4 5 6 7 8
| ipcMain.handle('dialog:save-file', async (event, options) => { const win = BrowserWindow.fromWebContents(event.sender) return dialog.showSaveDialog(win, options) })
ipcMain.handle('dialog:save-file-content', async (event, {filePath, content}) => { return fs.promises.writeFile(filePath, content) })
|
online 在线状态
官方文档
在线/离线 状态探测
index.html
1 2 3 4 5 6 7 8 9 10
| <script> const onlineStatus = document.getElementById('online-status'); const refresh = async () => { onlineStatus.innerText = navigator.onLine ? '在线' : '离线'; await window.electronAPI.send('online-status:change', navigator.onLine) } refresh(); window.addEventListener('online',refresh) window.addEventListener('offline',refresh) </script>
|
动态更改托盘图标
main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| const onlineIcon = nativeImage.createFromPath('src/assets/online.jpeg'); const offlineIcon = nativeImage.createFromPath('src/assets/offline.jpeg'); let tray = null; function createTray() { tray = new Tray(onlineIcon) }
ipcMain.on('online-status:change', (event, status) => {
let flag = true; setInterval(() => { flag ? tray.setImage(onlineIcon) : tray.setImage(nativeImage.createEmpty()); flag = !flag; },500) })
|
多窗口
加载窗口
main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| let loadingWindow = null const createLoadingWindow =() => { loadingWindow = new BrowserWindow({ width: 300, height: 300, frame: false, webPreferences: { webSecurity: false, preload: path.join(__dirname, 'preload.js') } }) loadingWindow.loadFile( 'src/loading.html'); }
ipcMain.on('loading-finish', (event) => { loadingWindow.close() mainWindow.show() })
|
父子窗口
通过使用 parent
选项,你可以创建子窗口:
1 2 3 4 5 6
| const { BrowserWindow } = require('electron')
const top = new BrowserWindow() const child = new BrowserWindow({ parent: top }) child.show() top.show()
|
child
窗口将总是显示在 top
窗口的顶部.
模态窗口
模态窗口是禁用父窗口的子窗口。 要创建模态窗口,必须同时设置parent
和modal
属性:
1 2 3 4 5 6 7 8
| const { BrowserWindow } = require('electron')
const top = new BrowserWindow() const child = new BrowserWindow({ parent: top, modal: true, show: false }) child.loadURL('https://github.com') child.once('ready-to-show', () => { child.show() })
|
阻止关闭
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| win.on('close', async(event)=>{
const options = {
type: 'question',
title: '操作提示',
message:'确认关闭当前窗口吗?'
button:['OK', 'Cancel'],
}
cosnt result = await window.ipcRenderer.invoke('dialog:meeage-box',option);
if(result.response === 0){
win.destroy();}
})
|