electron アプリを npm workspaces と Vite でビルドするときにやったこと

あるアプリの開発でいままで一つのパッケージに electron 用のコードとブラウザ用のコードを同居させていましたが、不要なパッケージが含まれたり、どっちに依存しているか分かりづらいなどの問題がありました。そこで npm workspaces でそれぞれをパッケージに分割したモノレポ構成へと変更し、また create-react-app を使った Webpack でのビルドからより高速な Vite への変更を行いました。

改善前

.
├── public
│   └── index.html
├── src
│   └── index.tsx
├── electron
│   ├── index.ts
│   ├── preload.ts
│   └── tsconfig.json
├── tsconfig.json
├── package.json
└── jest.config.js

改善後

.
├── packages
│   ├── main
│   │   ├── src
│   │   │   ├── index.ts
│   │   │   └── preload.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── renderer
│   │   ├── src
│   │   │   └── main.tsx
│   │   ├── index.html
│   │   ├── jest.config.js
│   │   ├── tsconfig.json
│   │   ├── tsconfig.node.json
│   │   └── vite.config.json
├── .npmrc
└── package.json

packages/main には electron 関係のスクリプトを配置します。またいままでの package.json をここに移動し、electron 側に必要ない依存などを取り除きました。

packages/renderer は npm create vite@latest コマンドで生成された構成を元に、必要な依存などを追加しました。

改善後の package.json

新しく作成します。 各パッケージにスクリプトが記述されるようになったので、それぞれを立ち上げるだけの非常にシンプルなスクリプトになりました。

{
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "scripts": {
    "start": "concurrently \"npm run dev -w renderer\" \"npm start -w main\"",
    "test": "npm run test -w renderer",
    "publish": "cross-env CI=false npm run build --workspaces && npm run publish -w main"
  },
  "devDependencies": {
    "concurrently": "^8.2.0",
    "cross-env": "^7.0.3"
  }
}

改善後の electron 側の package.json

{
  "name": "main",
  "private": true,
  "productName": "example",
  "version": "0.0.1",
  "main": "dist/index.js",
  "scripts": {
    "start": "npm run build && electron-forge start",
    "build": "tsc",
    "package": "electron-forge package",
    "make": "electron-forge make",
    "publish": "electron-forge publish"
  },
  "license": "MIT",
  "config": {
    "forge": {
      "packagerConfig": {
        "ignore": [
          "^/src",
          "^/README.md",
          "^/tsconfig.json",
          "node_modules/electron$",
          "node_modules/@electron-forge$"
        ],
        "prune": false
      },
      "makers": [
        {
          "name": "@electron-forge/maker-zip",
          "platforms": [
            "darwin",
            "win32"
          ]
        }
      ]
    }
  },
  "devDependencies": {
    "@electron-forge/cli": "6.3.0",
    "@electron-forge/maker-deb": "6.3.0",
    "@electron-forge/maker-rpm": "6.3.0",
    "@electron-forge/maker-squirrel": "6.3.0",
    "@electron-forge/maker-zip": "6.3.0",
    "electron": "^25.5.0",
    "typescript": "5.1.6"
  },
  "dependencies": {
    "electron-is-dev": "2.0.0",
    "electron-log": "^4.4.8"
  }
}

node_modules インストール先の修正

npm workspaces を利用すると、各パッケージで重複する依存パッケージがプロジェクトのルートディレクトリの node_modules にインストールされます。electron 側では tsc を使ってビルドしていたため、依存パッケージがバンドルされません。そのため動作に必要なパッケージは node_modules にそのまま入っている必要があります。そこで従来のように依存パッケージが各パッケージのディレクトリにインストールされるように調整しました。

プロジェクトのルートディレクトリに .npmrc を作成し、下記を記述します。

install-strategy = "nested"

electron-forge のパッケージができない問題の対応

electron-forge はパッケージ時に dependencies にあるパッケージだけをコピーし、devDependencies にあるパッケージを取り除くようになっています。しかし該当の package.json があるディレクトリの node_modules からパッケージを探す処理になっているため、npm workspaces を利用してパッケージが親ディレクトリにインストールされた状態だとエラーが発生します。

そこで electron-forge のパッケージ時に devDependencies から取り除く処理を行わせないようにします。また、実行に必要がないパッケージは手動で ignore に記述して取り除くようにします。

  "config": {
    "forge": {
      "packagerConfig": {
        "ignore": [
+          "node_modules/electron$",
+          "node_modules/@electron-forge$"
        ],
+        "prune": false

Vite.js でビルドできない問題の対応

利用していた json2csv というパッケージがデフォルトでは esm 版が読み込まれるのですが TypeScript のビルドに失敗する問題がありました。そこで同梱されている umd 版を読み込むように vite.config.tsalias を設定しました。

+    alias: {
+      json2csv: path.resolve(
+        __dirname,
+        "node_modules/json2csv/dist/json2csv.umd.js" // ESM 版の json2csv が動かないので UMD 版を使う
+      ),
+    },

付録: 開発中は localhost を読ませる

electron-is-dev を使って開発中か判別し、開発中なら Vite.js のサーバ http://localhost:5173/ を開き、そうでなければアプリパッケージにバンドルされた index.html を開くようにします。

import { app, BrowserWindow, ipcMain, Menu } from "electron"
import isDev from "electron-is-dev"
import log from "electron-log"
import fs from "fs"
import os from "os"
import path from "path"

const createWindow = (): void => {
  const mainWindow = new BrowserWindow({
    width: 900,
    height: 700,
    title: `example`,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, "preload.js"),
    },
  })

  mainWindow.maximize()

  if (isDev) {
    mainWindow.loadURL("http://localhost:5173/")
    mainWindow.webContents.openDevTools()
  } else {
    mainWindow.loadFile(path.join(__dirname, "../build/index.html"))
  }

  Menu.setApplicationMenu(null)
}