Electronでアプリを作るときに、React Hooksを使ってプロセス間通信のロジックをコンポーネント間で使い回せると楽しそうだなと思って、やってみました。
動機
Reactのコンポーネント内で非同期通信を扱おうとすると、関連するロジックがコンポーネントのライフサイクルを扱うメソッド内に散らばります。
class SomeComponent extends React.Component {
componentDidMount() {
ipcRenderer.on(onShowSaveDialogComplete, this.onSave)
}
componentWillUnmount() {
ipcRenderer.removeListener(onShowSaveDialogComplete, this.onSave)
}
onSave(event, message) {
this.setState({ message })
}
}
これは2つの点でイマイチです。
- ロジックが別のコンポーネントで使い回しにくい
- 別のステートを扱う非同期通信を同じコンポーネントに追加すると、ライフサイクルメソッド内に無関係のロジックが同居することになる(扱うステートが増えるほど、可読性が下がる)
Hooks APIを使えば、ロジックを一箇所にまとめた上で、簡単に使い回せるようになります。
参考: フックの導入(https://ja.reactjs.org/docs/hooks-intro.html)
環境構築
ElectronでReactを使う
Electron内でReactを使うための環境構築は結構めんどくさい(と思う)のですが、以下のページで紹介されていた方法は比較的お手軽でした。
How to build a React based Electron app
事前準備
Electronのバージョン6ではデフォルトでnodeIntegrationがfalseなので、preload機能で、ipcRendererをwindowに渡しておきます。
const { ipcRenderer } = require('electron')
window.ipcRenderer = ipcRenderer
mainWindow = new BrowserWindow({
webPreferences: {
nodeIntegration: false,
preload: `${__dirname}/preload.js`,
},
})
実践
ここでは例として、メインプロセス側でdialog.showSaveDialogを呼び出します。
- inputボックスにテキストを入力
- Saveボタンをクリックする
- レンダラープロセスからメインプロセスにテキストを送信
- メインプロセスでdialog.showSaveDialogを呼び出し
- 指定されたファイルにテキストを保存
- メインプロセスからレンダラープロセスに結果を返却
channel用に定数を用意
const Channels = {
showSaveDialog: 'showSaveDialog',
onShowSaveDialogComplete: 'onShowSaveDialogComplete',
}
module.exports = Channels
ipcMainのセットアップ
const { dialog, ipcMain } = require('electron')
const fs = require('fs')
const Channels = require('../shared/channels')
const { showSaveDialog, onShowSaveDialogComplete } = Channels
module.exports = function setupIpc() {
// showSaveDialogのメッセージを受け取る
ipcMain.on(showSaveDialog, async (event, arg) => {
const { text, options } = arg
// ダイアログを開いて、結果を受け取る
const { canceled, filePath } = await dialog.showSaveDialog({
...(options ? options : {}),
})
let error
// 入力とpathをチェックして、ファイルに保存する
if (text && !canceled && filePath) {
error = await new Promise(resolve => {
fs.writeFile(filePath, text, err => resolve(err))
})
} else { // 入力が不正のときは、エラーを返す
error = new Error(
`Invalid input or operation: ${JSON.stringify({
text,
canceled,
filePath,
})}`
)
}
// 結果をrendererに返却
event.reply(onShowSaveDialogComplete, {
// Error型のままではrenderer側で受け取れなかったので、messageを返す
errorMessage: error ? error.message : null,
filePath,
})
})
}
Hooks APIを利用しない場合
ipcRendererのリスナー登録・解除という関連するロジックが、別々の箇所に記載されており、見通しが悪くなりがちです。
import React, { createRef } from 'react'
import Channels from '../shared/channels'
const { ipcRenderer } = window
const { showSaveDialog, onShowSaveDialogComplete } = Channels
export default class WriteTextToFileClass extends React.Component {
constructor(props) {
super(props)
this.state = { message: null }
this.onSave = this.onSave.bind(this)
this.onSaveTextClick = this.onSaveTextClick.bind(this)
this.inputRef = createRef()
}
componentDidMount() {
ipcRenderer.on(onShowSaveDialogComplete, this.onSave)
}
componentWillUnmount() {
ipcRenderer.removeListener(onShowSaveDialogComplete, this.onSave)
}
render() {
return (
<div>
<p>
<input type="text" ref={this.inputRef}></input>
<button onClick={this.onSaveTextClick}>Save Text</button>
</p>
<p style={{ fontStyle: 'italic', fontSize: '0.5em' }}>
{this.state.message}
</p>
</div>
)
}
onSave(event, arg) {
const { errorMessage, filePath } = arg
const message = errorMessage || `Text has been saved successfully to ${filePath}`
this.setState({ message })
}
onSaveTextClick() {
const text = this.inputRef.current.value
ipcRenderer.send(showSaveDialog, {
text,
options: {
filters: [
{ name: 'Text File', extensions: ['txt'] },
],
}
})
}
}
Hooks APIを利用してリライト
ipcRendererに関連するロジックのみを抽出したカスタムフックを作成します。
関連するステートの初期化・更新、リスナーの登録・解除が一つの関数内で完結します。
Hooksを利用しない場合も、レンダープロップや高階関数コンポーネントを利用すれば、コンポーネントの再利用は可能ですが、ロジックだけを使いたい場合はこちらの方が簡潔です。
import { useCallback, useEffect, useState } from 'react'
import Channels from '../shared/channels'
const { ipcRenderer } = window
const { showSaveDialog, onShowSaveDialogComplete } = Channels
export default function useShowSaveDialog(options = {}) {
const [filePath, setFilePath] = useState(null)
const [errorMessage, setErrorMessage] = useState(null)
const sendMessage = useCallback(
text => {
ipcRenderer.send(showSaveDialog, { text, options })
},
[options]
)
useEffect(() => {
function handleOnComplete(event, arg) {
const { errorMessage, filePath } = arg
setFilePath(filePath)
setErrorMessage(errorMessage)
}
ipcRenderer.on(onShowSaveDialogComplete, handleOnComplete)
return () =>
ipcRenderer.removeListener(onShowSaveDialogComplete, handleOnComplete)
}[])
return [errorMessage, filePath, sendMessage]
}
作成したフックは関数コンポーネント内で下記のように呼び出します。
別のコンポーネントでも同じように呼び出すことで、簡単に使いまわすことができます。
import React, { useRef } from 'react'
import useShowSaveDialog from '../hooks/useShowSaveDialog'
export default function WriteTextToFile() {
const [errorMessage, filePath, showSaveDialog] = useShowSaveDialog({
filters: [
{ name: 'Text File', extensions: ['txt'] },
],
})
const inputRef = useRef(null)
const message = errorMessage || (filePath ? `Text has been saved successfully to ${filePath}` : null)
return (
<div>
<p>
<input type="text" ref={inputRef}></input>
<button onClick={() => showSaveDialog(inputRef.current.value)}>Save Text</button>
</p>
<p style={{ fontStyle: 'italic', fontSize: '0.5em' }}>
{message}
</p>
</div>
)
}
パフォーマンスについて
関数コンポーネントでHooksを使うことで、レンダーごとにコールバック関数を作るようになりますが、パフォーマンスへの影響はあまりないそうです。
最後に
Hooks APIやElectronに関しては、まだあまり使い慣れていないので、おかしな点が
あればコメントいただけると嬉しいです。