LoginSignup
71
78

More than 3 years have passed since last update.

Electron, TypeScript, React, Material-UI で簡単なメモ帳アプリを作る

Last updated at Posted at 2019-10-22

Electron、TypeScript、React、Material-UI の学習メモです。

概要

Electron

Wikipediaの記事が端的で分かりやすかったので転載。

Electronは、GitHubが開発したオープンソースのソフトウェアフレームワークである。

ChromiumNode.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の開発者ツールも利用できます。

Electron

TypeScript

マイクロソフトによって開発されたプログラミング言語です。
JavaScriptを静的型付けの仕様でラッピングしたようなもので、コンパイル後のソースはバイナリファイルではなく、JavaScriptのテキストファイルです。

TypeScript

React

Facebookによって開発された、JavaScriptのライブラリです。
HTMLと、それを操作するJSのロジックをセットにした「コンポーネント」を組み合わせることで、WEBページを構築します。

React

Material-UI

Googleが提唱するマテリアルデザインに準拠したUIパーツを、Reactのコンポーネントとして提供しているライブラリです。
自分でCSSを用意せずとも、素敵なWEBページをすぐに構築できます。

Material Design
Material-UI

環境構築

Node.js、npmが既に導入済みであることを前提に進めます。

  • Windows10
  • Node.js v10.15.1
  • npm v6.4.1

TypeScript

インストール

$ npm i -D typescript

設定

TypeScript

プロジェクトのルートフォルダで、下記コマンドを実行して初期設定ファイルを出力します。
コンパイル後のJSのバージョンは2017にしました。

$ npx tsc --init
tsconfig.json
{
  "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修正版)

インストール

ESLint
$ npm i -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
Prettier
$ npm i -D prettier eslint-config-prettier eslint-plugin-prettier

設定

初期設定ではダブルクォート指定の為、シングルクォートも可に上書きしました。

.eslintrc.json
{
  "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に対してエラーが発生します。
今回は、コメントでエラーをスキップしてみました。

webpack.config.js
// 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はウォッチモードで。

package.json
{
  // ...
  "main": "dist/main.js",
  "scripts": {
    "start": "electron .",
    "build": "webpack --display-error-details -w",
    "production": "webpack --mode=production"
  },
  // ...
}

ReactとMaterial-UI

インストール

TypeScript用の定義ファイルも一緒に取得します。

React
$ npm i -D react react-dom @types/react @types/react-dom
Material-UI
$ 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-typesoffにします。

.eslintrc.json
{
  // ...
  "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で利用するフォントのリンクもついでに入れておきます。

/dist/index.html
<!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>
/src/renderer/index.tsx
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

アプリの起動処理

/src/main.ts
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度だけ実行します。

/src/main.ts
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.jsonscriptに登録したコマンドを実行します。

# ソースのコンパイル
$ npm run build

# アプリの起動
$ npm start

hello_world.png

無事、DevToolと共に表示されました。

メインプロセスとレンダラープロセス

Electronがpackage.jsonmainで指定したファイルを実行することで、アプリケーションのメインプロセスが立ち上がります。
メインプロセスはElectronアプリの基幹となり、アプリの実行中に一つだけ存在します。この時点ではまだ何も表示されていません。
メインプロセスからBrowserWindowのインスタンスを一つ以上生成します。
生成されたBrowserWindowのインスタンス毎に実行されるプロセスを、レンダラープロセスと呼びます。
このレンダラープロセスは、WEBページの形式でアプリのGUIを担います。

プロセス流れ.png

プロセス間の通信

メインプロセスとレンダラープロセスの間で情報をやり取りするには、ipc(inter process communication)モジュールを用います。
メインプロセス向けに用意されたのがipcMain、レンダラープロセス向けに用意されたのがipcRendererです。
これはカスタムイベントに似ています。
特定のイベント名(チャンネル)に紐づく情報の購読登録、およびイベントの発火(情報の送信)を行うことで、非同期的に通信を行います。

IPC.png

// メインプロセス側の購読登録
import { ipcMain, IpcMainEvent } from 'electron';

ipcMain.on('close-window', (event: IpcMainEvent) => {
  // ...
});

// レンダラープロセス側の送信
import { ipcRenderer } from 'electron';
ipcRenderer.send('close-window');

メインプロセスからレンダラープロセスに送信する場合、レンダラープロセス側の購読登録はipcRendererを利用します。
一方でメインプロセス側からの送信は、送信対象となるレンダラープロセスのBrowserWindowのインスタンスを利用します。
これにより、他の無関係なレンダラープロセスが受信することはありません。

IPC_2.png

// レンダラープロセス側の購読登録
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
});

簡単なテキストエディタを作る

完成図

image.png

完全なソースはこちらです。
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のモジュールを用いてファイル操作を実行します。
メインプロセスとレンダラープロセス、両方から利用できます。

fileIO.png

/src/fileIO.ts
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を操作し、結果をレンダラープロセスに送信しています。

処理の流れ

dialog.png

/src/main.ts
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のモジュールを利用できる点は異なります。

ルートコンポーネント

/src/renderer/components/app.tsx
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;

メニューコンポーネント

/src/renderer/components/menu.tsx
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>
);

ボタンキャプチャ.PNG

index.tsx

/src/renderer/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バージョン です。

package.json
{
  // ...
  "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 のサイトを怠惰にスタイリングする。

71
78
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
71
78