Electron + TypeScript + Vue.jsでデスクトップアプリ(2)

はじめに

こんにちは.最近,「こちら側のどこからも開けられます」がどちら側のどこからも開けられません.

さて,以前, Electron + TypeScript + Vue.jsでデスクトップアプリ(1) -はじめのはじめ-という記事を書きました.今回はその続きとなるのですが,このとき作ろうとしていたアプリではなく,新しいもので行こうと思います.

というのも,そのアプリはとあるソーシャルゲームのガチャの記録を取って統計情報をどうこうしようというものだったのですが,記事を書いてからしばらくして,私はそのゲームをまったくやらなくなりアプリも必要なくなったのでレポジトリごと削除してしまったのでした.

というわけで,あらためて行ってみたいと思います.せっかくなのでツールセットも新しめのものにしました.

まぁおそらく中身はそれほど変わらないと思います.

こんな感じになります.

今回作るアプリ
今回作るアプリ

開発環境

こんかいの開発環境は次のとおりです.

  • OS: Ubuntu 18.04 64bit
  • メモリ: 7.7GiB
  • typescript: 2.6.2
  • npm: 5.5.1
  • node: v8.9.3
  • electron: v2.0.3
  • yarn: 1.7.0

リストからもわかるように,yarnを導入してみました.導入についてはここでは割愛します.

こんかい作るアプリ

アプリのネタは少し悩んだのですが,ここさいきんはとくに意味もなくRFCを眺めていることが多いことに気づき,いい感じに検索したりなんだりできるものを作ることにします.

ただし,この記事では,RFCを一覧表示するところまでにします(時間がない

ちなみにRFCは

RFC INDEX

ここから見ることができます.

最終的には文書を表示したり検索・フィルタリングしたりの機能を搭載する予定です.

構成

プロジェクトのディレクトリ構成は

.
├── src
│   ├── common
│   │   └── rfcInformations.ts
│   ├── main
│   │   ├── lib
│   │   │   └── fetchRFC.ts
│   │   └── main.ts
│   └── renderer
│       ├── components
│       │   └── RfcCard.vue
│       ├── fonts
│       ├── App.vue
│       ├── index.pug
│       ├── index.ts
│       ├── sfc.d.ts
│       └── tsconfig.json
├── .editorconfig
├── .gitignore
├── package.json
├── tsconfig.json
├── tslint.json
└── yarn.lock

こんな感じです(node_modules 等いくつかの本質的でないものは省略してあります).

src/main にメインプロセス側のコード,src/renderer にレンダラプロセス側のコードをおいています.

package.json (のdependencies )は以下のようになります.

  "devDependencies": {
    "@types/electron": "^1.6.10",
    "@types/fs-extra": "^5.0.3",
    "@types/jsdom": "^11.0.6",
    "@types/node": "^10.5.0",
    "@types/node-fetch": "^2.1.1",
    "@types/vue": "^2.0.0",
    "css-loader": "^0.28.11",
    "electron": "^2.0.3",
    "electron-packager": "^12.1.0",
    "fs-extra": "^6.0.1",
    "gulp": "4",
    "gulp-pug": "^4.0.1",
    "gulp-typescript": "^5.0.0-alpha.2",
    "jsdom": "^11.11.0",
    "node-fetch": "^2.1.2",
    "pug": "^2.0.3",
    "pug-html-loader": "^1.1.5",
    "pug-loader": "^2.4.0",
    "pug-plain-loader": "^1.0.0",
    "raw-loader": "^0.5.1",
    "style-loader": "^0.21.0",
    "ts-loader": "^4.4.2",
    "ts-node": "^7.0.0",
    "tslint": "^5.10.0",
    "tslint-config-airbnb": "^5.9.2",
    "typescript": "^2.9.2",
    "vue": "^2.5.16",
    "vue-class-component": "^6.2.0",
    "vue-loader": "^15.2.4",
    "vue-property-decorator": "^7.0.0",
    "vue-template-compiler": "^2.5.16",
    "vuetify": "^1.0.19",
    "webpack": "4.12.0",
    "webpack-cli": "^3.0.8",
    "webpack-stream": "^4.0.3"
  }

さいきん知ったのですが,electronアプリをパッケージングするとき,ネイティブモジュール以外はdevDependencies において良いようです.必要なものはwebpack がすべて1つのファイルにまとめてくれるのでyarn –prod するとnode_modules は空になります.

では,コードを見ていきましょう.

処理というか

大まかな処理の内容としては,(現時点では)次のようになります.

  1. アプリ起動
  2. ウィンドウサイズ等復元
  3. ブラウザウィンドウ生成
  4. メインプロセスで先のRFC Indexページのhtmlファイルをフェッチnode-fetch 使用)
  5. がんばってスクレイピングjsdom 使用)
  6. それをオブジェクトに詰めてレンダラプロセスに渡す
  7. 表示

1-3までは実は前回の記事とまったく同じなので省略しましょう.

node-fetch

ブラウザ環境のJavaScriptにはfetch というAPIがあります.次世代APIなので対応してない場合もありそうですが.要するに,これまで使われてきたXMLHttpRequest を代替するもので,コールバック型だったXHRから,はやり(?)のPromise 型でコードが書けますよ,というものです.

Promise だとなにがうれしいか,っていうのはおそらくみなさま私よりお詳しいでしょうからここでは言わないことにします.

で,node環境でもこのfetch を使えるようにするモジュールがnode-fetch です.

node-fetch

これを使ってRFCの一覧ページを取得してみましょう.

import nodeFetch from 'node-fetch';

const rfcIndexUrl = 'https://www.rfc-editor.org/rfc-index.html';
export async function fetchRFC(): Promise<RFCInformation[]> {
  return nodeFetch(rfcIndexUrl)
    .then(res => res.text())
    .then((body) => {
      // フェッチしたhtmlがテキストでくるのでここでスクレイピング

      // オブジェクトを詰めて返す
      return entities;
    })
    .catch((err) => {
      return null;
    });
}

import するときにfetch とすればブラウザ環境と同じようになるのですが,linterがうるさいのでnodeFetch としてあります.

ちなみにここで登場するRFCInformation は以下です.

export interface OtherInformations {
  formats: Format[];
  status: string;
  stream: Stream;
  obsolete?: Obsolete;
  update?: Update;
  also?: Also;
  doi: string;
}

export interface RFCInformation {
  number: number;
  title: string;
  authors: string[];
  published: { year: number, month: number };
  other: OtherInformations;
}

一部省略しましたが,RFC Indexのページにある情報は一通り格納できます.

jsdom

さて,フェッチしてきたHTMLをスクレイピングしていくわけなんですが,JavaScriptはもともとHTMLのDOM操作についてはもちろんお手の物ですよね.なのですが,node環境のJavaScriptは……そこでブラウザ環境と同様のDOM操作APIを使えるようにしてくれるのがjsdomです.

jsdom

使い方はふっつーのDOM操作です.

      const table = new JSDOM(body).window.document.querySelectorAll('table')[2];
      const entities: RFCInformation[] = [];
      for (const r of table.tBodies[0].rows) {
        const nMatch = r.cells[0].textContent.replace(/\n/g, '').match(/\s(\d+)/);
        if (nMatch == null || nMatch.length <= 1) continue;
        const num = nMatch[1];

        const info = r.cells[1].innerHTML
          .replace(/\n/g, '') // omits any new lines
          .replace(/\s+/g, ' ') // compresses consecctive spaces
          .replace(/<script[^>]+>[^<]+<\/script>/g, '') // removes <script> tags
          .replace(/<noscript>(RFC)?([^<]+)<\/noscript>/g, '$2'); // removes <noscript> tags
        const matches = info.match(/<b>([^<]+)<\/b>\s([^[]+)\[([^]+)\]\s((\([^)]+\)\s?)+)/);
        if (matches == null || matches.length <= 1)  continue;

        const d = matches[3].trim().split(' ');

        entities.push({
          number: parseInt(num),
          title: matches[1].trim(),
          authors: matches[2].trim().split(','),
          published: { year: parseInt(d[1]), month: months.indexOf(d[0]) },
          other: createOtherInformations(matches[4]),
        });

ブラウザ環境とぜんぜん変わりません(jQueryを使う古代の人はいませんよね?)

とやって作ったオブジェクトはローカルにキャッシュ(というか単にJSONとしてファイルに書き出して)するようにしました(コード省略).

あとはこれを画面に出すだけです.

表示する

レンダラ側は前回と同じくVue.jsなのですが,レイアウトやコンポーネント等をVuetifyを使って作りました.

import Vue from 'vue';
import Component from 'vue-class-component';

import { ipcRenderer } from 'electron';
import { RFCInformation } from '../common/rfcInformations';

import RfcCard from './components/RfcCard.vue';

@Component({
  name: 'app',
  components: {
    RfcCard
  }
})
export default class App extends Vue {
  rfcInformations: RFCInformation[] = [];

  password: string = "";

  mounted() {
    ipcRenderer.send('get-RFC');
    ipcRenderer.on('get-RFC', (event: Electron.Event, rfcs: RFCInformation[]) => this.onReceiveRFC(rfcs));
  }

  onReceiveRFC(rfcs: RFCInformation[]): void {
    this.rfcInformations = rfcs;
  }
};

コンテナとなるコンポーネントです.前回とあんまり変わりは……

vue-class-componentを使うことで,コンポーネントをTypeScriptのclass として書くことができます.

mounted() でメインプロセスにRFCのデータを要求し,メインプロセスは先にフェッチして保存しておいたファイルから情報を引き渡します.各データはRfcCard に表示を一任しています.

ちなみにレイアウトは

#app
  v-app(dark)
    main
      v-container(grid-list-md)
        v-layout(row, v-for="rfc in rfcInformations" :key="rfc.number"): v-flex(xs12): rfc-card(v-bind:rfc="rfc")

こうです.各データについてrfc-card タグによって表示する,だけです.

rfc-card

import Vue from 'vue'
import Component from 'vue-class-component';
import { Prop }  from 'vue-property-decorator';
import { RFCInformation } from '../../common/rfcInformations';
@Component({
  name: 'rfc-card'
})
export default class RfcCard extends Vue {
  @Prop({ type: Object })
  rfc: RFCInformation;

  get paddedNumber(): string {
    return this.rfc.number.toString().padStart(5, '0');
  }

  get authorsList(): string {
    return this.rfc.authors.join('; ');
  }

  get hasObsoletes(): boolean {
    if (this.rfc.other.obsolete && this.rfc.other.obsolete.obsoletes) {
      return this.rfc.other.obsolete.obsoletes.length > 0;
    }

    return false;
  }

  // 省略
}

vue-class-component のパワーにより,prop は@Prop として,computed はgetter として,メンバとして定義できるので補完とか,型とかの面で楽しく書けますね!

v-bind:rfc=”rfc” として親コンポーネントから渡されたデータを……

v-card: v-container(grid-list-md)
  v-layout(row)
    v-flex(xs2) {{ paddedNumber }}
    v-flex(xs10) {{ rfc.title }}
  v-layout(row): v-flex(xs12) {{ authorsList }}
  v-layout(row)
    template(v-if="hasObsoletes"): v-flex(xs6)
      #obsoletes Obsoletes:
        v-chip(color="blue lighten-2", v-for="(ob, i) in rfc.other.obsolete.obsoletes", :key="i", small, label) {{ ob }}
    template(v-if="hasObsoleted"): v-flex(xs6)
      #obsoletes Obsoleted by:
        v-chip(color="red lighten-2", v-for="(ob, i) in rfc.other.obsolete.obsoleted", :key="i", small, label) {{ ob }}
  v-layout(row)
    template(v-if="hasUpdates"): v-flex(xs6)
      #obsoletes Updates:
        v-chip(color="blue lighten-2", v-for="(ob, i) in rfc.other.update.updates", :key="i", small, label) {{ ob }}
    template(v-if="hasUpdated"): v-flex(xs6)
      #obsoletes Updated by:
        v-chip(color="red lighten-2", v-for="(ob, i) in rfc.other.update.updated", :key="i", small, label) {{ ob }}

のようにして表示します.

これを実行すると,冒頭のスクリーンショットのような感じになります.

いちおう,上にも記載したとおりUbuntu 18.04と,macOSでも動作を確認してあります.

おわりに

かなりはしょってしまいましたが,前回は窓を表示して終わりだったので,ほんのすこーーーーしは進みましたね.新しいライブラリやツールも(ここには書いてませんが……)試すことができました.

このあとは,上に書いたように,検索・フィルタリング,文書の閲覧などの機能を持たせていこうと思います.

これでクロスプラットホームのアプリがある程度かんたんに作れるようになるとよいですね.では.