使用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

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方法

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:

url = ELECTRON_MIRROR + ELECTRON_CUSTOM_DIR + '/' + ELECTRON_CUSTOM_FILENAME

For instance, to usethe China CDN mirror:

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

因此在下載Electron時只需要添加了兩個環境變量即可解決網絡超時(牆)的問題

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

安裝完electron後,可以嘗試寫一個最簡單的electron應用,項目結構如下

project
  |__index.js     # 主進程
  |__index.html   # 渲染進程
  |__package.json # 

對應的主進程index.js部分

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部分

<!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中添加運行命令

{
  ...,
  "main": "index.js",
  "script": {
     "start": "electron ."
  },
  ...
}

npm run start運行,一個最簡單的electron應用開發完成。

項目結構

Electron項目通常由主進程和渲染進程組成,主進程用於實現應用後端,一般會使用C++或rust實現核心功能並以Node插件的形式加載到主進程(比如字節跳動的飛書、飛聊的主進程則是使用rust實現),其中的JavaScript部分像一層膠水,用於連接Electron和第三方插件,渲染進程則是實現Web UI的繪製以及一些UI交互邏輯。主進程和渲染進程是獨立開發的,進程間使用IPC進行通信,因此對主進程和渲染進程進行分開打包,也就是兩套webpack配置,同時爲區分開發環境和生產環境,也需要兩套webpack配置。此外在開發electron應用時會有多窗口的需求,因此對渲染進程進行多頁面打包,整體結構如下。

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

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

安裝react

npm install --save react react-dom

安裝typescript

npm install --save-dev typescript

以及安裝對應的types包

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

編寫對應的tsconfig.json

{
  "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配置

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文件

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

安裝相應的babel插件

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預設

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

編寫相應的babel.config.js配置,配置中需要對開發模式和生產模式下的代碼分開處理,即使用不同的插件

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模塊

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

開發模式下對應的webpack配置webpack.main.dev.config.js如下

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如下

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項目有多窗口的需求,所以對渲染進程進行多頁面打包,渲染進程打包後的結構如下

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文件

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

css-loadersass-loaderstyle-loader處理樣式,url-loaderfile-loader處理圖片和字體,resolve-url-loader處理scss文件url()中的相對路徑問題

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

由於使用scss編寫樣式,所以需要安裝node-sass

npm install --save-dev node-sass

安裝node-sass其實存在挺多坑的,正常安裝經常會碰到下載網絡超時的問題(又是牆惹的禍),一般解決就是靠鏡像。

在安裝時添加--sass-binary-site參數,如下

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

對應的生產模式的webpack配置webpack.renderer.prod.config.js如下

// 渲染進程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項目

{
  ...
  "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用於並行運行命令,安裝如下

npm install --save-dev cross-env concurrently

可以嘗試的寫個小例子測試一下打包結果,主進程src/main/index.ts

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

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命令並行打包主進程和渲染進程代碼

npm run build

打包後的結果如下面所示,所以主進程在加載html文件時的路徑就是../renderer/${name}/index.html

使用npm run start-main命令運行項目。

開發模式

在渲染進程開發模式下需要實現模塊熱加載,這裏使用react-hot-loader包,另外需要起webpack服務的話,還需要安裝webpack-dev-server包。

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

修改babel配置,開發環境下添加如下插件

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

修改渲染進程入口文件,即在render時判斷當前環境幷包裹ReactHotContainer

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

// 渲染進程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開發模式下打包渲染進程

{
  ...,
  "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配置,添加打包完渲染進程後對主進程進行打包並運行的邏輯

...,
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來加載對應的頁面

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部分

...
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部分

...
const entry = {
  index: path.join(__dirname, 'src/renderer/index/index.tsx'), // 主頁面
  userInfo: path.join(__dirname, 'src/renderer/userInfo/index.tsx') // userInfo頁面
};
...

主進程實現對userInfo窗口的創建邏輯

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與主進程進行通信,發送打開用戶信息窗口消息

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

主進程接收渲染進程消息,並創建出userInfo窗口

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

運行結果

使用Electron-builder構建應用

Electron-builder可以理解爲一個黑盒子,能夠解決Electron項目的各個平臺(Mac、Window、Linux)打包和構建並且提供自動更新支持。安裝如下,需要注意electron-builder只能安裝到devDependencies

npm install --save-dev electron-builder

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

{
  ...,
  "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平臺包

{
  ...,
  "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包,正常下載會出現超時(牆又來惹禍了),導致打包不成功,解決方法依然是使用鏡像

 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++部分

#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調用

import { add } from '~build/Release/addon.node';

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

渲染進程則進行IPC調用發送calc-value消息得到結果,並渲染到頁面中

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

運行結果

Redux + React-Router集成

到此爲止,項目結構已經基本搭建完畢,剩下的則是添加一些基礎的狀態庫或者路由處理庫,項目中使用Redux管理狀態,React-Router處理路由,安裝如下

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作爲基礎的路由模式

const router = (
  <HashRouter>
    <Switch>
      <Route path="/" exact>
        <Page1/>
      </Route>
      <Route path="/page2">
        <Page2/>
      </Route>
    </Switch>
  </HashRouter>
);

react-router-dom提供了useHistoryHooks方便獲取history執行路由相關操作,比如跳轉到某個路由頁面

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

Redux部分則可以使用useSelectoruseDispatchHooks,直接選擇store中的state和連接dispatch,避免使用connect高階組件造成的冗餘代碼問題

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中可能存在的問題等。

安裝方式

npm install --save-dev devtron

使用方式如下

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

另外還可以使用electron-devtools-installer,用於安裝Devtools擴展,比如瀏覽器上常用的Redux、React擴展等,它會自動的去Chrome應用商店下載Chrome擴展並安裝,不過由於牆的原因,大概率會下載不了(萬惡的牆又來惹禍了)

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

使用方式

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章