electron化したReactアプリのプロセス間通信


 すっかり最近はフロントエンドエンジニアのキャリアを歩み出しています。気が付けば、JavaScriptでWebアプリ、スマホアプリ、デスクトップアプリ何でも作れるようになって凄い時代になったなぁと思っています。昔はJavaがwrite it once run anywareと言ってましたが、最近はわりとJavaScriptが近いポジションに居るのではないでしょうか。最低限ブラウザさえあれば始められる敷居の低さも好感触です。ぜひ、プログラミングに興味を持った方にはJavaScriptを触って欲しいですね。

 さて、閑話休題。普段遣いするツールをちょっとelectron + React with TypeScriptで作ってみた時にハマった話を整理してみます。

やりたいこと

  • create react appで作ったReactアプリをelectron化する
  • MainプロセスとBrowserプロセス間で通信する
  • パッケージングを行う

前提

  • create react appを利用する
  • TypeScriptで開発する
  • ejectはしない
  • シンプルを目指す

段取り

1. create react appを実行する

 いつもの!

create-react-app sample --typescript

2. electronとelectron-builderを依存関係に追加する

 今回はelectron-builderを使います。最終成果物が1つのファイルに纏まってすっきりするので。

npm i -D electron [email protected]

 ポイントはelectron-builderのバージョン。最新ではなく21.0.2を使います。ビルドファイルで書き込む設定情報が正しく解釈されないバグがあるからです。

3. package.jsonを修正その1

electron実行時のエントリーポイントとなるファイルを指定します。

  "version": "0.1.0",
  "main": "./electron/electron.js",
  "homepage": "./",
  • main
  • homepage

の二箇所を設定しましょう。

4. electron.jsを作成

 エントリーポイントとなるJavaScriptファイルを作成します。

electron.js
const electron = require('electron');
const app = electron.app;
const BrowserWindow = electron.BrowserWindow;

const path = require('path');
const url = require('url');
const fs = require('fs');

let mainWindow;

function createWindow() {
    mainWindow = new BrowserWindow({width: 1000, height: 800, webPreferences: {
        nodeIntegration: true
    }});
    mainWindow.setMenu(null);
    const startUrl = process.env.ELECTRON_START_URL || url.format({
        pathname: path.join(__dirname, '/../build/index.html'),
        protocol: 'file:',
        slashes: true
    });    
    mainWindow.loadURL(startUrl);
    mainWindow.on('closed', function () {
        mainWindow = null
    })
    mainWindow.webContents.openDevTools();
}

app.on('ready', createWindow);
app.on('window-all-closed', function () {
    if (process.platform !== 'darwin') {
        app.quit()
    }
});

app.on('activate', function () {
    if (mainWindow === null) {
        createWindow()
    }
});

 これで最低限electronでReactを実行出来るようになりました。

5. package.jsonを修正その2

 electron実行用にscriptを用意します。

  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "electron-start": "electron ."
  },

 この状態で

npm run build
npm run electron-start

 と実行すればウィンドウが立ち上がってアプリが実行されます。

6. 配布用ビルドの準備

 前述の方法はあくまで開発用途です。というわけで,技術に明るくない人でも簡単に使えるようにパッケージングしたいところです。electron-builderを使えばそんなことが出来るようになります。(昔に比べると最終成果物のファイルも小さくなりましたね……)

 ビルド設定情報を記述した設定ファイルを作成します。

build-win.js
'use strict';

const builder = require('electron-builder');
const fs = require('fs');
const packagejson = JSON.parse(fs.readFileSync('./package.json', 'utf8'));

builder.build({
  platform: 'win',
  config: {
    'appId': `com.example.${packagejson.name}`,
    'win': {
      'target': 'portable',
      "icon": "icon.ico",
    },
  },
});

7. package.jsonを修正その3

 パッケージング用のスクリプトをpackage.jsonに追加します。ファイルコピーを容易にするためにcpxをインストールしておきましょう。

package.json
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "electron-start": "electron .",
    "electron-build": "npm run build && cpx electron/electron.js build/ && cpx electron/build/icon.ico build && node ./electron/build/build-win.js"
  },

 あとはnpm run electron-buildを実行すればOKです。大体50MByteのexeファイルが出力されるはず。

8. electronとReact間でのプロセス通信

 electron本体と,Reactが動くのは,それぞれ

  • electron: Mainプロセス
  • React: Renderプロセス

 と異なるため,そのままでは直接データ連携することは不可能です。
よって,以下のような要件があった場合はどうしても一手間かかってしまいます。

  • Reactで保持しているデータをファイル保存する
  • アプリのメニューを押下したタイミングでReactのイベントを発火する

 それを実現するのがプロセス間通信です。今回はReactからMainプロセスにデータを送信する例で実験してみます。electronクラスの取り出し方に少しコツが要ります。まず,Windowsクラスを拡張してrequireを使えるようにしておきます。

index.ts
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './views/App';

ReactDOM.render(<App />, document.getElementById('root'));

/**
 * Electron用拡張
 */
declare global {
    interface Window {
      require: any;
    }
}

 続いてMainプロセスとの通信で使うipcRendererの取得処理です。

App.ts

const electron = window.require('electron');

class App extends React.Component<{}, AppState> {

  /**
   * Electron用
   */
  private ipcRenderer = electron.ipcRenderer;

  /**
   * 画面描画メソッド
   */
  public render() {
    return <div><button onClick={this.onClickHandler}>PUSH</button></div>
  }

  private onClickHandler = () => {
    // Mainプロセスに通知する
    ipcRenderer.send('notifyText', "hogehoge");
  }
}

 今度はそれを受け取るMainプロセス側。

electron.js
const electron = require('electron');
const app = electron.app;
const BrowserWindow = electron.BrowserWindow;
const ipcMain = electron.ipcMain;

const path = require('path');
const url = require('url');
const fs = require('fs');

let mainWindow;

function createWindow() {
    mainWindow = new BrowserWindow({width: 1000, height: 800, webPreferences: {
        nodeIntegration: true
    }});
    mainWindow.setMenu(null);
    const startUrl = process.env.ELECTRON_START_URL || url.format({
        pathname: path.join(__dirname, '/../build/index.html'),
        protocol: 'file:',
        slashes: true
    });    
    mainWindow.loadURL(startUrl);
    mainWindow.on('closed', function () {
        mainWindow = null
    })
    mainWindow.webContents.openDevTools();
}

app.on('ready', createWindow);
app.on('window-all-closed', function () {
    if (process.platform !== 'darwin') {
        app.quit()
    }
});

app.on('activate', function () {
    if (mainWindow === null) {
        createWindow()
    }
});

/**
 * Renderプロセスからの通知を受信
 */
ipcMain.on('notifyText', (event, args) => {
  //TODO: データ受信時の処理
});

 こんな感じに実装すればプロセス間通信を実現出来ます。

 お試しあれ!