序
ElectronではNode.jsの世界であるメインプロセスとChromiumの世界であるレンダラープロセスが分断されており1、レンダラープロセス2がNode.jsの世界でハチャメチャできないようになっています。
ではどうやってデスクトップアプリとして機能させるかというと、Electronのプロセス間通信(IPC3)の仕組みを使います。
まずメインプロセスはpreload
スクリプトにNode.js世界の処理を呼び出すためのAPIを公開します。レンダラープロセスはメインプロセスがあらかじめ用意したAPIを経由してのみ、ファイルシステムなどを含むNode.jsの世界にアクセスすることができます。
- この辺、最初あんまりピンと来てなかったのですが、構成要素自体はWebアプリそのまんまですね
- レンダラープロセスがフロントエンド、メインプロセスがバックエンド(APIサーバー)というわけです
そういうわけで、Electronを使ったアプリを作っていると、「メインプロセスで処理している間、レンダラープロセス側が操作できないようにしたい」というシチュエーションが結構あります。
レンダラープロセスから呼び出した非同期処理を待つのであれば、ipcRenderer.invoke()
がそもそもPromise
を返すので特に困ることはありません。普通のWebアプリでREST APIをfetch()
するのと同じです。
しかし、これがメインプロセスから呼び出した非同期処理の場合、処理が終わるまでのPromise
を、レンダラープロセスが簡単に得ることはできません。
例えば、Electronはメニューバーをメインプロセス側で定義します。
// メニューバー定義
const menu = Menu.buildFromTemplate([
{
label: 'myApp',
role: 'appMenu'
},
{
label: 'File',
role: 'fileMenu',
submenu: [
{
label: 'Close Window',
accelerator: 'CommandOrControl+W',
role: 'close'
},
{
label: 'Export Data...',
accelerator: 'CommandOrControl+E',
click: () => exportConfig()
},
{
label: 'Import Data...',
accelerator: 'CommandOrControl+I',
click: () => importConfig()
}
]
},
{
label: 'Edit',
role: 'editMenu'
},
{
label: 'Window',
role: 'windowMenu'
}
]);
Menu.setApplicationMenu(menu);
exportConfig
やimportConfig
はファイルシステムを扱う、ちょっと重めの非同期処理だと思ってください。これらをメニューバーから呼び出せるようにしているわけですが、ファイルシステムのようなOSの機能を使う処理ですから、コードはメインプロセス側に記述する必要があります。
メインプロセス側で定義されているメニューバーからメインプロセス側の処理を呼び出すことになるので、このままではレンダラープロセスがそれを検知することはできません。そうすると、メインプロセスで非同期処理の実行中であっても、レンダラープロセス側で画面操作ができてしまいます。
単純に気持ち悪いのもそうですが、データの整合性なんかを考えてもよいことはないので、レンダラープロセスにメインプロセスが処理中であることを教えてあげよう、というのがこの記事の主旨です。
- メインプロセス側を完全な同期処理として実装すれば、メインプロセスでの処理中におそらくレンダラープロセスもブロックされる(未調査)ので画面操作をさせないだけならこれでも実現はできると思います
- ただ、レンダラープロセスがブロックされるとアニメーションも止まって完全にフリーズするので、見た目が悪いです
なお、この記事で使っているElectronのバージョンは28.2.3
です。
実践
前置きが長くなりましたが、実装に入りましょう。
Electronのプロセス間通信にはレンダラープロセスからメインプロセスへの通信だけでなく、メインプロセスからレンダラープロセスにメッセージを送る仕組みが用意されていますので、これを使います。
Electronはプロセス間通信でチャネル名と一緒に引数を渡すことができます。じゃあメインプロセス側で作ったPromise
を渡せばいいじゃないか、となるわけですが、そうは問屋が卸しません。
Electronのプロセス間通信では引数の値をそのまま渡すのではなく、構造化複製アルゴリズムでシリアライズしたものを渡します。構造化複製アルゴリズムはPromise
をシリアライズできないので、この手は使えません。
なので、今回はチャネル名でPromise
の開始と終了を通知するという方法を取ります。
const promiseForRenderer =
(func: (win: Electron.BrowserWindow) => Promise<unknown>) => async () => {
const win = BrowserWindow.getFocusedWindow();
if (!win) {
return;
}
win.webContents.send('promise-start');
await func(win);
win.webContents.send('promise-finish');
};
Electronは複数のウインドウを開くことができるため、メインプロセスとレンダラープロセスは1:n
の関係になります。なので、メインプロセスからレンダラープロセスにメッセージを送るには、どのレンダラーに送るのかを特定しなければいけません。
このpromiseForRenderer
関数は、Electron.BrowserWindow
を引数とするメインプロセスの非同期関数をラップするもので、送信先ウインドウ(=レンダラープロセス)の特定と、処理の開始と終了を示すメッセージ送信を追加で実行します。
exportConfig
やimportConfig
はその処理内でウインドウを特定していました(ファイル保存/開くダイアログの親ウインドウを指定しないといけないため)が、これを引数で受け取るようにします。
async function exportConfig(win: Electron.BrowserWindow) {
// 処理は割愛
}
async function importConfig(win: Electron.BrowserWindow) {
// 処理は割愛
}
メニュー定義はこうなります(抜粋)。
{
label: 'File',
role: 'fileMenu',
submenu: [
{
label: 'Close Window',
accelerator: 'CommandOrControl+W',
role: 'close'
},
{
label: 'Export Data...',
accelerator: 'CommandOrControl+E',
click: promiseForRenderer(exportConfig)
},
{
label: 'Import Data...',
accelerator: 'CommandOrControl+I',
click: promiseForRenderer(importConfig)
}
]
}
これで非同期関数の実行時、'promise-start'
、'promise-finish'
チャネルのメッセージがレンダラー側に送られるようになりましたので、プリロードにこれらをリスニングするための仕組みを作ります。
プリロードの実装
- 個人で制作中のコードから抜粋したものなのでファイル分けてますが、1個にまとめてもよいです
import { ipcRenderer } from 'electron';
export const processingApi = {
onStartPromise: (handler: (promise: Promise<unknown>) => void) => {
// Promise開始時のイベントリスナー
const startListener = () => {
// promise-finishが来たらresolveする
const waitProcessing = new Promise<void>((resolve) => {
// Promise終了時のイベントリスナー
const finishListener = () => {
ipcRenderer.off('promise-finish', finishListener);
resolve();
};
ipcRenderer.on('promise-finish', finishListener);
});
handler(waitProcessing);
};
ipcRenderer.on('promise-start', startListener);
// リスニング解除用の関数を返す
return () => {
ipcRenderer.off('promise-start', startListener);
};
}
};
onStartPromise
はPromise
を引数とする関数をハンドラーとして受け取ります。メインプロセスから'promise-start'
チャネルのメッセージを受け取ると、「次にメインプロセスから'promise-finish'
チャネルのメッセージを受け取ったらresolve
する」というPromise
を作り、handler
に渡します。
ipcRenderer.off()
でリスニングを解除しないと、非同期処理のたびにイベントリスナーが増えていくので注意。
import { contextBridge } from 'electron';
import { processingApi } from './api/processing';
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('processing', processingApi);
} catch (error) {
console.error(error);
}
} else {
window.processing = processingApi;
}
プリロード経由でこのAPIをレンダラープロセスに公開します。
こうすることで、メインプロセスでの処理をレンダラープロセス上でPromiseとして扱うことができるようになります。
レンダラープロセス側で使う
- これも制作中のコードから抜粋しているので、Reactでの使用例になってます。ご容赦ください
-
前回の記事(【React】非同期処理が終わるまで処理中の表示を出すカスタムフック)で実装した、
useWaitProcessing
フックが出てきますので、合わせて参照してみてください- というよりも、今回の実装は
useWaitProcessing
で扱いやすいように書いたので、これにかなり引きずられた実装となっています
- というよりも、今回の実装は
import useWaitProcessing from '@renderer/hooks/useWaitProcessing';
function App(): JSX.Element {
// waitProcessingは渡されたPromiseの解決まで処理中の表示をするための関数
const { waitProcessing, WaitingScreen } = useWaitProcessing();
// onStartPromiseにwaitProcessing((Promise<unknown>) => void)を渡す
useEffect(() => {
// onStartPromiseはリスニング解除用の関数を返すので、そのままreturnする
return window.processing.onStartPromise(waitProcessing);
}, []);
// 略
return (
<>
<WaitingScreen />
<main>{/*略*/}</main>
</>
)
}
これで、メインプロセスが'promise-start'
チャネルにメッセージを送ったら処理中表示、'promise-finish'
チャネルにメッセージを送ったら表示解除、という動作ができるようになりました。
もう一歩:チャネルを統合する
当初'promise-start'
と'promise-finish'
の二つのチャネルを使って実装していたのですが、Qiitaで記事化するにあたり一連の処理でチャネル名を複数使うのはどうかなという気がしたので、'promise'
チャネルと引数('start' | 'finish'
)を使う方法に書き換えてみました。
export const processingApi = {
onStartPromise: (handler: (promise: Promise<unknown>) => void) => {
// Promiseのresolveを呼ぶための関数を外に出す
let resolver: (() => void) | null = null;
// 引数は'start' | 'finish'
const listener = (_event: IpcRendererEvent, aspect: 'start' | 'finish') => {
switch (aspect) {
case 'start': {
// Promiseを作ってhandlerに渡す
// Promiseは後からresolver関数を呼ぶことで完了できる
const promise = new Promise<void>((resolve) => {
resolver = () => {
resolve();
resolver = null;
};
});
handler(promise);
break;
}
case 'finish'
//resolver関数を呼んでpromiseを完了させる
resolver?.();
}
};
ipcRenderer.on('promise', listener);
// イベントリスナーを解除用の関数を返す
return () => {
ipcRenderer.off('promise', listener);
};
}
};
promiseForRenderer
はこうなります。
const promiseForRenderer =
(func: (win: Electron.BrowserWindow) => Promise<unknown>) => async () => {
const win = BrowserWindow.getFocusedWindow();
if (!win) {
return;
}
win.webContents.send('promise', 'start');
await func(win);
win.webContents.send('promise', 'finish');
};
…promiseForRenderer
はこっちのほうがいいなという感じですが、onStartPromise
の処理がやや複雑になった気もします。どっちがよいとは言い切れませんが、イベントリスナーの制御が1つで済んでいること、見た目がより線形に近くなったと感じられることから、個人的にはこちら(単一チャネル)の方がよいと思います。
-
Promise
やイベントリスナーを扱うコードって、見た目が非線形になりがちで読みにくいですよね
なお
今回の実装はチャネルを共有しているので、一つのレンダラープロセス内で複数の非同期処理を(Promise.all
でまとめずに)同時に待つような用途だとうまくいかない(はず)です。
- 処理工程ごとの完了状況を表示したい、など、いくつかシチュエーションは思いつくので、その場合は別の実装を考える必要があるでしょう
- 専用のチャネルにしたほうがよさそうな気がします
終わりに
Electron、最初はとっつきにくい印象でしたが、やってること自体はWebアプリと変わらないので、ちょっと特殊なフルスタックWebフレームワークだと思えば案外扱いやすいかもしれないです。
- ビルド周りやディレクトリ構成なんかをちゃんとやろうとすると沼っぽいですが、electron-viteを使うと比較的環境構築やりやすかったです。おすすめ