Electron + ESM + TypeScript + webpack + electron-builder

プログラミング

Electron v28.0.0 より ESM【ESModules】がサポートされましたので、ESM 化された Electron アプリケーションを electron-builder でビルドして配布するまでの手順を解説したいと思います。

参考: Electron Releases – v28.0.0


CommonJS から ESModules へ
Native ESM 方式では、Node.js 自身が ESModules を直接解釈します。
Node.js 12.17.0 以降で対応しています。

CJS【CommonJS】 と ESM の主な違いはモジュールのインポート/エクスポートの仕組みです。
CJS ではモジュールのインポート時にスクリプトの実行はその場で一時停止し、読み込み後に実行が再開されます。
ESM では非同期的にモジュールを読み込むことができ、インポートされたモジュールの変更もリアルタイムで反映されます。

コード上では以下のような違いになります。

// インポート - CJS
const greet = require("./greet");
greet();

// インポート - ESM
import { greet } from "./greet.mjs";
greet();


// エクスポート - CJS
module.exports = function(){
	console.log("Hello from CommonJS!");
};

// エクスポート - ESM
export function greet(){
	console.log("Hello from ESM!");
}

その他の主な相違点としては、以下が挙げられます。

トップレベル await のサポートes2022以上)
ESM ではトップレベル(モジュールやスクリプトの最上位のスコープ、一番外側に記述されるコード領域)でのawaitの使用が可能です。

拡張子
CJS は.cjs(TypeScript だと.cts)、ESM は.mjs.mts)と明確に分けられます。
.js.ts)は、package.json のtypeの指定により実行時に解釈されます。
"type": "commonjs"であれば CJS として扱われ、"type": "module"であれば ESM として扱われます。
なお、ESM 形式ではインポートするファイルは拡張子を省略できません(パッケージのインポートを除く)。


各種パッケージのインストール
まずはプロジェクト用のディレクトリを作成し、Node.js 環境を初期化します。
なお、Node.js はすでにインストールされているものとします。

参考: Node.js のパッケージ管理ツール npm とは

>mkdir MyApp
>cd MyApp
>npm init -y

次に TypeScript と Node.js の型定義をインストールします。
型定義@types/nodeは Node.js の API を使用するために必要です。
開発時にのみ必要なので、--save-devもしくは-Dでインストールします。

>npm install typescript @types/node --save-dev


そしていよいよ Electron のインストールですが、electron-builder での配布が前提であれば、electron-builder とともに開発用としてインストールしなければなりません。
通常、開発用としてインストールされたパッケージは、ビルドされた最終的な実行可能ファイルには含まれませんが、electron-builder と electron はあえて含めないようにする必要があります。

>npm install electron electron-builder --save-dev

パッケージのインストールは一旦ここで完了です。
webpack のインストールについては、webpack の章で説明します。


package.json と tsconfig.json の設定
package.jsonでは、以下の項目を追記します。

・・・
"type": "module",
・・・

ちなみに CJS の場合は"type": "commonjs"、もしくはデフォルトが CJS なので追記なしです。
typeの指定により、.jsファイルが CJS か ESM か Electron 実行時に判断されます。
.mjsや.cjsのように明確に拡張子が分けられている場合は、typeの指定と関係なく拡張子が示す通りに動作します。

次に TypeScript 環境を初期化します。
以下のコマンドを実行します。

>npx tsc --init

tsconfig.json という設定ファイルが出力されるので、以下の項目を編集・追加します。
(他の項目はそのままで良いです)

・・・
"compilerOptions": {
・・・
	"target": "es2022", // 出力されるECMAScriptバージョン
	"module": "node16", // 出力されるモジュール形式
	"outDir": "./dist", // 出力先ディレクトリ
・・・
},
"include": [
	"./src/**/*" // トランスパイルの対象となるソースファイル
]
・・・

target
出力される ECMAScript のバージョンです。
トップレベルawaitが実装されたes2022が良いでしょう。
"module": "commonjs"の場合には必要ありません。

なお、現状指定できる値は以下の通りです。
es3/es5/(es6/es2015)/es2016/es2017/es2018/es2019/es2022

module
出力されるモジュール形式です。
node16は CJS/ESM の両方に対応し、.mts.mjsに、.cts.cjsにトランスパイルされます。
.ts.jsを出力しますが、コードの内容は package.json のtypeの指定により CJS か ESM かをトランスパイラが判断します。

なお、現状指定できる値は以下の通りです。
commonjs/(es6/es2015)/es2020/es2022/esnext/node16/nodenext/amd/umd/system/none

nodenextは現状node16と同じですが、常に最新のモジュール解決ロジックが適用されます。
node_modules 配下を含めすべてのパッケージが ESM 形式化可能な場合であれば、esnextもしくはes2022を指定しても良いですが、現在の Node.js 界隈の状況を鑑みるとnode16の方が無難だと思います。

outDir
出力先ディレクトリを指定します。
この指定方法では、プロジェクトルートディレクトリのサブディレクトリ dist 配下に出力されます。

include
トランスパイルするソースファイルを指定します。
この指定方法では、プロジェクトルートディレクトリのサブディレクトリ src 配下のすべてのソースファイルがトランスパイルの対象となります。
"**"は任意のディレクトリを表し、"**/*"はそのすべてのサブディレクトリ内のファイルを対象とするときに使用されます。
この書式を用いることで、ディレクトリ構造の深さに関わらず、指定されたディレクトリ配下のすべてのファイルを選択できます。


ソースコード
本記事では配布までの手順の解説が主眼であるため、非常にシンプルなソースコードを紹介します。
レンダラプロセスやプリロードスクリプトなどの解説については、下記を参照してください。

参考: Electron のプリロードスクリプトによるプロセス間通信

import { app, BrowserWindow } from "electron";
import TestClass from "./TestClass.mjs"; // 拡張子は.mjs

let mainWindow: BrowserWindow | null = null;

async function createWindow(){
	const params = {width:1024, height:600, x:50, y:50, title:"Main Window"};
	mainWindow = new BrowserWindow(params);

	await mainWindow.loadFile("./main.html");
	mainWindow.webContents.openDevTools(); // 開発ツールの起動
}

app.on("ready", function(){
	createWindow();
	var test = new TestClass();
});

ESM 形式ではrequireではなくimport文でモジュールをロードします。
この際、インポートするファイルは拡張子を省略できません(パッケージのインポートを除く)。
また、トランスパイラtscでは拡張子はトランスパイル後の拡張子.mjsを指定するので注意が必要です。

相対パスでの指定では、自身のファイルからの位置になります。
ちなみに、TestClass.mts の内容はシンプルにこんな感じです。

class TestClass{
	constructor(){
		console.log("test");
	}
}
export default TestClass;

ロードする画面である main.html の内容は以下のような感じです。

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<meta http-equiv="Content-Security-Policy" content="default-src 'self';"/><!-- CSP -->
</head>
<body>
<div>テスト</div>
</body>
</html>


CSP【Content-Security-Policy】
ESM とは直接関係ありませんが、Electron アプリケーションでは CSP の指定が必須となっています。
CSP とは、ウェブセキュリティのためのポリシー指向のメカニズムであり、ウェブページに対して特定のセキュリティポリシーを適用することを可能にします。
これはクロスサイトスクリプティング(XSS)攻撃などのウェブセキュリティの脅威に対抗するための設定です。
上記の設定selfでは、JavaScript やスタイルのインライン記述(直書き)を禁止しています。
self以外の設定値には以下のようなものがあります。

none: すべての外部ソースをブロック
self: 外部ソースのインクルードを許可
unsafe-inline: インラインスタイルを許可
unsafe-eval: 動的評価eval()の許可

例えば下記の例では、JavaScript は外部のインクルードは許可するが直書きは禁止、スタイルはインクルードもインラインも許可しています。

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';"/>


トランスパイルと実行
この時点でソースコードをトランスパイルし、実行することができます。
package.json に以下のスクリプトを組み込み、実行してみます。

・・・
"scripts": {
	"tsc": "tsc",
	"start": "electron --trace-warnings ./dist/main.mjs",
},
・・・

オプション--trace-warningsは、警告が発生した正確な場所を示すスタックトレースを出力します。
これにより、どのファイルや関数が警告の原因であるかを正確に把握できるようになります。

なお、このプログラムはカレントディレクトリにある main.html をloadFileするので、これを main.mjs と同じ場所(dist ディレクトリ)に配置します。

>cp src/main.html dist # main.htmlの配置
>npm run tsc # トランスパイル
>npm run start # 実行

うまく動作しました。

Electron アプリケーションの実行


webpack の設定
Electron アプリケーションを webpack 化することで、複数の JavaScript ファイルやリソースを一つのファイルにバンドルすることができます。
webpack はパフォーマンスやリソースを最適化するだけでなく、コードのミニファイ(圧縮)による難読化という面でも優れています。
実は electron-builder でパッケージングされたものを配布したとしても、あなたのソースコードは丸見えになります。
詳細はあえて説明しませんが、インストール先ディレクトリを探せばほぼそのままのコードが見つかることでしょう。
この他、開発の効率化など様々なメリットがあるので、webpack を利用することをお勧めします。


webpack のインストール
まずは、webpack をインストールします。
ローダー(ts-loader)もインストールして、トランスパイルからバンドルまでを webpack で一気にやってしまいましょう。

>npm install webpack webpack-cli --save-dev
>npm install ts-loader --save-dev


tsconfig.json の設定
トランスパイラtscではファイルのインポートはトランスパイル後の拡張子.mjsを指定しますが、ts-loaderでは TypeScript の拡張子.mtsを指定します。
先ほどの main.mts を以下のように修正します。

import TestClass from "./TestClass.mjs"; // 拡張子は.mjs
 ↓
import TestClass from "./TestClass.mts"; // 拡張子は.mts

これに伴い、tsconfig.json の設定も変更が必要になります。

・・・
"compilerOptions": {
・・・
	"allowImportingTsExtentions": true, // TypeScriptの拡張子を許可する
	"noEmit": true, // 出力ファイルを生成しない
・・・
},
・・・

allowImportingTsExtentions: true
TypeScript の拡張子.mts.cts.tsimport文で利用することを許可します。

noEmit: true
allowImportingTsExtentionsnoEmitが必須なのでこれを指定します。
"noEmit": trueを指定すると、トランスパイルする際に JavaScript ファイル、ソースマップ、型定義ファイルなどの出力を生成しません。
実際の出力は webpack に任せます。


webpack.config.js の設定
webpack の設定は JavaScript で記述します。
デフォルトのファイル名は webpack.config.js ですが、ファイル名をオプションで指定できるため任意の名称で構いません。
ここではせっかくなので、ESM 形式で設定を記述してみます。

import path from "path";
import { fileURLToPath } from "url";

const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 1
const srcDir = path.resolve(__dirname, "src");
const bundleDir = path.resolve(__dirname, "bundle");

const main = {
	mode: "production", // 2
	target: "electron-main", // 3
	entry: path.resolve(srcDir, "main.mts"), // 4
	output: { // 5
		path: bundleDir,
		filename: "main.mjs",
		module: true,
		chunkFormat: "module"
	},
	resolve: {
		extensions:[".mts", ".cts", ".mjs", ".cjs", ".ts", ".js"] // 6
	},
	module: {
		rules: [{ // 7
			test: /\.(mts|cts)$/,
			use: {
				loader: "ts-loader",
				options: {
					transpileOnly: true
				}
			},
			include: srcDir,
			exclude: /node_modules/
		}]
	},
	experiments: { // 8
		outputModule: true
	}
};
export default main;

① __dirname
ESM 形式では自身のディレクトリを指す__dirname変数(自動設定されるローカル変数)が使用できませんので、例のように定義します。

② mode
development/productionがあります。
developmentモードではバンドルされたファイルは圧縮されず、さらにソースマップもバンドルされることによりブラウザの開発ツールでのデバッグが容易になります。

③ target
Electron では、electron-mainelectron-rendererelectron-preloadの3つの出力形式が指定できます。
ここでは Electron のメインプロセスのみなので、electron-mainを指定しています。
レンダラプロセスやプリロードスクリプトなどの解説については、下記を参照してください。

参考: Electron のプリロードスクリプトによるプロセス間通信

ちなみにブラウザ向けであればweb、Node.js アプリケーションであればnodeを指定します。

④ entry
ここで指定されたエントリーポイントから開始してすべての依存関係を解析し、1つのバンドルにまとめられます。
entryを複数指定すると、entryごとにバンドルファイルが出力されます。

⑤ output
出力するディレクトリやファイル名を指定します。
ここではプロジェクトルートディレクトリのサブディレクトリ bundle 配下に出力されるよう指定しています。

output.module: true
生成するバンドル全体が ESM 形式であることを明示的に指定します。
webpack は出力ファイルを ESM としてマークし、インポートやエクスポートの解析を最適化します。

output.chunkFormat: “module”
個々のチャンク(webpack が生成する各々のコードの断片やモジュール)が ESM 形式で出力されます。
array-pushを指定するとブラウザ環境用に、commonjsを指定すると Node.js 環境用に出力されます。
output.chunkFormat: "module"output.module: false(または未指定)の場合、個々のチャンクは ESM 形式で出力されますが、バンドル全体の扱いや最適化は ESM としては最適化されません。

⑥ resolve.extensions
拡張子が省略されたモジュールを解決する際に webpack が試みるファイル拡張子のリストです。
webpack は指定された拡張子のリストを順に試して、マッチするファイルを探しだします。
一般的には、プロジェクトのソースコードで使用されているすべての拡張子を含めるべきです。

⑦ module.rules
ローダーの設定を行います。
ここではts-loaderを使用するので、ts-loaderの設定をここで行います。

module.rules.test
ローダーが適用されるべきファイルの種類を正規表現で表します。
ここでは拡張子が.mts.ctsのファイルをターゲットとしています。

module.rules.use.loader
ここではts-loaderを指定します。

module.rules.use.options.transpileOnly: true
トランスパイルの際に型チェックをスキップするように指定します。
型チェックを行うとエラーが発生しますが、Native ESM 化にあたっては、現状これを回避する方法がありません
(誰かわかる人がいましたら教えてください)

>npx webpack

ERROR in ./src/main.mts
Module build failed (from ./node_modules/ts-loader/index.js):
Error: TypeScript emitted no output for XXX/src/main.mts.

module.rules.use.include
トランスパイルする TypeScript ファイルの場所(ディレクトリ)を指定します。

module.rules.use.exclude
トランスパイルの対象から除外するファイルやディレクトリを正規表現で指定します。
基本的には node_modules 配下の外部パッケージはすでにトランスパイル済みであり、excludeに含めておくべきです。
トランスパイルするかどうかの対象であり、バンドルの対象から外れるわけではありません。

⑧ experiments.outputModule: true
実験的な機能のようです。
バンドル全体を ESM 形式で出力するらしいので、とりあえず設定してみます。
この設定が有効になれば、将来的にはoutput.moduleoutput.chunkFormatのような設定が必要なくなるかもしれません。


パッケージングと実行
package.json に以下のスクリプトを組み込み、パッケージングしてみます。

・・・
"scripts": {
	"pack": "webpack --stats-error-details",
	"start2": "electron --trace-warnings ./bundle/main.mjs"
},
・・・

オプション--stats-error-detailsは、発生したエラーの詳細情報を表示するために使用されます。
これにより、問題の原因を特定しやすくなります。

なお、このプログラムはカレントディレクトリにある main.html をloadFileするので、これを出力された main.mjs と同じ場所(bundle ディレクトリ)に配置します。

>cp src/main.html bundle # main.htmlの配置
>npm run pack # パッケージング
>npm run start2 # 実行


electron-builder による配布
electron-builder は Electron アプリケーションのパッケージングと配布用のファイルを作成するためのツールです。
electron-builder を利用することにより、アプリケーションの開発と配布プロセスを簡素化します。


package.json の設定
electron-builder の設定は package.json のbuildディレクティブに記述します。
またビルドするためにはmainの設定が必要です。

{
	"main": "./bundle/main.mjs",
・・・
	"build": {
		"productName": "Test5d",
		"appId": "com.test",
		"asar": true,
		"directories": {
			"output": "./build"
		},
		"files": [
			"./bundle/**/*"
		],
		"mac": {
			"icon": "./assets/icon/icon.icns",
			"target": ["dmg"]
		},
		"win": {
			"icon": "./assets/icon/icon.ico",
			"target": "nsis"
		},
		"nsis":{
			"oneClick": false,
			"allowToChangeInstallationDirectory": true
		}
	},
・・・
}

main
エントリーファイル、すなわち Electron が最初に読み込む JavaScript ファイル(メインプロセスのスクリプト)を指定します。

productName
ビルドされたアプリケーションの表示名を指定します。
アプリケーションのウィンドウタイトル、インストーラ、メニューやデスクトップのショートカットなどユーザーに見える場所で使用される名称となります。

appId
アプリケーションの識別子です。
グローバルに一意である必要があり、通常は企業などのドメイン名を逆順にした形式を使用することが一般的です。

asar
app.asarというアーカイブファイルにソースコードとその依存関係をパッケージングします。

directories.output
ビルドされたアプリケーションの出力先ディレクトリを指定します。

files
どのファイルやディレクトリがアプリケーションのパッケージに含まれるべきかを指定します。
外部パッケージ(node_modules 配下)は webpack によりバンドルされているため、ここには含めません。
なお、除外ファイル等を指定する場合には、先頭に!をつけます。

"files": [
	"./bundle/**/*",
	"!./bundle/test/*"
],

mac
Mac 用のインストーラを作成するための設定です。

win
Windows 用のインストーラを作成するための設定です。

win.icon
アイコンファイルを指定します。
256 x 256 pxのサイズである必要があります。
省略された場合はデフォルトのアイコンが使用されます。

win.target
NSIS【Nullsoft Scriptable Install System】は、Windows 用のインストーラを作成するためのスクリプト駆動型のツールです。
electron-builder は、NSIS を使用して Windows 用のインストーラ(.exe 形式)を生成します。

この他、以下のような設定があります。

nsis-web
小さなインストーラをダウンロードし、実行時にアプリケーションの残りの部分をダウンロードします。
portable
インストール不要のポータブルアプリケーションを作成。
アプリケーションをインストールする代わりに、直接実行ファイルを実行できます。
zip
単純な ZIP アーカイブ。
msi
Microsoft Installer 形式。

nsis.oneClick
trueを指定した場合、インストーラを実行するとすぐにインストールが始まります。
インストールパスの選択や追加オプションの確認などの手順が省略されます。
ここではfalseを指定しています。

nsis.allowToChangeInstallationDirectory: true
ユーザーがインストール先ディレクトリを変更することを許可します。


リソースへのパスの変更
electron-builder によるパッケージングによってリソースの相対パスが変更されます。
カレントディレクトリは、プロジェクトのルートディレクトリとなります。

main.mts では main.html をロードする箇所を以下のように修正します。

await mainWindow.loadFile("./main.html");
 ↓
await mainWindow.loadFile("./bundle/main.html");


ビルド
package.json に以下のスクリプトを組み込み、ビルドしてみます。

・・・
"scripts": {
	"buildDev": "electron-builder --win --x64 --dir",
	"build": "electron-builder --win --x64"
},
・・・

--win, --x64
オプション--winは Windows 用、--x64は 64 ビット用を表します。
electron-builder はプロジェクトの設定や環境に基づいて適切なデフォルトビルドオプションを選択するため、これらのオプションは必須ではありません。

--dir
実際のインストーラーパッケージ(.exe、.dmg など)を生成せずに、アプリケーションのファイルを出力ディレクトリに直接配置します。
outputで指定したディレクトリ配下にwin-unpacked/{productName}.exeという実行ファイルが出力されますので、これを直接実行することでアプリケーションが起動します。
アプリケーションを迅速にテストしたり、プロジェクトのファイル構造だけを確認したい場合に便利です。

以下のコマンドで実行します。

>npm run build

outputで指定した出力ディレクトリに{productName} Setup {version}.exeというインストーラが生成されますので、これを配布します。

コメント