Electron、TypeScript、React、Material-UI の学習メモです。
概要
Electron
Wikipediaの記事が端的で分かりやすかったので転載。
Electronは、GitHubが開発したオープンソースのソフトウェアフレームワークである。
ChromiumとNode.jsを使っており、HTML、CSS、JavaScriptのようなWeb技術で、macOS、Windows、Linuxに対応したデスクトップアプリケーションをつくることができる。
https://ja.wikipedia.org/wiki/Electron_(%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2)
SPAなどと大体同じ要領で、スタンドアロンでも動作するデスクトップアプリを作れるようになります。
Chromeの開発者ツールも利用できます。
TypeScript
マイクロソフトによって開発されたプログラミング言語です。
JavaScriptを静的型付けの仕様でラッピングしたようなもので、コンパイル後のソースはバイナリファイルではなく、JavaScriptのテキストファイルです。
React
Facebookによって開発された、JavaScriptのライブラリです。
HTMLと、それを操作するJSのロジックをセットにした「コンポーネント」を組み合わせることで、WEBページを構築します。
Material-UI
Googleが提唱するマテリアルデザインに準拠したUIパーツを、Reactのコンポーネントとして提供しているライブラリです。
自分でCSSを用意せずとも、素敵なWEBページをすぐに構築できます。
環境構築
Node.js、npmが既に導入済みであることを前提に進めます。
- Windows10
- Node.js v10.15.1
- npm v6.4.1
TypeScript
インストール
$ npm i -D typescript
設定
TypeScript
プロジェクトのルートフォルダで、下記コマンドを実行して初期設定ファイルを出力します。
コンパイル後のJSのバージョンは2017にしました。
$ npx tsc --init
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"lib": [
"dom",
"es2019"
],
"jsx": "react",
"sourceMap": true,
"strict": true,
"esModuleInterop": true
}
}
ESLintとPrettier
こちらの記事を参考にいたしました。ありがとうございます。
VSCodeでESLint+@typescript-eslint+Prettierを導入する(v2.0.0修正版)
インストール
$ npm i -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
$ npm i -D prettier eslint-config-prettier eslint-plugin-prettier
設定
初期設定ではダブルクォート指定の為、シングルクォートも可に上書きしました。
{
"env": {
"browser": true,
"node": true,
"es6": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:prettier/recommended",
"prettier/@typescript-eslint"
],
"plugins": [
"@typescript-eslint"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module",
"project": "./tsconfig.json"
},
"rules": {
// ...
"prettier/prettier":[
"warn",
{
"singleQuote":true
}
]
}
}
ElectronとWebpack
こちらの記事を参考にいたしました。ありがとうございます。
TypeScriptとElectron
インストール
WebpackでTypeScriptを扱う為のLoaderもインストールします。
$ npm i -D electron webpack webpack-cli ts-loader
設定
Webpack
TypeScriptのコンパイル設定をstrict
に設定している為、nodeのモジュールをrequire
でインポートする際に、暗黙のanyに対してエラーが発生します。
今回は、コメントでエラーをスキップしてみました。
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path');
// メインプロセスの設定
const main = {
mode: 'development',
target: 'electron-main',
entry: path.join(__dirname, 'src', 'main'),
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist')
},
module: {
// ...
};
// レンダラープロセスの設定
const renderer = {
mode: 'development',
target: 'electron-renderer',
devtool: 'inline-source-map',
entry: path.join(__dirname, 'src', 'renderer', 'index'),
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'dist', 'scripts')
},
module: {
// ...
};
module.exports = [main, renderer];
target
は、webpackに環境を伝えるプロパティです。
Targets | webpack
package.json
main
には、エントリーポイントとなるコンパイル後のファイルを指定します。
開発時はwebpackはウォッチモードで。
{
// ...
"main": "dist/main.js",
"scripts": {
"start": "electron .",
"build": "webpack --display-error-details -w",
"production": "webpack --mode=production"
},
// ...
}
ReactとMaterial-UI
インストール
TypeScript用の定義ファイルも一緒に取得します。
$ npm i -D react react-dom @types/react @types/react-dom
$ npm i -D @material-ui/core @material-ui/icons
Material-UIのアイコンも取得しています。
ReactコードのコンパイルはTS側で行うため、Webpackのloaderや設定は不要です。
React用Lintの導入
インストール
$ npm i -D eslint-plugin-react eslint-plugin-react-hooks
設定
.eslintrc.json
に設定を追記します。
Props
の型チェックはTypeScriptで行うので、react/prop-types
はoff
にします。
{
// ...
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:prettier/recommended",
"prettier/@typescript-eslint",
+ "plugin:react/recommended"
],
"plugins": [
+ "react",
+ "react-hooks",
"@typescript-eslint"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module",
"project": "./tsconfig.json",
+ "ecmaFeatures": {
+ "jsx": true
+ }
},
"rules": {
// ...
+ "react/prop-types": "off",
}
}
VSCode
拡張機能のESLintを追加します。
setting.json
にて、入力時にチェックし、保存時にフォーマット修正をするようにします。
"eslint.enable": true,
"eslint.run": "onType",
"eslint.autoFixOnSave": true,
"eslint.validate": [
"javascript",
"javascriptreact",
{"language": "typescript", "autoFix": true },
{"language": "typescriptreact", "autoFix": true }
],
まだまだ理解がふんわりしているのですが、とりあえず環境構築できました。
疲れた。息切れ。
create-react-appを利用すると、もっと手順が減ります。
Electron概要
ElectronでHelloWorld
ファイルの準備
プロジェクトフォルダ内に、下記のファイルとフォルダを準備します。
root
├ package.json
├ dist
│ ├ index.html
│ └ scripts
└ src
├ main.ts
└ renderer
└ index.tsx
アプリのVIEW
後で使うMaterial-UIで利用するフォントのリンクもついでに入れておきます。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Hello Electron</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
</head>
<body>
<div id="contents"></div>
<script type="text/javascript" src="scripts/index.js"></script>
</body>
</html>
import React from 'react';
import ReactDOM from 'react-dom';
import { Paper, Typography, makeStyles, Theme } from '@material-ui/core';
// 適用するCSSクラスの定義
const useStyles = makeStyles((theme: Theme) => ({
root: {
padding: theme.spacing(3)
}
}));
// Headコンポーネントのインターフェースを定義
interface HeadProps {
headline: string;
}
const Head: React.FC<HeadProps> = ({ headline }) => {
return (
<Typography variant="h3" component="h1">
{headline}
</Typography>
);
};
const App: React.FC = () => {
const classes = useStyles();
return (
<Paper className={classes.root}>
<Head headline="Hello World" />
</Paper>
);
};
ReactDOM.render(<App />, document.getElementById('contents'));
React×TypeScriptのサンプルコードで、関数コンポーネントのインターフェースとしてReact.SFC
を利用しているものを見かけますが、廃止予定のようですね。
React 16.7 - React.SFC is now deprecated | stackoverflow
アプリの起動処理
import { BrowserWindow, app } from 'electron';
const mainURL = `file://${__dirname}/index.html`;
let mainWindow: BrowserWindow | null = null;
// アプリ起動後にWindowを立ち上げる
const createWindow = (): void => {
mainWindow = new BrowserWindow({
width: 600,
height: 450,
webPreferences: { nodeIntegration: true }
});
mainWindow.loadURL(mainURL);
// 開発者ツールも同時に開く
mainWindow.webContents.openDevTools();
mainWindow.on('closed', () => {
mainWindow = null;
});
};
// アプリの起動と終了
app.on('ready', createWindow);
app.on('window-all-closed', () => {
app.quit();
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
}
});
webPreferences: { nodeIntegration: true }
は、セキュリティ上好ましくない設定だそうですが、一旦これで進めます。
リモートコンテンツで、Node.js integration を有効にしない | electron
【Electron】nodeIntegration: falseのまま、RendererプロセスでElectronのモジュールを使用する
ChromeのDevToolの拡張機能
公式サイトの手順に従い、React Developer ToolsをElectronの環境でも利用できるようにします。
DevToolを設定するaddDevToolsExtension
メソッドの呼び出しは、ready
イベント後に1度だけ実行します。
import os from 'os';
import path from 'path';
// ...
app.on('ready', () => {
createWindow();
// ReactのDevTool追加
BrowserWindow.addDevToolsExtension(
path.join(
os.homedir(),
'/AppData/Local/Google/Chrome/User Data/Default/Extensions/fmkadmapgofadopljbjfkapdkoienihi/4.2.0_0'
)
);
});
公式サイト上では、便利なサードパーティーツールelectron-devtools-installerが紹介されています。
当初はこちらを利用しようとしたのですが、
ビルド時に依存パッケージの7zip
が無いというエラーが出る → 7zipをインストールする → アプリ起動時にdist\7zip-lite\7z.exe
が無いというエラーが出る
というところで詰まってしまい、一旦外しました。また別の機会に…。
アプリの起動
予めpackage.json
のscript
に登録したコマンドを実行します。
# ソースのコンパイル
$ npm run build
# アプリの起動
$ npm start
無事、DevToolと共に表示されました。
メインプロセスとレンダラープロセス
Electronがpackage.json
のmain
で指定したファイルを実行することで、アプリケーションのメインプロセスが立ち上がります。
メインプロセスはElectronアプリの基幹となり、アプリの実行中に一つだけ存在します。この時点ではまだ何も表示されていません。
メインプロセスからBrowserWindow
のインスタンスを一つ以上生成します。
生成されたBrowserWindow
のインスタンス毎に実行されるプロセスを、レンダラープロセスと呼びます。
このレンダラープロセスは、WEBページの形式でアプリのGUIを担います。
プロセス間の通信
メインプロセスとレンダラープロセスの間で情報をやり取りするには、ipc(inter process communication)モジュールを用います。
メインプロセス向けに用意されたのがipcMain
、レンダラープロセス向けに用意されたのがipcRenderer
です。
これはカスタムイベントに似ています。
特定のイベント名(チャンネル)に紐づく情報の購読登録、およびイベントの発火(情報の送信)を行うことで、非同期的に通信を行います。
// メインプロセス側の購読登録
import { ipcMain, IpcMainEvent } from 'electron';
ipcMain.on('close-window', (event: IpcMainEvent) => {
// ...
});
// レンダラープロセス側の送信
import { ipcRenderer } from 'electron';
ipcRenderer.send('close-window');
メインプロセスからレンダラープロセスに送信する場合、レンダラープロセス側の購読登録はipcRenderer
を利用します。
一方でメインプロセス側からの送信は、送信対象となるレンダラープロセスのBrowserWindow
のインスタンスを利用します。
これにより、他の無関係なレンダラープロセスが受信することはありません。
// レンダラープロセス側の購読登録
import { ipcRenderer, IpcRendererEvent } from 'electron';
ipcRenderer.on('read-file', (event: IpcRendererEvent, data) => {
console.log(data.fileName);
console.log(data.fileData);
});
// メインプロセス側の送信
browserWinObj.webContents.send('read-file', {
fileName,
fileData
});
簡単なテキストエディタを作る
完成図
完全なソースはこちらです。
https://github.com/BeeBow6/electron-text-editor
ファイルの準備
ディレクトリ構成
さらにフォルダとファイルを追加します。
root
├ package.json
├ dist
│ ├ index.html
│ └ scripts
└ src
├ main.ts
+ ├ fileIO.ts
└ renderer
├ index.tsx
+ └ components
+ ├ app.tsx
+ └ menu.tsx
Node.jsを利用したファイル操作
ファイルの読み込みと書き出しをまとめたユーティリティモジュールです。
Node.jsのモジュールを用いてファイル操作を実行します。
メインプロセスとレンダラープロセス、両方から利用できます。
import fs from 'fs';
export enum FILE_EVENTS {
OPEN_DIALOG = 'open_dialog',
SAVE_DIALOG = 'save_dialog',
OPEN_FILE = 'open_file',
SAVE_FILE = 'save_file'
}
export const FILE_FILTERS: {
name: string;
extensions: string[];
}[] = [
{ name: 'Text', extensions: ['txt'] },
{ name: 'All Files', extensions: ['*'] }
];
export interface FileInfoType {
fileName: string;
fileText: string;
}
export const readFile = (fileName: string): string => {
let fileText = '';
try {
fileText = fs.readFileSync(fileName, 'UTF-8');
} catch (e) {
console.log(e);
}
return fileText;
};
export const saveFile = (fileName: string, fileText: string): void => {
try {
fs.writeFileSync(fileName, fileText, 'UTF-8');
} catch (e) {
console.log(e);
}
};
メインプロセス
レンダラープロセス側で「OPEN」「SAVE AS」がクリックされた際の、メインプロセス側での処理をイベントハンドラに登録します。
dialogは、OSのダイアログを利用する為のモジュールです。
このモジュールは基本的にメインプロセス側から出しか利用できません。
remoteモジュールを介することで、レンダラープロセス側からも直接利用することが出来ます。
ただ、モーダル表示にするには第一引数に親ウィンドウのBrowserWindow
オブジェクトを渡す必要があります。
その為メインプロセス側でdiaog
を操作し、結果をレンダラープロセスに送信しています。
処理の流れ
import { BrowserWindow, app, dialog, ipcMain } from 'electron';
import {
FILE_EVENTS,
readFile,
saveFile,
FileInfoType,
FILE_FILTERS
} from './fileIO';
const mainURL = `file://${__dirname}/index.html`;
let mainWindow: BrowserWindow | null = null;
const createWindow = (): void => ...
app.on('ready', () => ...
app.on('window-all-closed', () => ...
app.on('activate', () => ...
// ファイルを開く
ipcMain.on(FILE_EVENTS.OPEN_DIALOG, () => {
if (mainWindow === null) return;
const fileNames: string[] | undefined = dialog.showOpenDialogSync(
mainWindow,
{
properties: ['openFile'],
filters: FILE_FILTERS
}
);
if (!fileNames || !fileNames.length) return;
const fileText = readFile(fileNames[0]);
//レンダラープロセスへ送信
mainWindow.webContents.send(FILE_EVENTS.OPEN_FILE, {
fileName: fileNames[0],
fileText
});
});
// 名前をつけて保存する
ipcMain.on(FILE_EVENTS.SAVE_DIALOG, (_, fileInfo: FileInfoType) => {
if (mainWindow === null) return;
const newFileName: string | undefined = dialog.showSaveDialogSync(
mainWindow,
{
defaultPath: fileInfo.fileName,
filters: FILE_FILTERS
}
);
if (!newFileName) return;
saveFile(newFileName, fileInfo.fileText);
//レンダラープロセスへ送信
mainWindow.webContents.send(FILE_EVENTS.SAVE_FILE, newFileName);
});
レンダラープロセス
WEBで構築するのとほとんど変わりませんが、Node.jsのモジュールを利用できる点は異なります。
ルートコンポーネント
import { ipcRenderer } from 'electron';
import React, { useState, useEffect, useCallback } from 'react';
import { Container, TextField } from '@material-ui/core';
import { FILE_EVENTS, saveFile, FileInfoType } from '../../fileIO';
import Menu from './menu';
// ipcを利用して、メインプロセスにダイアログ表示を依頼
const openFileDialog = (): void => {
ipcRenderer.send(FILE_EVENTS.OPEN_DIALOG);
};
const openSaveAsDialog = (fileInfo: FileInfoType): void => {
ipcRenderer.send(FILE_EVENTS.SAVE_DIALOG, fileInfo);
};
const App: React.FC = () => {
const [text, setText] = useState('');
const [fileName, setFileName] = useState('');
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.currentTarget.value);
}, []);
const handleFileSave = useCallback(() => {
if (fileName) {
saveFile(fileName, text);
} else {
openSaveAsDialog({
fileName: '',
fileText: text
});
}
}, [fileName, text]);
const handleFileSaveAs = useCallback(() => {
openSaveAsDialog({
fileName: fileName,
fileText: text
});
}, [fileName, text]);
// Dialog選択結果の取得
useEffect(() => {
// 開いたファイルの名前とデータを取得
ipcRenderer.on(FILE_EVENTS.OPEN_FILE, (_, fileInfo: FileInfoType) => {
setText(fileInfo.fileText);
setFileName(fileInfo.fileName);
});
// 別名保存した際の名前を取得
ipcRenderer.on(FILE_EVENTS.SAVE_FILE, (_, newFileName: string) => {
setFileName(newFileName);
});
return (): void => {
ipcRenderer.removeAllListeners(FILE_EVENTS.OPEN_FILE);
ipcRenderer.removeAllListeners(FILE_EVENTS.SAVE_FILE);
};
}, []);
return (
<Container>
<Menu
onFileOpen={openFileDialog}
onFileSave={handleFileSave}
onFileSaveAs={handleFileSaveAs}
/>
<TextField
multiline
fullWidth
variant="outlined"
rows={10}
rowsMax={20}
value={text}
inputProps={{
style: {
fontSize: 14
}
}}
onChange={handleChange}
helperText={fileName || '[Untitled]'}
/>
</Container>
);
};
export default App;
メニューコンポーネント
import React from 'react';
import { Button, ButtonGroup, makeStyles, Theme } from '@material-ui/core';
import { Save, FolderOpen, NoteAdd } from '@material-ui/icons';
const useStyles = makeStyles((theme: Theme) => ({
buttonWrp: {
margin: theme.spacing(2, 0, 3)
},
buttonIcon: {
marginLeft: theme.spacing(0.5)
}
}));
interface MenuProps {
onFileOpen: () => void;
onFileSave: () => void;
onFileSaveAs: () => void;
}
const Menu: React.FC<MenuProps> = props => {
const classes = useStyles();
return (
<ButtonGroup
variant="outlined"
color="primary"
className={classes.buttonWrp}
>
<Button onClick={props.onFileOpen}>
OPEN
<FolderOpen className={classes.buttonIcon} />
</Button>
<Button onClick={props.onFileSave}>
SAVE
<Save className={classes.buttonIcon} />
</Button>
<Button onClick={props.onFileSaveAs}>
SAVE AS
<NoteAdd className={classes.buttonIcon} />
</Button>
</ButtonGroup>
);
};
export default Menu;
ボタンのアルファベット文字列
ボタンの文字列は、小文字を指定しても大文字に変換されます。
これは、Googleが提唱するマテリアルデザインにて、本文と区別する為にボタンは全て大文字にするべきとしている為です。
これを防ぐには、ボタンのラベルのスタイルをtextTransform:'none'
に変更します。
下記例ではテーマごと上書きしており、配下の全てのButtonコンポーネントに適用されるようにしています。
Customizing components | Material-UI
import { MuiThemeProvider, createMuiTheme, Button } from '@material-ui/core';
const theme = createMuiTheme({
typography: {
button: {
textTransform: 'none'
}
}
});
const CustomTheme: React.FC = () => (
<MuiThemeProvider theme={theme}>
<Button variant="contained">hogehogehoge</Button>
</MuiThemeProvider>
);
index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/app';
ReactDOM.render(<App />, document.getElementById('contents'));
ビルドしてエレクトロンをデバッグ実行(electron .
)すると、最低限の機能を持ったテキストエディタが立ち上がります。
アプリのパッケージ化
作成したアプリを、Winows向けにパッケージングします。
パッケージングには、electron-packagerを利用します。
$ npm i -D electron-packager
package.jsonにもパッケージ用スクリプトを追加しておきます。
左から、対象ディレクトリ("main"で指定しているもの),アプリ名,環境(Windows x64),Electronバージョン です。
{
// ...
"main": "dist/main.js",
"scripts": {
"start": "electron .",
"build": "webpack --display-error-details -w"
// electron-packager <sourcedir> <appname> --platform=<platform> --arch=<arch> [optional flags...]
+ "package" : "electron-packager . electronTextEditor --platform=win32 --arch=x64 --electron-version=6.0.11"
},
// ...
}
上記コマンドを実行すると、「electronTextEditor-win32-x64」フォルダとその中に「electronTextEditor.exe」が出力されます。
おぉ…アプリができた…!
参考情報
Building a desktop application with Electron
Meet Material-UI — your new favorite user interface library
TypeScript and React: Hooks
electron-sample-apps/mini-code-editor/
any型で諦めない React.EventCallback
ようこそ!Electron入門
Electronの手習い〜Electron環境からパッケージ化まで〜
Material-UI と styled-components を組み合わせて、React のサイトを怠惰にスタイリングする。