LoginSignup
1
2

More than 1 year has passed since last update.

Electron + TypeScript で Web APIを呼び出すアプリを作る

Last updated at Posted at 2022-08-13

はじめに

(2022年11月13日追記) 郵便番号検索APIをrender.jsから呼び出していましたが、mainから呼び出すように変更しました。

Electron の勉強のため簡単なアプリケーションを作ってみました。
こんな感じの画面になります。

image.png

機能は次の通りです。

  1. 郵便番号を入力して住所を検索する。
    住所の検索には Web API を使用しています。
  2. 検索結果を画面に表示する。
    グリッド形式で表示してみました。
  3. 住所の検索結果をファイルに保存する。
    保存するときにはダイアログを表示してファイル名を指定できるようにしています。

開発

住所を検索する Web API

郵便番号から住所を検索する API は次のサイトのものを使用しました。

curl コマンドで実行してみると、以下のような JSON 形式で住所情報が返ってくることがわかります。

$ curl -i "https://zipcloud.ibsnet.co.jp/api/search?zipcode=1000001"
HTTP/2 200
access-control-allow-origin: *
content-type: text/plain;charset=utf-8
x-cloud-trace-context: 2a2e857d7c90820992f424cfcb10a6ee
date: Fri, 12 Aug 2022 12:55:21 GMT
server: Google Frontend
content-length: 287

{
	"message": null,
	"results": [
		{
			"address1": "東京都",
			"address2": "千代田区",
			"address3": "千代田",
			"kana1": "トウキョウト",
			"kana2": "チヨダク",
			"kana3": "チヨダ",
			"prefcode": "13",
			"zipcode": "1000001"
		}
	],
	"status": 200
}

プロジェクト作成

まずはプロジェクトを作成します。

$ mkdir webapi
$ cd webapi
$ npm init
$ npm install --save-dev typescript ts-loader
$ npm install --save electron

TypeScript のトランスパイルのための設定ファイルを作成します。

$ tsc init

次に package.json を修正します。scripts の部分で build と start を追加しています。tsc で TypeScript をトランスパイル後に実行するようにしています。

  "scripts": {
    "build": "tsc",
    "start": "npm run build && electron ./index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

Web API を呼び出す

アプリケーションの雛形(index.ts)は次のサイトを参考にして作りました。SampleApp というクラスにまとまっていて、すごく見やすいです。

index.ts はほぼこのサイトのものと同じです。後ほどになりますが、データを保存するコードをここに追加します。

index.ts
import { BrowserWindow, app, App } from "electron";

class SampleApp {
  private mainWindow: BrowserWindow | null = null;
  private app: App;
  private mainURL: string = `file://${__dirname}/index.html`;

  constructor(app: App) {
    this.app = app;
    this.app.on("window-all-closed", this.onWindowAllClosed.bind(this));
    this.app.on("ready", this.create.bind(this));
    this.app.on("activate", this.onActivated.bind(this));
  }

  private onWindowAllClosed() {
    this.app.quit();
  }

  private create() {
    this.mainWindow = new BrowserWindow({
      width: 800,
      height: 600,
    });

    this.mainWindow.loadURL(this.mainURL);

    this.mainWindow.on("closed", () => {
      this.mainWindow = null;
    });
  }

  private onReady() {
    this.create();
  }

  private onActivated() {
    if (this.mainWindow === null) {
      this.create();
    }
  }
}

const MyApp: SampleApp = new SampleApp(app);

index.html を作ります。
郵便番号を入れる入力エリアと検索ボタンがある簡単なものです。結果を表示する場所は <p id="address"></p> で定義しています。このバージョンではグリッド表示ではなく単純なテキスト表示です。(グリッドでの表示は後ほど行います。) 最後にレンダラーで使用する renderer.js を読み込んでいます。

index.html
<!DOCTYPE html>
<html>
  <body>
    <h1>郵便番号検索</h1>
    <input type="text" id="zipcode" />
    <button type="button" class="btn btn-primary" id="search">Search</button>
    <br />
    <p id="address"></p>
  </body>
  <script src="./renderer.js"></script>
</html>

renderer.js を作ります。
searchAddress 関数は、指定された郵便番号を使用して住所を検索します。このとき fetch() を使用して Web API を呼び出しています。関数の戻り値は検索結果の住所を文字列として連結したものです。また、検索ボタンがクリックされたときに発火するイベントリスナーを設定しています。検索ボタンが押されると、searchAddress よ呼び出して、結果を id="address" のエレメントに設定します。

renderer.js
async function searchAddress(zipcode) {
  const response = await fetch(
    `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipcode}`
  );
  const json = await response.json();
  let addresses = "";
  for (let result of json.results) {
    addresses += result.address1 + result.address2 + result.address3 + " ";
  }
  return addresses;
}

const zipcode = document.getElementById("zipcode");
const address = document.getElementById("address");
const search = document.getElementById("search");

if (search !== null) {
  search.addEventListener("click", () => {
    if (zipcode !== null) {
      searchAddress(zipcode.value).then((addresses) => {
        console.log(addresses);
        if (address !== null) {
          address.innerText = addresses;
        }
      });
    }
  });
}

この状態で実行してみます。実行は次のコマンドで行うことができます。

$ npm start

Web API を呼び出して、無事に結果を表示することができました。

image.png

検索結果をグリッド表示にする

検索結果をグリッド表示にします。グリッドにはGridjsを使用しました。

先ほど作成した index.html を修正します。
最初に gridjs.umd.js と mermaid.css を取り込みます。

<link href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css" rel="stylesheet" />
<script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>

前の結果表示箇所 <p id="address"></p> の部分を書き換えます。
Web API からメッセージを id="message" の部分に赤色で表示するようにしました。郵便番号の桁数が足りないなどエラーメッセージがここに赤色で出力されます。グリッドを表示する場所は id="address_grid" の部分になります。

<p id="message" , style="color: #f00"></p>
<div id="address_grid"></div>

ここまでの修正を反映した index.html は次のようになります。

index.html
<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css"
      rel="stylesheet"
    />
    <script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
  </head>
  <body>
    <h1>Web API Sample</h1>
    <input type="text" id="zipcode" />
    <button type="button" class="btn btn-primary" id="search">Search</button>
    <br />
    <p id="message" , style="color: #f00"></p>
    <div id="address_grid"></div>
  </body>
  <script src="renderer.js" charset="UTF-8"></script>
</html>

続いて renderer.js を修正します。
グリッドは再検索時に上書きできるようにグローバル変数で定義しました。検索結果が0件の時は空のリストを指定して検索結果をクリアしています。また、グリッドには 6 件ごとにページネーションをする指定を入れています。

renderer.js
async function searchAddress(zipcode) {
  let response = await fetch(
    `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipcode}`
  );
  return response.json();
}

var grid = null;
document.getElementById("search").addEventListener("click", () => {
  const zipcode = document.getElementById("zipcode").value;
  searchAddress(zipcode).then((response) => {
    const message = document.getElementById("message");
    message.innerHTML = response.message;
    let results = response.results;
    if (results === null) {
      results = [];
    }
    if (grid) {
      grid
        .updateConfig({
          data: results,
        })
        .forceRender();
    } else {
      grid = new gridjs.Grid({
        columns: [
          { id: "zipcode", name: "郵便番号" },
          { id: "address1", name: "都道府県" },
          { id: "address2", name: "市区町村" },
          { id: "address3", name: "町域" },
        ],
        data: results,
        pagination: {
          limit: 6,
        },
      }).render(document.getElementById("address_grid"));
    }
  });
});

プログラムを実行してみると、検索結果がグリッドで表示されるようになりました。

image.png

検索結果をファイルに保存する

ここまでは普通の Web アプリケーションがやっていることと同じです。ここからは Node API の機能を使って住所の検索結果をローカル PC に保存してみます。
Electron ではレンダラーから直接 Node API を呼び出すことができないので、 preload.js を定義してレンダラー側から呼び出せるようにします。(この例では preload.ts でファイルを作成しています。トランスパイル後に preload.js が作成され、実行時にはこの preload.js が使用されます。)

index.ts の BrowserWindow 作成時に preload の指定を追加します。これで preload された関数をレンダラーから呼び出すことができるようになります。

    this.mainWindow = new BrowserWindow({
      width: 800,
      height: 600,
      webPreferences: {
        preload: path.join(__dirname, "preload.js"),
      },
    });

preload.ts は次のように作成します。レンダラーから window.api.saveData() を呼び出せば、ファイルの保存ができるようになります。

preload.ts
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("api", {
  saveData: (data: object) => ipcRenderer.invoke("saveData", data),
});

最後に index.ts に saveData が呼び出された時のハンドラを定義します。ハンドラの設定は SampleApp コンストラクタ内で行なっています。ここでは saveData ハンドラが呼び出されたときにhandleSaveData メソッドが呼び出されるようにバインドしています。

    ipcMain.handle("saveData", this.handleSaveData.bind(this));

handleSaveData は次のようになります。

  private async handleSaveData(
    event: Electron.IpcMainInvokeEvent,
    data: object
  ) {
    try {
      const result = await dialog.showSaveDialog({
        properties: ["createDirectory"],
      });
      if (result && result.filePath) {
        await fs.writeFile(result.filePath, JSON.stringify(data, null, "  "));
      }
    } catch (err: any) {
      console.error(err.toString());
    }
  }
}

ファイルを保存するためのダイアログは Electron の dialog モジュールにある showSaveDialog を使用しています。ダイアログ作成時に createDirectory を指定していますが、これはダイアログでフォルダの作成が行えるようにするものです。(このオプションは Mac のみ有効です。) オプションを追加することで、ダイアログのタイトルを変えたりボタンの名前を変えたりすることもできます。

ファイルへの書き込みは Node API を使用しています。昔からある fs モジュールを使用しても良いのですが、ここでは Promise に対応した fs を見つけたのでそちらを使用してみます。まずは Promise 版の fs をインポートします。

import { promises as fs } from "fs";

次にファイルへ書き込みを行います。 data はオブジェクトなので、JSON.stringify() を使って JSON 文字列に変換しています。また、JSONを見やすくするために第 3 引数にインデントのためのスペースを指定しています。

await fs.writeFile(result.filePath, JSON.stringify(data, null, "  "));

プログラムを実行してみます。保存ボタンを押すとダイアログが表示されて、ファイルが保存できるようになりました。

image.png

保存されたファイルは次のようになります。

address.json
[
  {
    "address1": "岡山県",
    "address2": "岡山市北区",
    "address3": "",
    "kana1": "オカヤマケン",
    "kana2": "オカヤマシキタク",
    "kana3": "",
    "prefcode": "33",
    "zipcode": "7000000"
  },
  {
    "address1": "岡山県",
    "address2": "岡山市中区",
    "address3": "",
    "kana1": "オカヤマケン",
    "kana2": "オカヤマシナカク",
    "kana3": "",
    "prefcode": "33",
    "zipcode": "7000000"
  },
(以下省略)

パッケージの作成

プログラムの作成は前章で終わりですが、ここではプログラムを配布できるようにパッケージを作成してみます。パッケージの作成には electron-builder を使用しています。

まずは electron-builder のインストールです。

$ npm install --save-dev electron-builder

package.json に次の build 部分を追加します。配布物の出力先ディレクトリ、配布物に含めるファイルを指定しています。

  "build": {
    "appId": "com.example.sample.webapi",
    "directories": {
      "output": "dist"
    },
    "files": [
      "index.html",
      "index.js",
      "package-lock.json",
      "package.json",
      "preload.js",
      "renderer.js"
    ]
  },

ビルドします。

$ npx electron-builder

オプションを何も指定しないと、ローカル PC に合わせたパッケージができるようです。私は Mac を使っているので、webapi-sample.app というファイルが作成されていました。

使用している PC は Mac ですが、 Windows 向けのインストーラーも作成することができるようです。次のコマンドを実行すると Windows 用のインストーラーが作成されます。

$ npx electron-builder --win --x64

最終版のソースコード

最終版のソースコードは次のようになりました。

index.html
<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css"
      rel="stylesheet"
    />
    <script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
  </head>
  <body>
    <h1>郵便番号検索</h1>
    <input type="text" id="zipcode" />
    <button type="button" class="btn btn-primary" id="search">検索</button>
    <br />
    <p id="message" , style="color: #f00"></p>
    <div id="address_grid"></div>
    <button type="button" class="btn" id="save" disabled>保存</button>
  </body>
  <script src="renderer.js" charset="UTF-8"></script>
</html>
index.ts
import { BrowserWindow, app, App, ipcMain, dialog } from "electron";
import path = require("path");
import { promises as fs } from "fs";

const DEBUG_MODE = false;

class SampleApp {
  private mainWindow: BrowserWindow | null = null;
  private app: App;
  private mainURL: string = `file://${__dirname}/index.html`;

  constructor(app: App) {
    this.app = app;
    this.app.on("window-all-closed", this.onWindowAllClosed.bind(this));
    this.app.on("ready", this.create.bind(this));
    this.app.on("activate", this.onActivated.bind(this));
    ipcMain.handle("saveData", this.handleSaveData.bind(this));
  }

  private onWindowAllClosed() {
    this.app.quit();
  }

  private create() {
    let windowSize = { width: 800, height: 600 };
    if (DEBUG_MODE) {
      const { screen } = require("electron");
      const primaryDisplay = screen.getPrimaryDisplay();
      windowSize = primaryDisplay.workAreaSize;
    }
    this.mainWindow = new BrowserWindow({
      width: windowSize.width,
      height: windowSize.height,
      webPreferences: {
        preload: path.join(__dirname, "preload.js"),
      },
    });

    this.mainWindow.loadURL(this.mainURL);

    if (DEBUG_MODE) {
      this.mainWindow.webContents.openDevTools();
    }

    this.mainWindow.on("closed", () => {
      this.mainWindow = null;
    });
  }

  private onReady() {
    this.create();
  }

  private onActivated() {
    if (this.mainWindow === null) {
      this.create();
    }
  }

  private async handleSaveData(
    event: Electron.IpcMainInvokeEvent,
    data: object
  ) {
    try {
      const result = await dialog.showSaveDialog({
        properties: ["createDirectory"],
      });
      if (result && result.filePath) {
        await fs.writeFile(result.filePath, JSON.stringify(data, null, "  "));
      }
    } catch (err: any) {
      console.error(err.toString());
    }
  }
}

const MyApp: SampleApp = new SampleApp(app);
preload.ts
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("api", {
  saveData: (data: object) => ipcRenderer.invoke("saveData", data),
});
renderer.js
"use strict";

async function searchAddress(zipcode) {
  let response = await fetch(
    `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipcode}`
  );
  return response.json();
}

let grid = null;
let results;

document.getElementById("search").addEventListener("click", () => {
  const zipcode = document.getElementById("zipcode").value;
  searchAddress(zipcode).then((response) => {
    const message = document.getElementById("message");
    message.innerHTML = response.message;
    results = response.results;
    if (results === null) {
      results = [];
    }
    if (grid) {
      grid
        .updateConfig({
          data: results,
        })
        .forceRender();
    } else {
      grid = new gridjs.Grid({
        columns: [
          { id: "zipcode", name: "郵便番号" },
          { id: "address1", name: "都道府県" },
          { id: "address2", name: "市区町村" },
          { id: "address3", name: "町域" },
        ],
        data: results,
        pagination: {
          limit: 6,
        },
      }).render(document.getElementById("address_grid"));
    }
    document.getElementById("save").disabled = results.length === 0;
  });
});

document.getElementById("save").addEventListener("click", () => {
  console.log("clicked: save button");
  window.api.saveData(results);
});
package.json
{
  "name": "webapi-sample",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "tsc",
    "start": "npm run build && electron ./index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "build": {
    "appId": "com.example.sample.webapi",
    "directories": {
      "output": "dist"
    },
    "files": [
      "index.html",
      "index.js",
      "package-lock.json",
      "package.json",
      "preload.js",
      "renderer.js"
    ]
  },
  "author": "",
  "license": "ISC"
}

最後に

夏休みの宿題、ではありませんが、 Electron を使った簡単なアプリケーションを作成することができるようになりました。まだ TypeScript や JavaScript に慣れてないので、まだまだこれからといった感じです。今回は画面アプリケーションをフレームワークを使わずに作っていますが、もっと楽に作るには、もっといろいろなことをやるには React とか Vue.js も使いながら開発を進めるのでしょうね...まだまだ先は長いです。

参考サイト

(追記) 郵便番号検索APIをmainから呼び出すようにする

render.jsの変更

修正前のソースでは、下記のように直接render.jsから呼び出しています。

render.js(修正前)
async function searchAddress(zipcode) {
  let response = await fetch(
    `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipcode}`
  );
  return response.json();
}

document.getElementById("search").addEventListener("click", () => {
  const zipcode = document.getElementById("zipcode").value;
  searchAddress(zipcode).then((response) => {
    const message = document.getElementById("message");
    message.innerHTML = response.message;
    results = response.results;

これを下記のように変更しました。

render.js(修正後)
document.getElementById("search").addEventListener("click", () => {
  const zipcode = document.getElementById("zipcode").value;
  window.api.searchAddress(zipcode).then((response) => {
    const message = document.getElementById("message");
    message.innerHTML = response.message;
    results = response.results;

preload.tsの変更

mainのメソッドを呼び出すためにsearchAddressを1行追加します。

preload.ts
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("api", {
  saveData: (data: object) => ipcRenderer.invoke("saveData", data),
  searchAddress: (data: object) => ipcRenderer.invoke("searchAddress", data),
});

index.tsの変更

ソースを修正する前に、HTTPリクエストを呼び出すためのrequest-promiseをインストールします。

$ npm install request-promise --save

request-promiseを読み込んで

const rp = require("request-promise");

searchAddressのハンドラを追加して、

class SampleApp {
    constructor(app) {
        this.mainWindow = null;
        this.mainURL = `file://${__dirname}/index.html`;
        this.app = app;
        this.app.on("window-all-closed", this.onWindowAllClosed.bind(this));
        this.app.on("ready", this.create.bind(this));
        this.app.on("activate", this.onActivated.bind(this));
        electron_1.ipcMain.handle("saveData", this.handleSaveData.bind(this));
        electron_1.ipcMain.handle("searchAddress", this.handleSearchAddress.bind(this));
    }

ハンドラを実装します。

    handleSearchAddress(event, zipcode) {
        return __awaiter(this, void 0, void 0, function* () {
            const url = `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipcode}`;
            try {
                let response = yield rp({
                    url: url,
                    json: true,
                });
                console.log(response);
                return response;
            }
            catch (err) {
                console.error(err);
            }
        });
    }

ソースをGitHubに追加しました。

1
2
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
1
2