使用Webpack/React去打包构建Electron应用

前言

Electron是一个跨平台创建桌面应用程序的框架,允许我们使用HTML/CSS/JS去创建跨平台桌面应用程序。随着大前端的发展,当我们去开发Web UI时,会习惯性的使用Webpack等构建工具以及React等钱的MVVM框架去辅助开发。在开发Electron时也是同理,因此本文将介绍如何使用Webpack/React去打包构建整个Electron应用,并使用Electron-builder构建出App。其实社区提供了很多Electron Webpack的脚手架和模版,比如electron-forgeelectron-react-boilerplate等等,但通过自己的摸索和构建(重复造轮子),能对前端打包构建体系有个更深刻的理解。

目录

  1. Electron简介
  2. Electron安装
  3. 结构设计
  4. 使用webpack打包主进程和渲染进程
  5. 使用electron-builder构建应用
  6. C++模块支持
  7. Redux + React-router集成
  8. Devtron辅助开发工具集成
  9. 总结
  10. 参考

Electron简介

Electron是使用Web前端技术(HTML/CSS/JavaScript/React等)来创建原生跨平台桌面应用程序的框架,它可以认为是Chromium、Node.js、Native APIs的组合。

Chromium由Google开源,相当于Chrome浏览器的精简版,在Electron中负责Web UI的渲染。Chromium可以让开发者在不考虑浏览器兼容性的情况下去编写Web UI代码。

Node.js是一个 JavaScript 运行时,基于事件驱动、非阻塞I/O 模型而得以轻量和高效。在Electron中负责调用系统底层API来操作原生GUI以及主线程JavaScript代码的执行,并且 Node.js中常用的utils、fs等模块在 Electron 中也可以直接使用。

Native APIs是系统提供的GUI功能,比如系统通知、系统菜单、打开系统文件夹对话框等等,Electron通过集成Native APIs来为应用提供操作系统功能支持。

与传统Web网站不同,Electron基于主从进程模型,每个Electron应用程序有且仅有一个主进程(Main Process),和一个或多个渲染进程(Renderer Process),对应多个Web页面。除此之外,还包括GUP进程、扩展进程等其他进程。

主进程负责窗口的创建、进程间通信的协调、事件的注册和分发等。渲染进程负责UI页面的渲染、交互逻辑的实现等。但在这种进程模型下容易产生单点故障问题,即主进程崩溃或者阻塞将会导致整个应用无法响应。

Electron安装

在安装Electron的过程中遇到最大的问题可能就是下载Electron包时出现网络超时(万恶的墙),导致安装不成功。

解决方法自然是使用镜像,这里我们可以打开node_modules/@electron/get/dist/cjs/artifact-utils.js,找到处理镜像的方法mirrorVar

1
2
3
4
5
6
7
8
9
10
function mirrorVar(name, options, defaultValue) {
    // Convert camelCase to camel_case for env var reading
    const lowerName = name.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}_${b}`).toLowerCase();
    return (process.env[`NPM_CONFIG_ELECTRON_${lowerName.toUpperCase()}`] ||
        process.env[`npm_config_electron_${lowerName}`] ||
        process.env[`npm_package_config_electron_${lowerName}`] ||
        process.env[`ELECTRON_${lowerName.toUpperCase()}`] ||
        options[name] ||
        defaultValue);
}

以及获取下载路径getArtifactRemoteURL方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
async function getArtifactRemoteURL(details) {
    const opts = details.mirrorOptions || {};
    let base = mirrorVar('mirror', opts, BASE_URL); // ELECTRON_MIRROR 环境变量
    if (details.version.includes('nightly')) {
        const nightlyDeprecated = mirrorVar('nightly_mirror', opts, '');
        if (nightlyDeprecated) {
            base = nightlyDeprecated;
            console.warn(`nightly_mirror is deprecated, please use nightlyMirror`);
        }
        else {
            base = mirrorVar('nightlyMirror', opts, NIGHTLY_BASE_URL);
        }
    }
    const path = mirrorVar('customDir', opts, details.version).replace('{{ version }}', details.version.replace(/^v/, '')); // ELECTRON_CUSTOM_DIR环境变量,并将{{version}}替换为当前版本
    const file = mirrorVar('customFilename', opts, getArtifactFileName(details));
    // Allow customized download URL resolution.
    if (opts.resolveAssetURL) {
        const url = await opts.resolveAssetURL(details);
        return url;
    }
    return `${base}${path}/${file}`;
}

可以看到可以定义挺多环境变量来指定镜像,比如ELECTRON_MIRROR、ELECTRON_CUSTOM_DIR等等,这其实在官方文档中也有标明

Mirror

You can use environment variables to override the base URL, the path at which to look for Electron binaries, and the binary filename. The URL used by @electron/get is composed as follows:

1
url = ELECTRON_MIRROR + ELECTRON_CUSTOM_DIR + '/' + ELECTRON_CUSTOM_FILENAME

For instance, to usethe China CDN mirror:

1
2
ELECTRON_MIRROR="https://cdn.npm.taobao.org/dist/electron/"
ELECTRON_CUSTOM_DIR="{{ version }}"

因此在下载Electron时只需要添加了两个环境变量即可解决网络超时(墙)的问题

1
ELECTRON_MIRROR="https://cdn.npm.taobao.org/dist/electron/"  ELECTRON_CUSTOM_DIR="{{ version }}" npm install --save-dev electron

安装完electron后,可以尝试写一个最简单的electron应用,项目结构如下

1
2
3
4
project
  |__index.js     # 主进程
  |__index.html   # 渲染进程
  |__package.json #

对应的主进程index.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
const electron = require('electron');
const { app } = electron;

let window = null;

function createWindow() {
  if (window) return;
  window = new electron.BrowserWindow({
    webPreferences: {
      nodeIntegration: true // 允许渲染进程中使用node模块
    },
    backgroundColor: '#333544',
    minWidth: 450,
    minHeight: 350,
    height: 350,
    width: 450
  });
  window.loadFile('./index.html').catch(console.error);
  window.on('close', () => window = null);
  window.webContents.on('crashed', () => console.error('crash'));
}
app.on('ready', () => createWindow());
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', createWindow)

对应的渲染进程index.html部分

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE>
<html lang="zh">
<head><title></title></head>
<style>
    .box {color: white;font-size: 20px;text-align: center;}
</style>
<body>
<div class="box">Hello world</div>
</body>
</html>

package.json中添加运行命令

1
2
3
4
5
6
7
8
{
  ...,
  "main": "index.js",
  "script": {
     "start": "electron ."
  },
  ...
}

npm run start运行,一个最简单的electron应用开发完成。

项目结构

Electron项目通常由主进程和渲染进程组成,主进程用于实现应用后端,一般会使用C++或rust实现核心功能并以Node插件的形式加载到主进程(比如字节跳动的飞书、飞聊的主进程则是使用rust实现),其中的JavaScript部分像一层胶水,用于连接Electron和第三方插件,渲染进程则是实现Web UI的绘制以及一些UI交互逻辑。主进程和渲染进程是独立开发的,进程间使用IPC进行通信,因此对主进程和渲染进程进行分开打包,也就是两套webpack配置,同时为区分开发环境和生产环境,也需要两套webpack配置。此外在开发electron应用时会有多窗口的需求,因此对渲染进程进行多页面打包,整体结构如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
project
  |__src
     |__main                                          # 主进程代码
        |__index.ts
        |__other
     |__renderer                                      # 渲染进程代码
        |__index                                      # 一个窗口/页面
           |__index.tsx
           |__index.scss
        |__other  
  |__dist                                             # webpack打包后产物
  |__native                                           # C++代码
  |__release                                          # electron-builder打包后产物
  |__resources                                        # 资源文件
  |__babel.config.js                                  # babel配置
  |__tsconfig.json                                    # typescript配置
  |__webpack.base.config.js                           # 基础webpack配置
  |__webpack.main.dev.js                              # 主进程开发模式webpack配置
  |__webpack.main.prod.js                             # 主进程生产模式webpack配置
  |__webpack.renderer.dev.js                          # 渲染进程开发模式webpack配置
  |__webpack.renderer.prod.js                         # 渲染进程生产模式webpack配置

打包构建流程其实比较简单,使用webpack分别打包主进程和渲染进程,最后在使用electron-builder对打包后的代码进行打包构建,最后构建出app。

多窗口的处理,在渲染进程下的每一个目录代表一个窗口(页面),并在webpack entry入口中标明,打包时分别打包到dist/${name}目录下,主进程加载时按webpack entry标识的名称进行加载。

使用webpack打包主进程和渲染进程

首先安装webpack

1
npm install --save-dev webpack webpack-cli webpack-merge

安装react

1
npm install --save react react-dom

安装typescript

1
npm install --save-dev typescript

以及安装对应的types包

1
npm install --save-dev @types/node @types/react @types/react-dom @types/electron @types/webpack

编写对应的tsconfig.json

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
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "target": "ES2018",
    "module": "CommonJS",
    "lib": [
      "dom",
      "esnext"
    ],
    "declaration": true,
    "declarationMap": true,
    "jsx": "react",
    "strict": true,
    "pretty": true,
    "sourceMap": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "noImplicitReturns": true,
    "moduleResolution": "Node",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "allowJs": true,
    "resolveJsonModule": true
  },
  "exclude": [
    "node_modules",
    "native",
    "resources"
  ],
  "include": [
    "src/main",
    "src/renderer"
  ]
}

编写基础的webpack配置webpack.base.config.js,主进程和渲染进程都需要用到这个webpack配置

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
const path = require('path');
// 基础的webpack配置
module.exports = {
  module: {
    rules: [
      // ts,tsx,js,jsx处理
      {
        test: /\.[tj]sx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader', // babel-loader处理jsx或tsx文件
          options: { cacheDirectory: true }
        }
      },
      // C++模块 .node文件处理
      {
        test: /\.node$/,
        exclude: /node_modules/,
        use: 'node-loader' // node-loader处理.node文件,用于处理C++模块
      }
    ]
  },
  resolve: {
    extensions: ['.js', '.jsx', '.json', '.ts', '.tsx', '.node'],
    alias: {
      '~native': path.resolve(__dirname, 'native'), // 别名,方便import
      '~resources': path.resolve(__dirname, 'resources') // 别名,方便import
     
    }
  },
  devtool: 'source-map',
  plugins: []
};

安装babel-loader处理jsx或tsx文件,node-loader处理.node文件

1
npm install --save-dev babel-loader node-loader

安装相应的babel插件

1
npm install --save-dev @babel/core @babel/plugin-proposal-class-properties @babel/plugin-proposal-decorators @babel/plugin-proposal-optional-chaining @babel/plugin-syntax-dynamic-import @babel/plugin-transform-react-constant-elements @babel/plugin-transform-react-inline-elements

以及安装babel预设

1
npm install --save-dev @babel/preset-env @babel/preset-react @babel/preset-typescript

编写相应的babel.config.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
const devEnvs = ['development', 'production'];
const devPlugins = []; // TODO 开发模式

const prodPlugins = [ // 生产模式
  require('@babel/plugin-transform-react-constant-elements'),
  require('@babel/plugin-transform-react-inline-elements'),
  require('babel-plugin-transform-react-remove-prop-types')
];

module.exports = api => {
  const development = api.env(devEnvs);

  return {
    presets: [
      [require('@babel/preset-env'), {
        targets: {
          electron: 'v9.0.5' // babel编译目标,electron版本
        }
      }],
      require('@babel/preset-typescript'), // typescript支持
      [require('@babel/preset-react'), {development, throwIfNamespace: false}] // react支持
    ],
    plugins: [
      [require('@babel/plugin-proposal-optional-chaining'), {loose: false}], // 可选链插件
      [require('@babel/plugin-proposal-decorators'), {legacy: true}], // 装饰器插件
      require('@babel/plugin-syntax-dynamic-import'), // 动态导入插件
      require('@babel/plugin-proposal-class-properties'), // 类属性插件
      ...(development ? devPlugins : prodPlugins) // 区分开发环境
    ]
  };
};

主进程webpack打包配置

主进程打包时只需要将src/main下的所有ts文件打包到dist/main下,值得注意的是,主进程对应的是node工程,如果直接使用webpack进行打包会将node_modules中的模块也打包进去,所以这里使用webpack-node-externals插件去排除node_modules模块

1
npm install --save-dev webpack-node-externals

开发模式下对应的webpack配置webpack.main.dev.config.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
const path = require('path');
const webpack = require('webpack');
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const webpackBaseConfig = require('./webpack.base.config');

module.exports = merge.smart(webpackBaseConfig, {
  devtool: 'none',
  mode: 'development', // 开发模式
  target: 'node',
  entry: path.join(__dirname, 'src/main/index.ts'),
  output: {
    path: path.join(__dirname, 'dist/main'),
    filename: 'main.dev.js' // 开发模式文件名为main.dev.js
  },
  externals: [nodeExternals()], // 排除Node模块
  plugins: [
    new webpack.EnvironmentPlugin({
      NODE_ENV: 'development'
    })
  ],
  node: {
    __dirname: false,
    __filename: false
  }
});

生产模式与开发模式类似,因此对应webpack配置的webpack.main.prod.config.js如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const path = require('path');
const merge = require('webpack-merge');
const webpack = require('webpack');
const webpackDevConfig = require('./webpack.main.dev.config');

module.exports = merge.smart(webpackDevConfig, {
  devtool: 'none',
  mode: 'production', // 生产模式
  output: {
    path: path.join(__dirname, 'dist/main'),
    filename: 'main.prod.js' // 生产模式文件名为main.prod.js
  },
  plugins: [
    new webpack.EnvironmentPlugin({
      NODE_ENV: 'production'
    })
  ]
});

渲染进程打包配置

渲染进程的打包就是正常前端项目的打包流程,考虑到electron项目有多窗口的需求,所以对渲染进程进行多页面打包,渲染进程打包后的结构如下

1
2
3
4
5
6
7
8
9
10
dist
  |__renderer # 渲染进程
     |__page1 # 页面1
        |__index.html
        |__index.prod.js
        |__index.style.css
     |__page2 # 页面2
        |__index.html
        |__index.prod.js
        |__index.style.css
生产模式

先来看生产模式下的打包,安装相应的插件和loader,这里使用html-webpack-plugin插件去生成html模版,而且需要对每一个页面生成一个.html文件

1
npm install --save-dev mini-css-extract-plugin html-webpack-plugin

css-loadersass-loaderstyle-loader处理样式,url-loaderfile-loader处理图片和字体,resolve-url-loader处理scss文件url()中的相对路径问题

1
npm install --save-dev css-loader file-loader sass-loader style-loader url-loader resolve-url-loader

由于使用scss编写样式,所以需要安装node-sass

1
npm install --save-dev node-sass

安装node-sass其实存在挺多坑的,正常安装经常会碰到下载网络超时的问题(又是墙惹的祸),一般解决就是靠镜像。

在安装时添加--sass-binary-site参数,如下

1
npm install --save-dev node-sass --sass-binary-site=http://npm.taobao.org/mirrors/node-sass

对应的生产模式的webpack配置webpack.renderer.prod.config.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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
// 渲染进程prod环境webpack配置
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const merge = require('webpack-merge');
const webpackBaseConfig = require('./webpack.base.config');

const entry = {
  index: path.join(__dirname, 'src/renderer/index/index.tsx'), // 页面入口
};
// 对每一个入口生成一个.html文件
const htmlWebpackPlugin = Object.keys(entry).map(name => new HtmlWebpackPlugin({
  inject: 'body',
  scriptLoading: 'defer',
  template: path.join(__dirname, 'resources/template/template.html'), // template.html是一个很简单的html模版
  minify: false,
  filename: `${name}/index.html`,
  chunks: [name]
}));

module.exports = merge.smart(webpackBaseConfig, {
  devtool: 'none',
  mode: 'production',
  target: 'electron-preload',
  entry
  output: {
    path: path.join(__dirname, 'dist/renderer/'),
    publicPath: '../',
    filename: '[name]/index.prod.js' // 输出则是每一个入口对应一个文件夹
  },
  module: {
    rules: [ // 文件处理规则
      // 处理全局.css文件
      {
        test: /\.global\.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
            options: { publicPath: './' }
          },
          {
            loader: 'css-loader',
            options: { sourceMap: true }
          },
          {loader: 'resolve-url-loader'}, // 解决样式文件中的相对路径问题
        ]
      },
      // 一般样式文件,使用css模块
      {
        test: /^((?!\.global).)*\.css$/,
        use: [
          { loader: MiniCssExtractPlugin.loader },
          {
            loader: 'css-loader',
            options: {
              modules: { localIdentName: '[name]__[local]__[hash:base64:5]' },
              sourceMap: true
            }
          },
          {loader: 'resolve-url-loader'},
        ]
      },
      // 处理scss全局样式
      {
        test: /\.global\.(scss|sass)$/,
        use: [
          { loader: MiniCssExtractPlugin.loader },
          {
            loader: 'css-loader',
            options: { sourceMap: true, importLoaders: 1 }
          },
          {loader: 'resolve-url-loader'},
          {
            loader: 'sass-loader',
            options: { sourceMap: true }
          }
        ]
      },
      // 处理一般sass样式,依然使用css模块
      {
        test: /^((?!\.global).)*\.(scss|sass)$/,
        use: [
          { loader: MiniCssExtractPlugin.loader },
          {
            loader: 'css-loader',
            options: {
              modules: { localIdentName: '[name]__[local]__[hash:base64:5]' },
              importLoaders: 1,
              sourceMap: true
            }
          },
          {loader: 'resolve-url-loader'},
          {
            loader: 'sass-loader',
            options: { sourceMap: true }
          }
        ]
      },
      // 处理字体文件 WOFF
      {
        test: /\.woff(\?v=\d+\.\d+\.\d+)?$/,
        use: {
          loader: 'url-loader',
          options: { limit: 10000, mimetype: 'application/font-woff' }
        }
      },
      // 处理字体文件 WOFF2
      {
        test: /\.woff2(\?v=\d+\.\d+\.\d+)?$/,
        use: {
          loader: 'url-loader',
          options: { limit: 10000, mimetype: 'application/font-woff' }
        }
      },
      // 处理字体文件 TTF
      {
        test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/,
        use: {
          loader: 'url-loader',
          options: {  limit: 10000, mimetype: 'application/octet-stream' }
        }
      },
      // 处理字体文件 EOT
      {
        test: /\.eot(\?v=\d+\.\d+\.\d+)?$/,
        use: 'file-loader'
      },
      // 处理svg文件 SVG
      {
        test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,
        use: {
          loader: 'url-loader',
          options: { limit: 10000, mimetype: 'image/svg+xml' }
        }
      },
      // 处理图片
      {
        test: /\.(?:ico|gif|png|jpg|jpeg|webp)$/,
        use: {
          loader: 'url-loader',
          options: { limit: 5000 }
        }
      }
    ]
  },
  plugins: [
    new webpack.EnvironmentPlugin({
      NODE_ENV: 'production'
    }),
    new MiniCssExtractPlugin({
      filename: '[name]/index.style.css',
      publicPath: '../'
    }),
    ...htmlWebpackPlugin
  ]
});

到此为止,已经完成了主进程的打包配置和渲染进程生产模式打打包配置,这里可以直接测试项目生产环境的打包结果。

首先向package.json中添加相应的运行命令,build-main打包主进程,build-renderer打包渲染进程,build主进程和渲染进程并行打包,start-main运行Electron项目

1
2
3
4
5
6
7
8
9
10
11
{
  ...
  "main": "dist/main/main.prod.js",
  "scripts": {
    "build-main": "cross-env NODE_ENV=production webpack --config webpack.main.prod.config.js",
    "build-renderer": "cross-env NODE_ENV=production webpack --config webpack.renderer.prod.config.js",
    "build": "concurrently "npm run build-main" "npm run build-renderer"",
    "start-main": "electron ./dist/main/main.prod.js"
  },
  ...
}

在编写脚本中使用到了cross-env,顾名思义,提供跨平台的环境变量支持,而concurrently用于并行运行命令,安装如下

1
npm install --save-dev cross-env concurrently

可以尝试的写个小例子测试一下打包结果,主进程src/main/index.ts

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
import { BrowserWindow, app } from 'electron';
import path from "path";
// 加载html,目前只对生产模式进行加载
function loadHtml(window: BrowserWindow, name: string) {
  if (process.env.NODE_ENV === 'production') {
    window.loadFile(path.resolve(__dirname, `../renderer/${name}/index.html`)).catch(console.error);
    return;
  }
  // TODO development
}

let mainWindow: BrowserWindow | null = null;
// 创建窗口
function createMainWindow() {
  if (mainWindow) return;
  mainWindow = new BrowserWindow({
    webPreferences: {
      nodeIntegration: true
    },
    backgroundColor: '#333544',
    minWidth: 450,
    minHeight: 350,
    width: 450,
    height: 350
  });
  loadHtml(mainWindow, 'index');
  mainWindow.on('close', () => mainWindow = null);
  mainWindow.webContents.on('crashed', () => console.error('crash'));
}
app.on('ready', () => { createMainWindow() });
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});
app.on('activate', () => { createMainWindow() })

渲染进程主页面src/renderer/index/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from 'react';
import ReactDOM from 'react-dom';

// @ts-ignore
import style from './index.scss'; // typescript不支持css模块,所以这么写编译器会不识别,建议加个@ts-ignore

function App() {
  return (
    <div className={style.app}>
      <h3>Hello world</h3>
      <button>+ Import</button>
    </div>
  )
}

ReactDOM.render(<App/>, document.getElementById('app'));

使用build命令并行打包主进程和渲染进程代码

1
npm run build

打包后的结果如下面所示,所以主进程在加载html文件时的路径就是../renderer/${name}/index.html

使用npm run start-main命令运行项目。

开发模式

在渲染进程开发模式下需要实现模块热加载,这里使用react-hot-loader包,另外需要起webpack服务的话,还需要安装webpack-dev-server包。

1
2
npm install --save-dev webpack-dev-server
npm install --save react-hot-loader @hot-loader/react-dom

修改babel配置,开发环境下添加如下插件

1
const devPlugins = [require('react-hot-loader/babel')];

修改渲染进程入口文件,即在render时判断当前环境并包裹ReactHotContainer

1
2
3
4
5
6
7
8
9
10
import { AppContainer as ReactHotContainer } from 'react-hot-loader';

const AppContainer = process.env.NODE_ENV === 'development' ? ReactHotContainer : Fragment;

ReactDOM.render(
  <AppContainer>
      <App/>
  </AppContainer>,
  document.getElementById('app')
);

对应的开发模式的webpack配置webpack.renderer.prod.config.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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
// 渲染进程dev环境下的webpack配置
const path = require('path');
const webpack = require('webpack');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const {spawn} = require('child_process');
const webpackBaseConfig = require('./webpack.base.config');

const port = process.env.PORT || 8080;
const publicPath = `http://localhost:${port}/dist`;

const hot = [
  'react-hot-loader/patch',
  `webpack-dev-server/client?http://localhost:${port}/`,
  'webpack/hot/only-dev-server',
];

const entry = {
  index: hot.concat(require.resolve('./src/renderer/index/index.tsx')),
};
// 生成html模版
const htmlWebpackPlugin = Object.keys(entry).map(name => new HtmlWebpackPlugin({
  inject: 'body',
  scriptLoading: 'defer',
  template: path.join(__dirname, 'resources/template/template.html'),
  minify: false,
  filename: `${name}.html`,
  chunks: [name]
}));

module.exports = merge.smart(webpackBaseConfig, {
  devtool: 'inline-source-map',
  mode: 'development',
  target: 'electron-renderer',
  entry,
  resolve: {
    alias: {
      'react-dom': '@hot-loader/react-dom' // 开发模式下
    }
  },

  output: { publicPath, filename: '[name].dev.js' },

  module: {
    rules: [
      // 处理全局css样式
      {
        test: /\.global\.css$/,
        use: [
          {loader: 'style-loader'},
          {
            loader: 'css-loader',
            options: {sourceMap: true}
          },
          {loader: 'resolve-url-loader'},
        ]
      },
      // 处理css样式,使用css模块
      {
        test: /^((?!\.global).)*\.css$/,
        use: [
          {loader: 'style-loader'},
          {
            loader: 'css-loader',
            options: {
              modules: { localIdentName: '[name]__[local]__[hash:base64:5]' },
              sourceMap: true,
              importLoaders: 1
            }
          },
          {loader: 'resolve-url-loader'}
        ]
      },
      // 处理全局scss样式
      {
        test: /\.global\.(scss|sass)$/,
        use: [
          {loader: 'style-loader'},
          {
            loader: 'css-loader',
            options: {sourceMap: true}
          },
          {loader: 'resolve-url-loader'},
          {loader: 'sass-loader'}
        ]
      },
      // 处理scss样式,使用css模块
      {
        test: /^((?!\.global).)*\.(scss|sass)$/,
        use: [
          {loader: 'style-loader'},
          {
            loader: 'css-loader',
            options: {
              modules: { localIdentName: '[name]__[local]__[hash:base64:5]' },
              sourceMap: true,
              importLoaders: 1
            }
          },
          {loader: 'resolve-url-loader'},
          {loader: 'sass-loader'}
        ]
      },
      // 处理图片
      {
        test: /\.(?:ico|gif|png|jpg|jpeg|webp)$/,
        use: {
          loader: 'url-loader',
          options: { limit: 5000 }
        }
      },
      // 处理字体 WOFF
      {
        test: /\.woff(\?v=\d+\.\d+\/\d+)?$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 5000,
            mimetype: 'application/font-woff'
          }
        }
      },
      // 处理字体 WOFF2
      {
        test: /\.woff2(\?v=\d+\.\d+\.\d+)?$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 5000,
            mimetype: 'application/font-woff'
          }
        }
      },
      // 处理字体 TTF
      {
        test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 5000,
            mimetype: 'application/octet-stream'
          }
        }
      },
      // 处理字体 EOT
      {
        test: /\.eot(\?v=\d+\.\d+\.\d+)?$/,
        use: 'file-loader'
      },
      // 处理SVG
      {
        test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 5000,
            mimetype: 'image/svg+xml'
          }
        }
      }
    ]
  },

  plugins: [
    // webpack 模块热重载
    new webpack.HotModuleReplacementPlugin({
      multiStep: false
    }),
    new webpack.EnvironmentPlugin({
      NODE_ENV: 'development'
    }),
    new webpack.LoaderOptionsPlugin({
      debug: true
    }),
    ...htmlWebpackPlugin
  ],
  // webpack服务,打包后的页面路径为http://localhost:${port}/dist/${name}.html
  devServer: {
    port,
    publicPath,
    compress: true,
    noInfo: false,
    stats: 'errors-only',
    inline: true,
    lazy: false,
    hot: true,
    headers: {'Access-Control-Allow-Origin': '*'},
    contentBase: path.join(__dirname, 'dist'),
    watchOptions: {
      aggregateTimeout: 300,
      ignored: /node_modules/,
      poll: 100
    },
    historyApiFallback: {
      verbose: true,
      disableDotRule: false
    }
  }
});

package.json中添加运行命令,dev-main开发模式下打包主进程并运行Electron项目,dev-renderer开发模式下打包渲染进程

1
2
3
4
5
6
7
8
9
10
{
  ...,
  "start": {
     ...,
     "dev-main": "cross-env NODE_ENV=development webpack --config webpack.main.dev.config.js && electron ./dist/main/main.dev.js",
    "dev-renderer": "cross-env NODE_ENV=development webpack-dev-server --config webpack.renderer.dev.config.js",
    "dev": "npm run dev-renderer"
  },
  ...
}

在这里渲染进程可以通过模块热加载更新代码,但主进程不可以,并且主进程加载的.html文件需要在渲染进程打包完成后才能加载,因此修改webpack.renderer.dev.config.js配置,添加打包完渲染进程后对主进程进行打包并运行的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...,
devServer: {
  before() {
      // 启动渲染进程后执行主进程打包
      console.log('start main process...');
      spawn('npm', ['run', 'dev-main'], { // 相当于命令行执行npm run dev-main
        shell: true,
        env: process.env,
        stdio: 'inherit'
      }).on('close', code => process.exit(code))
        .on('error', spawnError => console.error(spawnError));
    }
},
...

修改主进程的loadHtml函数,开发模式通过url来加载对应的页面

1
2
3
4
5
6
7
8
function loadHtml(window: BrowserWindow, name: string) {
  if (process.env.NODE_ENV === 'production') {
    window.loadFile(path.resolve(__dirname, `../renderer/${name}/index.html`)).catch(console.error);
    return;
  }
  // 开发模式
  window.loadURL(`http://localhost:8080/dist/${name}.html`).catch(console.error);
}

npm run dev开发模式下运行如下

打包多个窗口,renderer目录下新建userInfo目录表示用户信息窗口, 并添加到开发模式和生产模式下的配置文件中,即webpack.renderer.dev.config.jswebpack.renderer.prod.config的entry入口中。

webpack.renderer.dev.config.js部分

1
2
3
4
5
6
...
const entry = {
  index: hot.concat(require.resolve('./src/renderer/index/index.tsx')), // 主页面
  userInfo: hot.concat(require.resolve('./src/renderer/userInfo/index.tsx')) // userInfo页面
};
...

webpack.renderer.prod.config.js部分

1
2
3
4
5
6
...
const entry = {
  index: path.join(__dirname, 'src/renderer/index/index.tsx'), // 主页面
  userInfo: path.join(__dirname, 'src/renderer/userInfo/index.tsx') // userInfo页面
};
...

主进程实现对userInfo窗口的创建逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function createUserInfoWidget() {
  if (userInfoWidget) return;
  if (!mainWindow) return;
  userInfoWidget = new BrowserWindow({
    parent: mainWindow,
    webPreferences: { nodeIntegration: true },
    backgroundColor: '#333544',
    minWidth: 250,
    minHeight: 300,
    height: 300,
    width: 250
  });
  loadHtml(userInfoWidget, 'userInfo');
  userInfoWidget.on('close', () => userInfoWidget = null);
  userInfoWidget.webContents.on('crashed', () => console.error('crash'));
}

主窗口渲染进程使用IPC与主进程进行通信,发送打开用户信息窗口消息

1
const onOpen = () => { ipcRenderer.invoke('open-user-info-widget').catch(); };

主进程接收渲染进程消息,并创建出userInfo窗口

1
2
3
ipcMain.handle('open-user-info-widget', () => {
  createUserInfoWidget();
})

运行结果

使用Electron-builder构建应用

Electron-builder可以理解为一个黑盒子,能够解决Electron项目的各个平台(Mac、Window、Linux)打包和构建并且提供自动更新支持。安装如下,需要注意electron-builder只能安装到devDependencies

1
npm install --save-dev electron-builder

然后在package.json中添加build字段,build字段配置参考:build字段通用配置

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
{
  ...,
  "build": {
    "productName": "Electron App",
    "appId": "electron.app",
    "files": [
      "dist/",
      "node_modules/",
      "resources/",
      "native/",
      "package.json"
    ],
    "mac": {
      "category": "public.app-category.developer-tools",
      "target": "dmg",
      "icon": "./resources/icons/app.icns"
    },
    "dmg": {
      "backgroundColor": "#ffffff",
      "icon": "./resources/icons/app.icns",
      "iconSize": 80,
      "title": "Electron App"
    },
    "win": {
      "target": [ "nsis", "msi" ]
    },
    "linux": {
      "icon": "./resources/icons/app.png",
      "target": [ "deb", "rpm", "AppImage" ],
      "category": "Development"
    },
    "directories": {
      "buildResources": "./resources/icons",
      "output": "release"
    }
  },
  ...
}

并向package.json中添加运行命令,package打包多个平台,package-mac构建Mac平台包,package-win构建window平台包,package-linux构建linux平台包

1
2
3
4
5
6
7
8
9
10
{
  ...,
  "script": {
    "package": "npm run build && electron-builder build --publish never",
    "package-win": "npm run build && electron-builder build --win --x64",
    "package-linux": "npm run build && electron-builder build --linux",
    "package-mac": "npm run build && electron-builder build --mac"
   }
  ...
}

在执行打包时,electron-builder会去下载electron包,正常下载会出现超时(墙又来惹祸了),导致打包不成功,解决方法依然是使用镜像

1
 ELECTRON_MIRROR=https://npm.taobao.org/mirrors/electron/ npm run package-mac


构建完成后,可以在release目录下看到打包出来的结果,Mac下为.dmg文件

在mac上双击安装即可

C++模块支持

说到electron应用,可能会需要C++模块支持,比如部分函数使用C++实现,或者调用已有的C++库或dll文件。前面在编写webpack.base.config.js配置时使用node-loader去处理.node文件,但在Electron下编写C++插件时,需要注意Electron提供的V8引擎可能与本地安装的Node提供的V8引擎版本不一致,导致编译时出现版本不匹配问题,因此在开发原生C++模块时可能需要手动编译Electron模块以适应当前Node的V8版本。另一种方法则是使用node-addon-api包或者Nan包去编写原生C++模块自动去适应Electron中的V8版本,关于Node C++模块可以参考文章:将C++代码加载到JavaScript中。

例如一个简单的C++加法计算模块,C++部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <node_api.h>
#include <napi.h>
using namespace Napi;
Number Add(const CallbackInfo& info) {
    Number a = info[0].As<Number>();
    Number b = info[1].As<Number>();
    double r = a.DoubleValue() + b.DoubleValue();
    return Number::New(info.Env(), r);
}
Object Init(Env env, Object exports) {
    exports.Set("add", Function::New(env, Add));
    return exports;
}
NODE_API_MODULE(addon, Init)

执行node-gyp rebuild构建.node文件,主进程在加载.node文件,并注册一个IPC调用

1
2
3
4
5
import { add } from '~build/Release/addon.node';

ipcMain.handle('calc-value', (event, a, b) => {
  return add(+a, +b);
})

渲染进程则进行IPC调用发送calc-value消息得到结果,并渲染到页面中

1
2
3
4
5
const onCalc = () => {
    ipcRenderer.invoke('calc-value', input.a, input.b).then(value => {
      setResult(value);
    });
};

运行结果

Redux + React-Router集成

到此为止,项目结构已经基本搭建完毕,剩下的则是添加一些基础的状态库或者路由处理库,项目中使用Redux管理状态,React-Router处理路由,安装如下

1
2
npm install --save redux react-redux react-router react-router-dom history
npm install --save-dev @types/redux @types/react-redux @types/react-router @types/react-router-dom @types/history

使用HashRouter作为基础的路由模式

1
2
3
4
5
6
7
8
9
10
11
12
const router = (
  <HashRouter>
    <Switch>
      <Route path="/" exact>
        <Page1/>
      </Route>
      <Route path="/page2">
        <Page2/>
      </Route>
    </Switch>
  </HashRouter>
);

react-router-dom提供了useHistoryHooks方便获取history执行路由相关操作,比如跳转到某个路由页面

1
2
const history = useHistory();
const onNext = () => history.push('/page2');

Redux部分则可以使用useSelectoruseDispatchHooks,直接选择store中的state和连接dispatch,避免使用connect高阶组件造成的冗余代码问题

1
2
3
4
const count = useSelector((state: IStoreState) => state.count);
const dispatch = useDispatch();
const onAdd = () => dispatch({ type: ActionType.ADD });
const onSub = () => dispatch({ type: ActionType.SUB });

运行结果

Devtron辅助开发工具集成

Devtron是一个Electorn调试工具,方便检查,监视和调试应用。可以可视化主进程和渲染进程中的包依赖、追踪和检查主进程和渲染进程互相发送的消息、显示注册的事件和监听器、检查app中可能存在的问题等。

安装方式

1
npm install --save-dev devtron

使用方式如下

1
2
3
app.whenReady().then(() => {
  require('devtron').install();
});

另外还可以使用electron-devtools-installer,用于安装Devtools扩展,比如浏览器上常用的Redux、React扩展等,它会自动的去Chrome应用商店下载Chrome扩展并安装,不过由于墙的原因,大概率会下载不了(万恶的墙又来惹祸了)

1
npm install --save-dev electron-devtools-installer @types/electron-devtools-installer

使用方式

1
2
3
4
5
6
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS, REACT_PERF } from 'electron-devtools-installer';

app.whenReady().then(() => {
  installExtension([REACT_PERF, REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS]).then(() => {});
  require('devtron').install();
});

总结

早期在接触electron时直接使用现成的react模版进行开发,但一味的使用社区模版,出现问题时难以查找,而且社区模版提供的功能也不一定符合自己的需求,虽然是重复造轮子,但在造轮子过程中也能学到不少东西。项目借鉴了electron-react-bolierplate的打包模式,对部分地方进行优化调整,添加了一些相应的功能。后续的TODO则是考了对渲染进程和主进程进行包拆分优化以及结构上的优化调整。

参考

Electron文档

electron-builder通用配置

Electron构建跨平台应用Mac/Windows/Linux

将C++代码加载到JavaScript中

项目GitHub地址: https://github.com/sundial-dreams/electron-react-template