前言
Electron是一个跨平台创建桌面应用程序的框架,允许我们使用HTML/CSS/JS去创建跨平台桌面应用程序。随着大前端的发展,当我们去开发Web UI时,会习惯性的使用Webpack等构建工具以及React等钱的MVVM框架去辅助开发。在开发Electron时也是同理,因此本文将介绍如何使用Webpack/React去打包构建整个Electron应用,并使用Electron-builder构建出App。其实社区提供了很多Electron Webpack的脚手架和模版,比如
目录
- Electron简介
- Electron安装
- 结构设计
- 使用webpack打包主进程和渲染进程
- 使用electron-builder构建应用
- C++模块支持
- Redux + React-router集成
- Devtron辅助开发工具集成
- 总结
- 参考
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包时出现网络超时(万恶的墙),导致安装不成功。
解决方法自然是使用镜像,这里我们可以打开
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); } |
以及获取下载路径
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_FILENAMEFor 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 # |
对应的主进程
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) |
对应的渲染进程
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> |
向
1 2 3 4 5 6 7 8 | { ..., "main": "index.js", "script": { "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入口中标明,打包时分别打包到
使用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 |
编写对应的
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配置
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 |
编写相应的
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打包配置
主进程打包时只需要将
1 | npm install --save-dev webpack-node-externals |
开发模式下对应的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 | 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配置的
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 |
1 | npm install --save-dev css-loader file-loader sass-loader style-loader url-loader resolve-url-loader |
由于使用scss编写样式,所以需要安装
1 | npm install --save-dev node-sass |
安装
在安装时添加
1 | npm install --save-dev node-sass --sass-binary-site=http://npm.taobao.org/mirrors/node-sass |
对应的生产模式的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 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 ] }); |
到此为止,已经完成了主进程的打包配置和渲染进程生产模式打打包配置,这里可以直接测试项目生产环境的打包结果。
首先向
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 |
可以尝试的写个小例子测试一下打包结果,主进程
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() }) |
渲染进程主页面
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')); |
使用
1 | npm run build |
打包后的结果如下面所示,所以主进程在加载html文件时的路径就是
使用
开发模式
在渲染进程开发模式下需要实现模块热加载,这里使用react-hot-loader包,另外需要起webpack服务的话,还需要安装
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')]; |
修改渲染进程入口文件,即在
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配置
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 } } }); |
向
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文件需要在渲染进程打包完成后才能加载,因此修改
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)); } }, ... |
修改主进程的
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); } |
打包多个窗口,
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页面 }; ... |
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页面 }; ... |
主进程实现对
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(); }; |
主进程接收渲染进程消息,并创建出
1 2 3 | ipcMain.handle('open-user-info-widget', () => { createUserInfoWidget(); }) |
运行结果
使用Electron-builder构建应用
Electron-builder可以理解为一个黑盒子,能够解决Electron项目的各个平台(Mac、Window、Linux)打包和构建并且提供自动更新支持。安装如下,需要注意electron-builder只能安装到
1 | npm install --save-dev electron-builder |
然后在
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" } }, ... } |
并向
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文件。前面在编写
例如一个简单的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) |
执行
1 2 3 4 5 | import { add } from '~build/Release/addon.node'; ipcMain.handle('calc-value', (event, a, b) => { return add(+a, +b); }) |
渲染进程则进行IPC调用发送
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 |
使用
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> ); |
1 2 | const history = useHistory(); const onNext = () => history.push('/page2'); |
Redux部分则可以使用
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(); }); |
另外还可以使用
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文档
electron-builder通用配置
Electron构建跨平台应用Mac/Windows/Linux
将C++代码加载到JavaScript中
项目GitHub地址: https://github.com/sundial-dreams/electron-react-template