构建一个 Electron 桌面应用

3,380 阅读37分钟

前言

一直以来对 Electron 都很感兴趣,刚好趁着最近有空可以学习一下electron,本文记录了我构建项目的过程,作为一个electron初学者一路上,我遇到了很多挑战,其中也爬了许多坑,项目中主要采用了 react+ ts + electron 进行构建,由于本文内容较长同学们可以选择性阅读感兴趣的部分,有问题可以一起讨论啊,希望大家一起共同进步!!!

Electron 基础

Electron 是一个由 GitHub 开发的开源库,通过将 Chromium 和Node.js 组合并使用 HTML,CSS 和 JavaScript 进行构建 Mac,Windows,和 Linux 跨平台桌面应用程序。

Electron环境

Electron 是一个跨平台的桌面应用技术,从开发的角度,可理解为js的一个执行环境。

js 的执行环境也包括 浏览器、nodejs,在执行环境中 js 引擎会按照一定的规则解析和执行代码,在执行环境中基础设施api可以提供给该语言运行使用。

Electron 是一个可以用 JavaScript、HTML 和 CSS 构建桌面应用程序的库。Electron 环境结合了Chromium(浏览器环境)、Nodejs、系统原生API,让编程桌面程序成为现实。

因此在Electron里可以运行浏览器api,nodeapi,部分系统api的调用。在Electron这个大环境里,又分了js运行的小环境:主进程环境、渲染进程环境。不同环境里,有他们各自可以执行的api。在各自的进程中内存和状态是不会被共享的。

主进程

每个 Electron 应用有且只有一个主进程,并作为应用程序的入口点。 主进程在 Node.js 环境中运行,这意味着它具有 require 模块和使用所有 Node.js API 的能力。

主进程的主要目的是使用 BrowserWindow 模块创建和管理应用程序窗口,每个实例创建一个应用程序窗口,且在单独的渲染器进程中加载一个网页。

Electron 目前只支持三个平台:win32 (Windows), linux (Linux) 和 darwin (macOS) 。

process.platform

渲染进程

每个 Electron 应用都会为每个打开的 BrowserWindow ( 与每个网页嵌入 ) 生成一个单独的渲染器进程。渲染器负责渲染网页内容。 这也意味着渲染器无权直接访问 require 或其他 Node.js API。

在主进程创建的一个个web页面也都运行着自己的进程,即渲染进程,Electron 使用 Chromium 来展示页面,所以 Chromium 的多进程结构也被充分利用,渲染进程间相互独立,只关心和管理自己的页面。

原生API

为了使 Electron 的功能不仅仅限于对网页内容的封装,主进程也添加了自定义的 API 来与用户的作业系统进行交互。 Electron 有着多种控制原生桌面功能的模块,例如菜单、对话框以及托盘图标。

渲染进程、窗口、index.html的关系

他们三者之间有如下关系:

  • 主进程中 new BrowserWindow() 会开启一个系统窗口,但不会开启一个独立的进程,窗口由主进程管理。
  • 窗口可以loadURL加载一个页面,此时会开启这个页面的渲染进程。
  • 当loadURL加载一个index.html文件的时候,此时渲染进程里运行的就是这个index.html的内容。

主进程使用 BrowserWindow 实例创建网页。每个 BrowserWindow 实例都在自己的渲染进程中运行。当一个 BrowserWindow 实例被销毁后,相应的渲染进程也会被终止。

生命周期

  • ready: app完成初始化操作后会触发一次,用于加载窗口

  • dom-ready: 一个窗口中文本加载完成

  • did-finish-load: 导航完成时触发

  • window-all-closed: 所有窗口都被关闭时触发

  • before-quit: 在关闭窗口之前触发

  • will-quit: 窗口关闭并且应用退出时触发

  • quit: 当所有窗口被关闭时触发

  • closed: 当窗口关闭时触发,此时应删除窗口引用

window-all-closed

如果你没有监听此事件并且所有窗口都关闭了,默认的行为是控制退出程序。

程序正常退出的生命周期顺序 befor-quit ==>> will-quit ==>> quit;

但如果监听了 window-all-closed 事件,可以在该生命周期钩子中手动控制程序是否退出,手动调用 app.quit() 可继续执行退出操作。

如果用户按下了 Cmd + Q,或者开发者调用了 app.quit(),Electron 会首先关闭所有的窗口然后触发 will-quit 事件,在这种情况下 window-all-closed 事件不会被触发。

事件触发

app.quit()

尝试关闭所有窗口 将首先发出 before-quit 事件。 如果所有窗口都已成功关闭, 则将发出 will-quit 事件, 并且默认情况下应用程序将终止。

此方法会确保执行所有beforeunload 和 unload事件处理程序。 可以在退出窗口之前的beforeunload事件处理程序中返回false取消退出。

app.exit([exitCode])

使用 exitCode 立即退出。 exitCode 默认为0。

所有窗口都将立即被关闭,而不询问用户,而且 before-quit 和 will-quit 事件也不会被触发。

此方法在执行时不会退出当前的应用程序, 你需要在调用 app.relaunch 方法后再执行 app. quit 或者 app.exit 来让应用重启。

当 app.relaunch 被多次调用时,多个实例将在当前实例退出后启动。

app.relaunch

从当前实例退出,重启应用。

app.isReady()

如果 Electron 已完成初始化返回 boolean - true,否则 false 。 另见 app.whenReady()。

app.whenReady()

返回 Promise - 当Electron 初始化完成。 可用作检查 app.isReady() 的方便选择,假如应用程序尚未就绪,则订阅ready事件。

app.hide()

隐藏所有的应用窗口,不是最小化.

app.show()

显示隐藏后的应用程序窗口。 不会使它们自动获得焦点。

app.getAppPath()

当前应用程序目录。

app.getPath()

获取指定的目录或文件路径。

  • 常见的用户目录:
    • desktop、documents、downloads、pictures、music、video
  • 特殊的文件目录:
    • temp对应系统临时文件夹路径。
    • ·exe对应当前执行程序的路径。
    • ·appData对应应用程序用户个性化数据的目录。
    • userData是appData路径后再加上应用名的路径,是appData的子路径。这里说的应用名是开发者在package.json中定义的name属性的值。

proload

预加载(preload)脚本包含了那些执行于渲染器进程中,且先于页面内容开始加载的代码 。这些脚本虽运行于渲染器的环境中,却因能访问 Node.js API 而拥有了更多的权限。

因为预加载脚本与浏览器共享同一个全局 Window 接口,并且可以访问 Node.js API,所以它通过在全局 window 中暴露任意 API 来增强渲染器,以便你的网页内容使用。

虽然预加载脚本与其所附着的渲染器在共享着一个全局 window 对象,但您并不能从中直接附加任何变动到 window 之上。

  • preload脚本是运行在渲染进程中的
  • preload加载时机 在页面运行其他脚本之前预先加载

preload 的使用方式

const { contextBridge } = require('electron')

contextBridge.exposeInMainWorld('myAPI', {
  desktop: true,
})
console.log(window.myAPI)

通过暴露 ipcRenderer 帮手模块于渲染器中,您可以使用 进程间通讯 从渲染器触发主进程任务

上下文隔离

上下文隔离功能将确保您的 预加载脚本 和 Electron的内部逻辑 运行在所加载的 webcontent 网页 之外的另一个独立的上下文环境里。 这对安全性很重要,因为它有助于阻止网站访问 Electron 的内部组件 和 您的预加载脚本可访问的高等级权限的API 。

这意味着,实际上,您的预加载脚本访问的 window 对象并不是网站所能访问的对象。 例如,如果您在预加载脚本中设置 window.hello = 'wave' 并且启用了上下文隔离,当网站尝试访问window.hello对象时将返回 undefined。

Electron 提供一种专门的模块来无阻地帮助您完成这项工作。 contextBridge 模块可以用来安全地从独立运行、上下文隔离的预加载脚本中暴露 API 给正在运行的渲染进程。 API 还可以像以前一样,从 window.myAPI 网站上访问。

// 在上下文隔离启用的情况下使用预加载
const { contextBridge } = require('electron')

contextBridge.exposeInMainWorld('myAPI', {
  doAThing: () => {}
})

// ✅ 正确使用
contextBridge.exposeInMainWorld('myAPI', {
  loadPreferences: () => ipcRenderer.invoke('load-prefs')
})

在渲染进程中 通过window 可直接访问导出的 API : window.myAPI.doAThing()

Electron + react + ts 项目创建

初始化 react 项目

大多数时候可能我们会先安装 create-react-app,命令如下所示:

# Mac 下可能需要加 sudo
cnpm install -g create-react-app

不过这个方案官方并不推荐 官方推荐我们卸载掉全局的 create-react-app,

npm uninstall -g create-react-app

并使用 npx 去创建项目,因为这个可以保证我们使用的 create-react-app 是最新的

npx create-react-app myreact
  • 结构
  • package.json:包管理文件
  • node_modules:局部安装的第三方模块
  • .gitignore:被git忽略的目录或者文件
  • src:开发目录
  • index.js:整个项目的入口文件
  • app.js:根组件
  • public:存放项目模板文件,以及通过href或者src引入的外部文件
  • readme.md:说明文档
  • vscode插件:ES7 React/Redux
  • react-devtools

项目配置

react 核心包安装

  • react 包为react的核心代码
  • react-dom 是React剥离出的涉及DOM操作的部分,react-dom保证数据与页面保持一致。
npm install react react-dom react-redux--save

修改默认打包配置

npm install react-app-rewired customize-cra --save-dev

在package.json 同级目录下新建 config-overides.js 进行配置

// config-overrides.js
/* eslint-disable no-useless-computed-key */
const {
  override,
  addWebpackAlias,
  addWebpackResolve,
  fixBabelImports,
  addLessLoader,
  adjustStyleLoaders,
  addWebpackPlugin,
  addWebpackModuleRule,
} = require('customize-cra');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); // 代码压缩
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); // 大文件定位
const ProgressBarPlugin = require('progress-bar-webpack-plugin'); // 打包进度
const CompressionPlugin = require('compression-webpack-plugin'); // gzip压缩
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); // css压缩
const path = require('path');

module.exports = override(
  // 导入文件的时候可以不用添加文件的后缀名
  addWebpackResolve({
    extensions: ['.tsx', '.ts', '.js', '.jsx', '.json'],
  }),
  // 路径别名
  addWebpackAlias({
    ['@']: path.resolve(__dirname, 'src'),
  }),
  // less预加载器配置
  addLessLoader({
    strictMath: true,
    noIeCompat: true,
    modifyVars: {
      '@primary-color': '#1DA57A', // for example, you use Ant Design to change theme color.
    },
    cssLoaderOptions: {}, // .less file used css-loader option, not all CSS file.
    cssModules: {
      localIdentName: '[path][name]__[local]--[hash:base64:5]', // if you use CSS Modules, and custom `localIdentName`, default is '[local]--[hash:base64:5]'.
    },
  }),
  // 注意是production环境启动该plugin
  process.env.NODE_ENV === 'production' &&
  addWebpackPlugin(
    new UglifyJsPlugin({
      // 开启打包缓存
      cache: true,
      // 开启多线程打包
      parallel: true,
      uglifyOptions: {
        // 删除警告
        warnings: false,
        // 压缩
        compress: {
          // 移除console
          drop_console: true,
          // 移除debugger
          drop_debugger: true,
        },
      },
    })
  ),
  addWebpackPlugin(new MiniCssExtractPlugin()),
  // 判断环境变量ANALYZER参数的值
  process.env.ANALYZER && addWebpackPlugin(new BundleAnalyzerPlugin()),
  // 打包进度条
  addWebpackPlugin(new ProgressBarPlugin()),
  addWebpackModuleRule({
    test: [/.bmp$/, /.gif$/, /.jpe?g$/, /.png$/],
    loader: require.resolve('url-loader'),
    options: {
      limit: 64,
      name: 'static/media/[name].[hash:8].[ext]',
    },
  })
  addWebpackModuleRule({
  test: [/.css$/, /.less$/], // 可以打包后缀为sass/scss/css的文件
  use: ['style-loader', 'css-loader', 'less-loader'],
})
);
  • addWebpackAlias 相当于 webpack的Plugin
  • addWebpackModuleRule 相当于webpack里Module的rules
  • addWebpackAlias 相当于 webpack里resolve的alias,添加路径别名
  • addWebpackExternals 相当于 webpack里Externals,添加外部扩展CDN

改写package.json 的启动命令

原来的:
"scripts": {
  "start": "react-scripts start",
  "build": "react-scripts build",
  "test": "react-scripts test",
  "eject": "react-scripts eject"

}

修改后的:
"scripts": {
  "start": "react-app-rewired start",
  "build": "react-app-rewired build",
  "test": "react-app-rewired test",
  "eject": "react-scripts eject"
}

安装 electron

安装依赖
npm install electron --save-dev

electron 一定要安装在 devDependencies 开发环境依赖里面,不然在后续的打包过程中会出现报错。

配置主进程入口

创建 main.js

const { app, BrowserWindow } = require("electron");
const path = require("path");
let mainWindow = null;
const createWindow = () => {
   mainWindow = new BrowserWindow({
    titleBarStyle: 'default ',
    frame: false, // 设置为false后为无边框窗口,即无法拖拽,拉伸窗体大小,没有菜单项
    transparent: false,  // 设置窗口transparent=true,会导致win.restore()无效。
    width: 800,
    height: 600,
    hasShadow:true,
    backgroundColor: '#0000',
    webPreferences: {
      nodeIntegration: true, // 是否集成 Nodejs
      enableRemoteModule: true, // 允许在渲染进程中使用 remote 模块,解决渲染进程和主进程间的通讯,否则 require("electron").remote.BrowserWindow 为空
      contextIsolation: false, // 上下文隔离为 true 时 允许使用 contextBridge模块
      preload: path.join(__dirname, './preload.js'),  // 配置 preload.js 预加载文件
    },
    icon: path.join(__dirname, './public/bilibili.ico')  // 修改应用程序的图标
   })

  /**
   * loadURL 分为两种情况
   *  1.开发环境,指向 react 的开发环境地址
   *  2.生产环境,指向 react build 后的 index.html
   */
  const startUrl =
    process.env.NODE_ENV === 'development'
      ? 'http://localhost:3000'
      :  path.join(__dirname, "/dist/index.html");
      
  mainWindow.loadURL(startUrl);

  mainWindow.on('closed', function () {
    mainWindow = null;
  });

};

app.whenReady().then(() => {
  createWindow()
})
配置 package.json

为package.json 添加配置项

{
  "main": "main.js", // 添加项目主入口
  "homepage": ".",
  "scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test",
    "eject": "react-app-rewired eject",
    "start-electron": "electron .", // electron项目启动
  },
  "browser": {
    "fs": true,
    "os": false,
    "path": true
  }
}
cross-evn 设置环境变量

cross-env 实现跨平台设置和使用环境变量的脚本,能够提供一个设置环境变量的scripts,这样我们就能够以unix方式设置环境变量,然而在windows上也能够兼容的。

npm install cross-env --save-dev

在 package.json 中使用,指定当前运行的环境变量

"scripts": {
  "start": "react-app-rewired start",
  "build": "react-app-rewired build",
  "test": "react-app-rewired test",
  "eject": "react-app-rewired eject",
  "start-electron": "cross-env NODE_ENV=development electron .",
  "start-electron-prod": "electron ."
},

electron 项目启动

基于package.json配置的启动命令进行优化

此次优化需要用到两个依赖包

concurrently:支持同时执行多个命令
wait-on:等待指令执行完

由于 electron 的启动基于react的项目页面,所以需要先运行react 项目

pnpm start

再执行 electron

pnpm start-electron

每次启动项目时要执行两条命令有点麻烦,再对启动命令进行优化

首先,我们需要新增一个插件 concurrently, 支持同时执行多个命令,一次可以完美的运行多个命令。

npm install concurrently --save-dev  // 安装 concurrently 库

为 package.json 添加新的命令,支持同时执行

 "serve":"concurrently "electron ." "npm start""

但是还存在一个问题,electron是基于react后启动的,需要存在 http://localhost:3000 后才能在electron中渲染相应页面

那就还需要用到一个库 wait-on,等待某个指令执行完之后再继续执行后续指令

npm install wait-on --save-dev  //安装 wait-on 库

再次改写 package.json 中的 serve 命令

"serve": "concurrently "npm run start" "wait-on http://localhost:3000 && npm run start-electron" ",

先启动 react 项目并监听3000端口是否启动完成,等待3000端口跑完了再启动 electron 打开 electron 窗口

由于eletron 主要展示为应用程序,所以在项目运行时不需要自动打开网页端

在启动项目时通过 cross-env BROWSER=none npm run start可以阻止浏览器的自动开启,具体设置如下

 "serve": "concurrently " cross-env  BROWSER=none npm run start" "wait-on http://localhost:3000 && npm run start-electron" ",

如下图所示

remote 模块

由于electron 的迭代 remote 的使用方法有所变更,

在v14以前可以直接使用 remote 无需安装 @electron/remote

使用 remote 的差异

引入remote
新版本 const {BrowserWindow} = require(‘@electron/remote’);
旧版本 const {BrowserWindow} = require(“electron”).remote;

在版本较高的 electron 中 需要通过 @electron/remote 获取

首先安装依赖

yarn add @electron/remote

在主进程中 初始化remote并开启

/* main.js */
const { app, BrowserWindow, ipcMain, Menu  } = require('electron');
const path = require('path');
// 从 @electron/remote/main 中引入remote 模块
const remote = require('@electron/remote/main')
let mainWindow = null;

const createWindow = () => {
   mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true, // 是否集成 Nodejs
      enableRemoteModule: true, // 允许在渲染进程中使用 remote 模块,解决渲染进程和主进程间的通讯,否则 require("electron").remote.BrowserWindow 为空
      contextIsolation: false,
    },
   },
  );
  remote.initialize()   // 初始化 remote 
  remote.enable(mainWindow.webContents)  // 开启 remote 

  mainWindow.loadURL(http://localhost:3000);
  mainWindow.on('closed', function () {
    mainWindow = null;
  });
};

app.whenReady().then(() => {
  createWindow()
})

渲染进程中的使用

// remote 主进程模块
const remote = require('@electron/remote');

Electron 实践

自定义窗口

实现自定义窗口,不采用系统原生窗口,将窗口设置为无边框窗口,通过layout自定义边框的样式、icon、标题等效果。

设置无边框窗口

const createWindow = () => {
   mainWindow = new BrowserWindow({
    titleBarStyle: 'default ',
    frame: false, // 设置为false后为无边框窗口,即无法拖拽,拉伸窗体大小,没有菜单项
    transparent: false,  // 设置窗口transparent=true,会导致win.restore()无效。
    width: 800,
    height: 600,
    hasShadow:true,
    backgroundColor: '#0000',
    webPreferences: {
      nodeIntegration: true, // 是否集成 Nodejs
      enableRemoteModule: true, // 允许在渲染进程中使用 remote 模块,解决渲染进程和主进程间的通讯,否则 require("electron").remote.BrowserWindow 为空
      contextIsolation: false,
      preload: path.join(__dirname, 'preload.js'),  
    },
    icon: path.join(__dirname, './public/bilibili.ico')  // 修改应用程序的图标
   },
  );
  remote.initialize()   
  remote.enable(mainWindow.webContents)  // 实现 remote 

  /**
   * loadURL 分为两种情况
   *  1.开发环境,指向 react 的开发环境地址
   *  2.生产环境,指向 react build 后的 index.html
   */
  const startUrl =
    process.env.NODE_ENV === 'development'
      ? 'http://localhost:3000'
      :  path.join(__dirname, "/build/index.html");
  mainWindow.loadURL(startUrl);

  mainWindow.on('closed', function () {
    mainWindow = null;
  });
};

实现窗口操作

import React from 'react'
import './index.scss'
import { MinusOutlined, CloseOutlined, BorderOutlined } from '@ant-design/icons';

const currentWindow = require('@electron/remote');

function frame() {

   function setFrameSize(type){
    switch(type){
      case 'min':
        console.log(currentWindow )
        currentWindow.getCurrentWindow().minimize()
        break
      case 'scale':
        // 判断窗口是否最大化
        if(currentWindow.getCurrentWindow().isMaximized()){
          // 将窗口大小恢复到上一次的状态 
          // 此时需要注意创建win窗口时设置 transparent 为 false, 否则 restore方法失效
          currentWindow.getCurrentWindow().restore()
        } else {
          currentWindow.getCurrentWindow().maximize()
        }
        break
      case 'close':
        // 关闭窗口
        currentWindow.getCurrentWindow().close()
        break
      default:
        break
    }
  }

  return (
    <div className="frame"  >
      <div className="title-wrap">
        <img src="/bilibili.ico" alt=""  className="icon"/>
        <span className="title">music</span>
      </div>
      <div className="frame-btn">
        <MinusOutlined   className="btn" onClick={()=>{setFrameSize('min')}}/>
        <BorderOutlined  className="btn" onClick={()=>{setFrameSize('scale')}}/>
        <CloseOutlined  className="btn" onClick={()=>{setFrameSize('close')}}/>
      </div>
    </div>
  )
}

export default frame

窗口拖拽

通过 css样式实现边框的拖拽

-webkit-app-region: drag; // 设为可拖动,  no-drag 不可拖动

设置为 drag 属性后会影响到点击事件,所以在点击区域要设置为 no-drag

.frame{
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  height: 32px;
  padding: 0 20px;
  border-bottom: 1px solid #e7e9e8 ;
  background-color: #d1c4e9;
  font-size: 16px;
  -webkit-app-region: drag; // 设为可拖动
  .title-wrap {
    .icon {
      width: 20px;
      height: 20px;
      margin-right: 10px;
    } 
    .title {
      vertical-align: 6px;
    }  
  }
  .frame-btn {
    -webkit-app-region: no-drag; // 点击按钮是不可拖拽
    .btn {
      display: inline-block;
      margin: 0 10px;
      font-size: 12px;
      cursor: pointer;

    }
  }
}

自定义顶部菜单

实现顶部菜单

const createWindow = () => {
   mainWindow = new BrowserWindow({
    // titleBarStyle: 'default ',
    // frame: true, // 设置为false后为无边框窗口,即无法拖拽,拉伸窗体大小,没有菜单项
    transparent: true,
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true, // 是否集成 Nodejs
      enableRemoteModule: true, // 允许在渲染进程中使用 remote 模块,解决渲染进程和主进程间的通讯,否则 require("electron").remote.BrowserWindow 为空
      contextIsolation: false,
    },
   },
  );
  
  // 设置顶部菜单是不能隐藏窗口边框,否则无法生效
   // 菜单模板设置
  const template = [{    label: '应用',    submenu: [      { label: '关于', accelerator: 'CmdOrCtrl+I', role: 'about' },      // 这里可以自定义菜单项,如下可以与渲染进程通信      {           label: '检测更新',          click: () => { mainWindow.webContents.send('menuCheckUpdate') },          accelerator: 'CommandOrControl+Shift+u', // 设置快捷键          enabled: false //'这里设置是否可点击'      },      { type: 'separator' }, // 分割线      { label: '隐藏', role: 'hide' },      { label: '隐藏其他', role: 'hideOthers' },      { type: 'separator' },      { label: '服务', role: 'services' },      { label: '退出', accelerator: 'Command+Q', click: () => {app.quit()} }    ]
  },
  {
    label: '编辑',
    submenu: [
      { label: '复制', accelerator: 'CmdOrCtrl+C', role: 'copy' },
      { label: '粘贴', accelerator: 'CmdOrCtrl+V', role: 'paste' },
      { label: '剪切', accelerator: 'CmdOrCtrl+X', role: 'cut' },
      { label: '撤销', accelerator: 'CmdOrCtrl+Z', role: 'undo' },
      { label: '重做', accelerator: 'Shift+CmdOrCtrl+Z', role: 'redo' },
      { label: '全选', accelerator: 'CmdOrCtrl+A', role: 'selectAll' }
    ]
  },
  {
    label: '窗口',
    role: 'window',
    submenu: [{
      label: '缩放',
      role: 'Zoom'
    }, {
      label: '最小化',
      role: 'minimize'
    }, {
      label: '关闭',
      role: 'close'
    }]
  },
  {
    label: '帮助',
    role: 'help',
    submenu: [{
      label: '开发者工具',
      role: 'toggledevtools',
      accelerator: 'CommandOrControl+Shift+i'
    }]
  }]
  mainWindow.webContents.openDevTools()
  // 加载菜单项
  let menuBuilder = Menu.buildFromTemplate(template)
  Menu.setApplicationMenu(menuBuilder)
 
  /**
   * loadURL 分为两种情况
   *  1.开发环境,指向 react 的开发环境地址
   *  2.生产环境,指向 react build 后的 index.html
   */
  const startUrl =
    process.env.NODE_ENV === 'development'
      ? 'http://localhost:3000'
      :  path.join(__dirname, "/build/index.html");
  mainWindow.loadURL(startUrl);

  mainWindow.on('closed', function () {
    mainWindow = null;
  });
};

可参考 菜单项配置

  • submenu -- 指定子菜单
  • label -- 菜单项名称
  • accelerator -- 设置快捷键
  • role -- 可以通过角色来为menu添加预定义行为。给菜单指定 role 去匹配一个标准角色, 而不是尝试在 click 函数中手动实现该行为
  • click -- MenuItem接收到点击事件后自动触发的方法 Function
  • enabled -- 是否启用该项

系统 dialog

导入文件 - dialog 选择文件

dialog.showOpenDialog([browserWindow, ]options)

在渲染进程中通过 remote 调用 dialog模块的 showOpenDialog 实现文件的导入,该方法返回用户选择的文件路径

参数配置:

  • filters 指定一个文件类型数组,用于规定用户可见或可选的特定类型范围。
    • name: string过滤的提示
    • extensions 数组应为没有通配符或点的扩展名
  • properties string[] (可选) - 包含对话框相关属性。 支持以下属性值:
    • openFile - 允许选择文件
    • openDirectory - 允许选择文件夹
    • multiSelections-允许多选。
    • showHiddenFiles-显示对话框中的隐藏文件。
    • createDirectory macOS -允许你通过对话框的形式创建新的目录。
    • promptToCreate Windows-如果输入的文件路径在对话框中不存在, 则提示创建。 这并不是真的在路径上创建一个文件,而是允许返回一些不存在的地址交由应用程序去创建。
    • noResolveAliases macOS-禁用自动的别名路径(符号链接) 解析。 所选别名现在将会返回别名路径而非其目标路径。
    • treatPackageAsDirectory **macOS **-将包 (如 .app 文件夹) 视为目录而不是文件。
    • dontAddToRecent Windows - 不要将正在打开的项目添加到最近的文档列表中。

调用该方法后回返回 Promise其中包含

  • canceled boolean - 对话框是否被取消。
  • filePaths string[] - 用户选择的文件路径的数组. 如果对话框被取消,这将是一个空的数组。
const {dialog} = require('@electron/remote');

dialog.showOpenDialog({
  filters: [
    {
      name: 'MD文件',
      extensions: ['md']
    }
  ],
  properties: ['openFile','multiSelections'],
  buttonLabel: '导入'
}).then((res)=>{
	// 导入的文件路径, 若窗口关闭则为空数组	
  console.log(res.filePaths)
}).catch((err) => {
  console.log(err)
})
}

点击导入后出现窗口

关闭窗口 - dialog 实现

对关闭按钮进行优化,在关闭应用时调用原生 dialog 询问是否退出应用

function setFrameSize(type){
    switch(type){
      case 'min':
        console.log(remote )
        remote.getCurrentWindow().minimize()
        break
      case 'scale':
        // 判断窗口是否最大化
        if(remote.getCurrentWindow().isMaximized()){
          // 将窗口从最小化状态恢复到以前的状态 
          // 创建win窗口时设置 transparent 为 false, 否则该方法失效
          remote.getCurrentWindow().restore()
        } else {
          remote.getCurrentWindow().maximize()
        }
        break
      case 'close':
        // 关闭按钮触发 dialog 
        dialog
        .showMessageBox({
          type: 'info',
          title: 'information',
          defaultId: 0,
          message: '确定要关闭吗?',
          buttons: ['最小化到托盘', '直接退出'],   // dialog 选项按钮,点击确认则下面的idx为0,取消为1
          cancelId: 2,  // dialog 关闭按钮代表的 response 值
          checkboxLabel: '记住我的选择',
        })
        .then(result => {
          console.log(result)
          if (result.response === 0) {
            remote.getCurrentWindow().hide(); // 隐藏窗口
          } else if (result.response === 1) {
            remote.getCurrentWindow().close(); // 关闭程序
          }
        })
        .catch(err => {
          console.log(err);
        });
        break
      default:
        break

    }
  }

菜单项配置

右键菜单

在渲染进程中触发右键,在渲染进程中是需要通过 remote 模块 或者 ipcRenderer 触发主进程中的模块。

在渲染进程中只需要监听 contextmenu 事件,然后将菜单展示出来即可。

  1. 进程通信实现
//  在主进程中设置右键菜单项
const template = [
  {
      label: '复制',
      role: 'copy'
  }, {
      label: '粘贴',
      role: 'paste'//使用了role,click事件不起作用
  }, {
      type: 'separator'//分隔符
  }, {
      label: '其它功能',
      click: () => {
          console.log('其它功能')
      }
  }
]

const contextMenu=Menu.buildFromTemplate(template)
// 监听右键菜单触发
ipcMain.on('showContextMenu',()=>{
  contextMenu.popup({
      window:BrowserWindow.getFocusedWindow()
  })
})

当渲染进程中右键触发监听时,通过 ipcRenderer 通知主进程显示菜单

import React, {useEffect} from 'react'
import { Outlet } from 'react-router-dom' //引入electron模块, 浏览器中没有该模块,所以会报错,只能在 electron 中跑通
import Frame from '@/components/frame'
const {ipcRenderer} = window.require('electron')

function App() {

  useEffect(()=>{
    // 右键监听事件
    document.addEventListener('contextmenu', handleContextMenu)
    return ()=> {
      // 移除事件监听
      document.removeEventListener('contextmenu', handleContextMenu)
    }
  })
  
  // 触发右键菜单
  function handleContextMenu() {
    ipcRenderer.send('showContextMenu')
  }
  
  return (
    <div className="wrapper">
        <Frame className="header" />
        <div className="main">
          <Outlet/>
          <span>hahah</span>
        </div>
        
    </div>
  )
}
export default App
  1. remote 模块调用主进程方法
const remote = require('@electron/remote');
const Menu = remote.Menu

const template = [
  {
      label: '复制',
      role: 'copy'
  }, {
      label: '粘贴',
      role: 'paste'//使用了role,click事件不起作用
  }, {
      type: 'separator'//分隔符
  }, {
      label: '其它功能',
      click: () => {
          console.log('其它功能')
      }
  }
]
const contextMenu = Menu.buildFromTemplate(template)

function App() {
  useEffect(()=>{
    // 右键监听事件
    document.addEventListener('contextmenu', handleContext);
    return ()=> {
      // 移除事件监听
      document.removeEventListener('contextmenu', handleContext)

    }
  })

  function handleContext(e){
      e.preventDefault() //阻止默认行为
      contextMenu.popup({
        window:remote.getCurrentWindow()
    })
  }
  return (
    <div className="wrapper">
        <Frame className="header" />
        <div className="main">
          <Outlet/>
          <span>hahah</span>
        </div>
        
    </div>
  )
}

export default App

托盘菜单


//  设置托盘右键菜单
const trayContextMenu = Menu.buildFromTemplate([
  {
    label: '播放/暂停',
    click: () => {
      mainWindow.webContents.send('playMusic')
    }
  },
  {
    label: '下一首',
    click: () => {
      mainWindow.webContents.send('nextMusic')
    }
  },
  {
    label: '上一首',
    click: () => {
      mainWindow.webContents.send('prevMusic')
    }
  },
  {
    type: 'separator'
  },
  {
    label: '退出',
    role: 'quit'
  }
])


app.whenReady().then(() => {
  // 配置托盘菜单
  const tray = new Tray('./public/bilibili.png')  // 托盘展示的icon
  // hover 时 托盘的提示信息
  tray.setToolTip('hover music')
  // 点击托盘时显示窗口
  tray.on('click', () => {
    mainWindow.show()
  })
  // 设置鼠标右键键事件
  tray.on('right-click', () => {
    tray.popUpContextMenu(trayContextMenu)
  })
  createWindow()
})

dock菜单

Electron有API来配置macOS Dock中的应用程序图标。 一个 macOS-only API 用于创建一个自定义的Dock 菜单, 但Electron也使用应用Dock 图标作为跨平台功能的入口。

一个自定义的Dock项也普遍用于为那些用户不愿意为之打开整个应用窗口的任务添加快捷方式。

//  设置 dock 菜单 仅适用于 macOS 
const dockMenu = Menu.buildFromTemplate([
  {
    label: 'New Window',
    click () { console.log('New Window') }
  }, {
    label: 'New Window with Settings',
    submenu: [
      { label: 'Basic' },
      { label: 'Pro' }
    ]
  },
  { label: 'New Command...' }
])



app.whenReady().then(() => {
  //  设置 dock 菜单 仅适用于 macOS 
  app.dock.setMenu(dockMenu)
  createWindow()
})

应用程序菜单

将最近文档列表添加到应用程序菜单

app.setUserTasks([
  {
    program: process.execPath,
    arguments: '--new-window',
    iconPath: process.execPath,
    iconIndex: 0,
    title: 'New Window',
    description: 'Create a new window'
  }
])

菜单项中每一个 菜单 Task 对象的配置项

  • program 字符串 - 要执行的程序的路径,通常你需要指定 process.execPath,也就是打开当前程序的路径
  • arguments 字符串- 给 program 这个程序执行时的命令行参数。
  • title 字符串 - 要在跳转列表中显示的字符串。
  • description string - 任务描述.
  • iconPath string - 在JumpList对象中显示的图标的绝对路径,JumpList对象可以是包含图标的任意资源文件。 通常可以指定process.execPath属性显示程序的图标.
  • iconIndex number - 图标文件中的索引. 如果一个图标文件包含多个图标,设置此属性指定是哪个图标. 如果图标文件只包含一个图标,此属性值为0.
  • workingDirectory 字符串(可选) - 当前工作目录。 默认值为空

通知 (Notification)

在进程中创建OS(操作系统)桌面通知,需要使用 Notification 模块。

Notification是一个EventEmitter(事件触发与事件监听器功能的封装)

在主进程中发送通知,创建窗口后立即显示消息

function showNotification () {
  new Notification({ 
    title: 'Basic Notification',
    body: 'Notification from the Main process' 
  }).show()
}

app.whenReady().then(createWindow).then(showNotification)

在渲染进程中也可直接显示通知,并设置消息对应的触发事件

 const sendMessage = ()=>{
    const notifyOpt = {
      title: 'Basic Notification',
      body: 'Notification from the Main process', 
      icon: path.join(remote.app.getAppPath(),"public/message.png"),
      silent: true  // 是否静音
    }
    const notify = new Notification( notifyOpt.title, notifyOpt)
    // 点击消息触发的事件
    notify.onclick =()=>{
      console.log('已点击')
    }
    // 关闭消息触发的事件
    notify.onclose = ()=>{
      console.log('已关闭消息提示')
    }
  }

获取 图片 路径

// app.getAppPath()获取当前项目所处的根路径
const newPath =  path.join(remote.app.getAppPath(),"public/message.png") 
console.log(newPath) // F:\demo\electron-react-demo\public\message.png

全局快捷键

监听全局键盘事件,即使应用程序没有获得键盘的焦点。globalShortcut 模块可以在操作系统中注册/注销全局快捷键, 以便可以为操作定制各种快捷键。

注意: 快捷方式是全局的; 即使应用程序没有键盘焦点, 它也仍然在持续监听键盘事件。

在 app 模块的 ready 事件就绪之前,这个模块不能使用。同时要在应用程序退出前注销设置的全局快捷键

const { app, globalShortcut   } = require('electron');

app.whenReady().then(() => {
  createWindow()
  // 注册快捷键监听器
  globalShortcut.register('CommandOrControl+shift+s', () => {
    console.log('CommandOrControl+s pressed')
    // 窗口最小化
    mainWindow.minimize()
  })

  globalShortcut.register('CommandOrControl+shift+q', () => {
    console.log('CommandOrControl+q pressed')
    mainWindow.hide()
  })
})

退出程序时要记得注销全局键盘事件

app.on('will-quit', () => {
  // 注销快捷键
  globalShortcut.unregister('CommandOrControl+s')

  // 注销所有快捷键
  globalShortcut.unregisterAll()
})

剪切板 (clipboard)

在系统剪贴板上执行复制和剪贴操作,在主进程 及 渲染进程都可对剪切板进行操作

通过 win+ v 可以打开系统的剪切板,可以通过该方法查看剪切板的写入是否成功。

const { clipboard } = require('electron')

clipboard.writeText('Example string', 'selection')
console.log(clipboard.readText('selection'))

复制、黏贴图片

// 复制图片
const imageUrl = path.join(remote.app.getAppPath(),"public/message.png")
const image =  nativeImage.createFromPath(imageUrl)
clipboard.writeImage(image)


// 从剪切板获取图片
const pasteImage = clipboard.readImage()
const pasteImageUrl = pasteImage.toDataURL()

从剪切板 写入或 读取到的图片类型为 NativeImage

读取到的 NativeImage 类型的图片需要通过 toDataURL() 将图像转化为的 data URL。

黑暗模式

利用进程通信调用主进程中的 nativeTheme.themeSource 切换主题

nativeTheme 读取并响应Chromium本地色彩主题中的变化。

const { nativeTheme  } = require('electron');

ipcMain.on('dark-mode:toggle',(event,arg)=>{
  console.log(arg)
  if(arg){
    nativeTheme.themeSource = 'dark'
  } else {
    nativeTheme.themeSource = 'light'
  }
})
  • nativeTheme.shouldUseDarkColors 只读属性, 为 boolean 值,表示当前OS / Chromium是否正处于dark模式。

  • nativeTheme.themeSource 值为:system, light or dark. 它被用来覆盖、重写Chromium内部的相应的值

将 nativeTheme.themeSource 设置为 dark 将产生以下效果:

  • 当访问 nativeTheme.shouldUseDarkColors 时值为 true

  • 任何在 Linux 和 Windows 上的 UI Electron 渲染,包括 context menus、devtools 等等,都会使用暗色界面。

  • 在 MacOS 上打开的任何 UI 界面,包括 menus、window frames 等,渲染时都会使用暗色界面。

  • prefers-color-scheme CSS 查询将匹配 dark 模式

  • updated 事件将被触发

prefers-color-scheme

利用 prefers-color-scheme CSS 媒体特性 结合 electron 的主题设置 实现应用的模式切换

prefers-color-scheme 用于检测用户是否有将系统的主题色设置为亮色或者暗色。

CSS文件使用@media媒体查询的prefers-color-scheme来设置< body >元素背景和文本颜色

@media (prefers-color-scheme: dark) {
  body {
    background: #333;
    color: white;
  }
}
@media (prefers-color-scheme: light) {
  body {
    background: #fff;
    color: black;
  }
}

进程通信

进程之间的通信

进程间通信 (IPC) 是在 Electron 中构建功能丰富的桌面应用程序的关键部分之一。

在 Electron 中,进程使用 ipcMainipcRenderer 模块,实现主进程与渲染进程之间的通信,有助于阻止网站访问 Electron 的内部组件 和 您的预加载脚本可访问的高等级权限的API 。

Electron 提供一种专门的模块来无阻地帮助您完成这项工作。 contextBridge 模块可以用来安全地从独立运行、上下文隔离的预加载脚本中暴露 API 给正在运行的渲染进程。 API 还可以像以前一样,从 window.myAPI 网站上访问。

const { contextBridge } = require('electron')

contextBridge.exposeInMainWorld('myAPI', {
  doAThing: () => {}
})
// 在渲染器进程使用导出的 API
window.myAPI.doAThing()

// ✅  暴露进程间通信相关 API 的正确方法是为每一种通信消息提供一种实现方法。
contextBridge.exposeInMainWorld('myAPI', {
  loadPreferences: () => ipcRenderer.invoke('load-prefs')
})

渲染进程 => 主进程(单向)

使用 ipcRenderer.send API 发送消息,然后使用 ipcMain.on API 监听接收到的消息。

渲染进程 => 主进程(双向)

  • 双向 IPC 的一个常见应用是从渲染器进程代码调用主进程模块并等待结果。 这可以通过将 ipcRenderer.invoke 与 ipcMain.handle 搭配使用来完成。
  • 用于单向通信的 ipcRenderer.send API 也可用于双向通信。
  • ipcRenderer.sendSync API 向主进程发送消息,并 同步 等待响应。而同步特性意味着它将阻塞渲染器进程,直到收到回复为止。

主进程 => 渲染进程

将消息从主进程发送到渲染器进程时,需要指定是哪一个渲染器接收消息。 消息需要通过其 WebContents 实例发送到指定的渲染器进程。

webContents.send API 将 IPC 消息从主进程发送到目标渲染器。

click: () => mainWindow.webContents.send('update-counter', -1)

项渲染器进程中暴露 IPC 功能

onUpdateCounter: (callback) => ipcRenderer.on('update-counter', callback)

渲染进程 => 渲染进程

没有直接的方法可以使用 ipcMain 和 ipcRenderer 模块在 Electron 中的渲染器进程之间发送消息。

  1. 将主进程作为渲染进程之间的消息代理,由主进程作为消息转发。
  2. 从主进程将一个 MessagePort 传递到两个渲染器。 这将允许在初始设置后渲染器之间直接进行通信。
  3. 将 localstorage 作为通信的桥梁,从 storage 中获取需要传递的信息

数据存储

electron 自带的存储方式: electron 中的 session 模块

本地持久化数据存储方式有 localStorage、electron-store

session 模块

管理浏览器会话、cookie、缓存、代理设置等。

session 模块在主进程中使用,可用于创建新的 session 对象。

还可以使用WebContents的session属性或 session模块访问现有页的session

const { BrowserWindow } = require('electron')

const win = new BrowserWindow({ width: 800, height: 600 })
win.loadURL('http://github.com')

const ses = win.webContents.session
console.log(ses.getUserAgent())  

const ses = win.webContents.session.cookies //获取到主窗口的Cookies
//获取Cookies
ses.cookies.get({ url: 'https://www.electronjs.org/' })
  .then((cookies) => {
    console.log(cookies)
  }).catch((error) => {
    console.log(error)
  })
//设置Cookies
ses.cookies.set({ url: 'https://www.electronjs.org/',name:"dummy_name",value:"dummy" })
  .then((cookies) => {
    console.log(cookies)
  }).catch((error) => {
    console.log(error)
  })

用session模块获取现有页面的Cookies

const { session } = require('electron')
//获取页面Cookies
session.defaultSession.cookies.get({ url: 'https://www.electronjs.org/' })
  .then((cookies) => {
    console.log(cookies)
  }).catch((error) => {
    console.log(error)
  })
//设置页面Cookies
const cookie = { url: 'https://www.electronjs.org', name: 'dummy_name', value: 'dummy' }
session.defaultSession.cookies.set(cookie)
  .then(() => {
    // success
  }, (error) => {
    console.error(error)
  })
//删除Cookies
const cookie = { url: 'https://www.electronjs.org', name: 'dummy_name'}
session.defaultSession.cookies.remove(cookie.url,cookie.name)
  .then(() => {
    // success
  }, (error) => {
    console.error(error)
  })

数据持久化

localStorage

localStorage 用于长久保存整个网站的数据,保存的数据没有过期时间,直到手动去删除

  • localStorage 会将设置的数据直接存储到本地文件Users\Username\AppData\Local\Google\Chrome\UserData\Default\Local Storage 中。
  • localStorage 仅在浏览器进程(渲染进程)中起作用。
  • 一个域名的 localStorage 的容量上限为5M,比Cookie的 4K 大大增加。
  • 浏览器中localStorage仅支持持久字符串的存储,对于JSON对象类型则需要转换。
  • localStorage 的安全性不高,对XSS攻击没有防御机制,可以通过xss漏洞来获取到Cookie。
  • localStorage 无法直接响应组件数据的变化,需要 通过 localStorage.setItem() 手动设置

而 localStorage 与 sessionStorage 的唯一区别是:localStorage 是永久性存储需要手动清除数据,而 sessionStorage 在会话结束时会清空所保存的键值对。

electron-store

electron-store 可以用来保存 Electron 应用程序或模块的简单数据持久性-保存和加载用户首选项,应用程序状态,缓存等。

数据保存在 app.getPath('userData')中的 JSON 文件中。可以在主进程和渲染器进程中直接使用此模块获取存储的数据。electron-store 数据存储方式在应用程序卸载之后存储数据的文件依然存在。

app.getPath('userData'): 用于储存应用程序配置文件的文件夹

本项目存储的位置上为 C:\Users\Administrator\AppData\Roaming\electron-react-demo

安装 electron-store 依赖

npm install electron-store

electron-store 在主进程和渲染进程中都可以使用

  1. 在主进程对 electron-store 进行初始化
const Store = require('electron-store');
// 初始化
Store.initRenderer();
  1. 在渲染进程中进行实例化后再设置所要存储的信息
// 注意只能用 require , 不能用 import
const Store = window.require("electron-store");

const store = new Store()
store.set('fileList',fileList)

electron-stroe 生成存储文件所在的位置

electron-store 实例方法

  • .set(key, value) : 设置存储项
  • .set(object):支持一次设置多个存储项
  • .get(key, [defaultValue]):获取存储项或defaultValue(如果该项目不存在)
  • .has(key):检查是否存在某一项
  • .delete(key): 删除某个存储项
  • .clear():删除所有

Electron 打包

1. 引入打包工具

Electron 的打包工具有两个:electron-packager 和 electron-builder。它们都可以把 Electron 应用打包成 将已有的electron应用打包成msi格式和exe可执行文件。

由于 electron-builder 比 electron-packager 有更丰富的的功能,支持更多的平台,同时也支持了自动更新。除此之外 electron-builder 打的包更为轻量,并且可以打包出不暴露源码的setup安装程序。考虑到以上几点选择了 electron-builder 作为本次项目的打包工具

安装 electron-builder

npm install electron-builder --save-dev

add electron-builder --save-dev

electron-builder 一定要安装在 devDependencies 开发环境依赖里,否则打包会出错。

devDependencies与dependencies的区别

dependencies 表示我们要在生产环境下使用该依赖,devDependencies 则表示我们仅在开发环境使用该依赖。在打包时,一定要分清哪些包属于生产依赖,哪些属于开发依赖,尤其是在项目较大,依赖包较多的情况下。若在生产环境下错应或者少引依赖包,即便是成功打包,但在使用应用程序期间也会报错,导致打包好的程序无法正常运行。

2. package.json 打包配置

根据项目需要进行相应的配置

{
  "build": {
    "appId": "XXX", // 软件包名,填你软件的名字
    "productName": "XXX", // 项目名 这也是生成的exe文件的前缀名
    "copyright": "GPL 3.0", // 使用版权的名称,可选
    "directories": { // 一些用到的文件夹
      "buildResources": "build", // 需要打包的静态文件目录
      "output": "dist" // 打包文件输出目录,默认为 dist
    },
    "nsis": { // 安装包生成程序 NSIS 的配置, NSIS 一般用来配置安装和卸载程序的
      "shortcutName": "makalo-cnblog-tool",  // 用于所有快捷方式的名称。默认为应用程序名称
      "oneClick": false,  // 是创建一键安装程序还是辅助安装程序
      "language": "2052", // 语言为中文
      "perMachine": true, // 是否开启安装时权限限制(此电脑或当前用户)true 表示此电脑,false代表当前用户
      "allowElevation": true,  // 仅辅助安装程序有效。允许请求提升。如果为false,则用户将不得不以提升的权限重新启动安装程序
      "allowToChangeInstallationDirectory": true, // 仅辅助安装程序有效。是否允许用户更改安装目录
      "createDesktopShortcut": true, // 创建桌面图标
      "createStartMenuShortcut": true, // 创建开始菜单图标
    },
    "win": { // Windows 下的配置
      "icon": "./build/favicon.png", // 图标路径 win 系统中 icon 的大小应为 256*256 的ico格式图片
      "target": "nsis" // 使用 NSIS 生成安装包,
      // 这个配置是在你需要额外迁移某些文件的时候,可以直接配置此项。则在打包时会额外迁移配置的文件【配置在这里,是指定此平台配置使用。如果需要通用,需要配置在build下级。具体后面有写】
      "extraResources": {
        "from": "./config.json",
        "to": "../config.json"
      }
    },
     "dmg": {
    	"contents": [
    		{
    			"x": 0,
    			"y": 0,
    			"path": "/Application"
    		}
    	]
    },
    "linux": {
    	"icon": "xxx/icon.ico"
    },
    "mac": {
    	"icon": "xxx/icon.ico"
    },
    "files": [ // 需要打包进去的文件
      "build/**/*", // build 下所有静态文件
      "./main.js" // 入口文件 main.js
    ],
    "extends": null // 不使用扩展
  },
}

3. package.json打包脚本配置

打包的命令脚本可以自行更改哦

{
	...
   "scripts": {
  		"build-win": "electron-builder --win --x64",
  		"build-linux": "electron-builder --linux",
  		"build-mac": "electron-builder --mac"
   },
}

4. Electron 打包

执行打包命令

先将react 打包出build文件后再打包electron,

在 webpack 打包react 项目时 public 文件夹下的内容将不会被打包,而是在你打包的时候,将public文件夹直接复制一份到你构建出来的 build 文件中。

# 先将 react 项目进行打包生成 build 文件
npm run build
# 再打包 electron 生成 dist 文件
npm run build-win

打完包后可以在项目的 dist 文件下找到 exe 文件,将打包后的安装包安装运行即可

在安装过程中出现系统拦截

安装成功后

resources文件夹下有个app.asar是项目源码的归档文件,而这个exe则是程序的启动文件

5. asar 解压

.asar 就是项目源码的归档文件,asar是一种归档格式,会有专门的解压工具 asar

# 安装 asar
npm install -g asar

# 解压到 ./app 文件夹下
asar extract app.asar ./app

将 asar 解压到当前目录的app文件夹下,从中可以发现解压后目录引用情况:

build 为 react 项目打包后的资源文件 其中包括 static 静态资源

node_modules 则为项目中被引用的所有依赖

6. 打包过程中出现的问题

打包后配置的图标不显示

electron必须使用256*256的图片,所以需要提前准备这样大小的图片。我们需要先准备一张256 * 256的png格式的图片。如果需要ico可以用这个网站生成256 * 256 的ico图片。

ico.116wz.com/

打包后运行白屏、窗口图标错误

该问题主要是打包后获取资源的路径指向问题。

首先需要现将 react 项目打包后再对 electron 进行打包,在 react 打包的过程中 public 的目录结构发生变化,public文件夹会被直接复制一份到打包构建出来的 build 文件中。

所以需要通过判断运行的环境来获取 ico 资源,在开发环境中可以从当前的 public 获取,而打包后的public 资源存在与 build 文件下可直接从 /build/下获取

// 判断运行环境
icon: process.env.NODE_ENV === 'development'? 
  path.join(__dirname, './public/bilibili.ico') 
  :  path.join(__dirname, './build/bilibili.ico')

在安装过程中出现安全拦截

用electron把项目打包成可执行文件后,安装的时候 360安全卫士会报有程序试图修改关键程序DLL文件,定位到d3dcompiler_47.dll 。

google 了一圈还是没能找到拦截原因,那么我去请教一下 ChatGPT 吧 (^-^) ! ! !

解决方式

一: 可以在360 中设置开发者模式,将编译输出路径添加到信任列表

二:再简单不过的就是退出 360 啦。

Electron踩坑实记

1. 不安全的ss内容安全策略(CSP)

如果使用electron的开发人员没有定义Content-Security-Policy,Electron会在DevTool console发出警告

Electron 安全警告(不安全的内容安全策略)此渲染器进程没有设置内容安全策略或启用了“不安全评估”的策略。这会使此应用程序的用户面临不必要的安全风险。

内容安全策略(CSP) 是一个确保内容安全的控制方式,应对跨站脚本攻击(XSS),数据嗅探攻击(Sniffing)和数据注入攻击(Data injection)的一层保护措施。

Electron建议任何载入到Electron的站点都要开启,以确保应用程序的安全。开启 CSP:

  1. 通过HTTP Header 的Content-Security-Policy的字段
Content-Security-Policy: default-src 'self'
  1. 在html的标签添加
<meta http-equiv="Content-Security-Policy" content="default-src 'self';">

default-src 代表默认规则,'self'表示限制所有的外部资源,只允许当前域名加载资源。

在electron中很多时候虽然没有所谓的域名,页面内容都是集成在一起的,但也可以设置为'self'

从而解决警告问题

而 process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true' 只是隐藏警告 而并没有解决本质问题

2. 访问本地资源图片报错 Not allowed to load local resource

访问项目中的图片资源出现报错

其主要原因是 出于安全考虑,electron 随着版本的升级设置了更多安全特性,导致 electron 通过file:/// 访问时不能直接访问本地资源的。

file://出于安全原因,Electron 默认情况下允许渲染进程仅在使用协议从本地源加载 html 文件时访问本地资源。

http://如果您从任何或协议加载 html,https://甚至是从本地服务器(如 webpack-dev-server)加载 html,则禁用对本地资源的访问。

如果您仅在开发期间从本地服务器加载 html 页面并在生产中切换到本地 html 文件,您可以在开发期间禁用网络安全,注意在生产中启用它。

如果您甚至在生产环境中从远程源加载 html,最好和安全的方法是将它上传到您的服务器上的某个位置并加载它。

解决方式

最简单粗暴的方式是直接配置 webSecurity: false, 允许 electron 访问本地资源,但可能会带来一些安全问题

webPreferences: {
  ...
  webSecurity: false
},

3. 在渲染进程中调用 node.js 的模块发生错误

通过 require('path') 直接引入模块发生报错甚至白屏,发生错误的原因是在渲染进程(渲染进程相当于我们的浏览器)不能直接使用这些需要在服务端运行的模块。

在渲染进程 require 关键字就是表示 node 模块的系统,若直接调用将报错:

fs.existsSync is not a function 或者 Uncaught ReferenceError: require is not defined

浏览器中没有集成 node 环境,所以不能调用 node 中的基础包,也无法使用 require关键字

在 nodejs 的原生包调用了很多的底层api,浏览器当然没有给到这个权限,所以,要想在浏览器中使用nodejs的api,需要用下面这种方式引入原生Nodejs包:

const electron = window.require('electron')
const process = window.require('process')
const fs = window.require('fs')
const Https = window.require('https')

通过使用window.require代替require来引入electron,在webpack打包过程中直接使用 require 和 import 的时候,require 会被 webpack 编译,而使用 window.require 不会被编译是因为 window.require 在浏览器中作为一个全局变量被定义、引用,而不是作为一个模块被导入。

electron将nodejs api挂载在了window对象上,为了实现底层间的通信需要调用window上的require函数来引入 nodejs 包。

若在要在 electron 中使用第三方的 nodejs 包,可以通过 preload.js 在渲染进程开始前将第三方模块预先挂载到window上,由此实现在渲染进程中的调用。

4. 打包时出现报错

Package "electron" is only allowed in "devDependencies". Please remove it from the "dependencies" section in your package.json.

electron 一定要安装在 devDependencies 开发环境依赖里面,不然打包会有报错。

提示 electronelectron-builder 应该放在 devDependencies 下面

这也是上面在安装 electronelectron-builder 依赖的时候,提醒的说一定要把这两个安装在开发环境下

解决办法:npm install electron electron-builder --save-dev

参考

  1. Electron 官方文档
  2. node官方文档
  3. react项目改造为electron+react项目
  4. Electron.js + React 实现跨平台方案
  5. 从0到1搭建TS+React环境
  6. react 中引入 electron 出现报错