Vue.js を使って Electron でアプリを作る1

Electron でアプリを作るので折角なので Vue を使ってみます。

まだ Vuex やルーターについて全然勉強していないのですけど、ドラゴン攻略の時期が近づいてきたので数年前に AdobeAIR で作ったここにあるカウントダウンタイマーを  Electron で作り直したいと思います。

この記事は試行錯誤中の内容が多いですが、次の記事で完成したあとのまとめを書いています。

vue-cli と electron-vue の導入

vue-cli 自体もまだ使ったことがなかったのですが、スケルトンを作成したりリロードなど開発環境を提供してくれたりする機能みたいです。これを使うと gulp は使わなくても良さそうです。実行するとフォルダも自動に作成されるのでフォルダ構成とか悩まなくて良いし、新しいプロジェクトを作る場合にはこれを使うと楽そう!

electron-vue は electron 用のプロジェクトスケルトンも作成してくれます。

こちらの記事を参考にしながら導入しました。

初回テスト起動時は webpack-dev-server の起動に数秒かかりますが、その後はテストでウィンドウを開いたまま編集してもリアルタイムにリロードしてくれるので確認作業が早いです。ちなみにリロードはレンダープロセス(Web ページの表示)のみなのでメインプロセスを編集した場合はテスト起動しなおさないといけません。

プロジェクトの作成

vue init を実行すると対話形式でプロジェクトの設定がはじまって、この時 ESLint の導入や Vue の各種拡張機能を使用するか聞いてきます。全部 Y を押すと全部インストールされるんですけど実際に開発し始めていくつか設定し直しました。

デフォルトだと vue-electron / vue-router / vue-resource / vuex がインストールされます。最初はまだ勉強してない vuex とかがずばばばっと出来てて真顔でした。

ESLint

構文を厳格にチェックしてくれるまるで鬼姑のようなツールですが、atom beautify と ESLint の設定が上手く噛み合わないため削除しました。ただこれは仕方なくなので atom の設定を見直したいです。無駄なコードとかも教えてくれるので凄く便利です。

vue-resource と vuex は不要だった

私のプロジェクトだとなんですけど、この2つは特にインストールしなくても良かったです。vue-resource  については公式で引退するわじゃあの。と言われていますが、データについても操作量は多くなくて今回は設定ファイルを読んで書き換えるだけなので、書き換えた後に ipc でリロードする方法にしました。( vuex の勉強を避けたわけではない)

初期でサンプルコードが書かれているのでこれを弄りながら vuex の勉強出来そうです(•ө•)ノ

課題になりそうなところ

今回作るアプリでまだきちんと試せていない課題になりそうな処理はこんなかんじです。

  • 2つの BrowserWindow を作って相互通信をする
  • メインプロセスと各レンダープロセスで共有の変数を持つ
  • 外部プロセスを起動して標準出力を取得し続ける

子ウィンドウを作って通信する

ひとつ目は以前 Electron の勉強してた時に試したので同じようにやってみたいと思います。

mainWindow = new BrowserWindow({
  height: 800,
  width: 600,
})
mainWindow.loadURL(winURL)
configWindow = new BrowserWindow({
  parent: mainWindow,
  width: 800,
  height: 600,
})
configWindow.loadURL(winSubURL);
configWindow.on('close', (event) => {
  configWindow.hide()
  event.preventDefault()
})

メインプロセスと各レンダーは ipc を使って通信します。

メインプロセス側:特に意味のないコード

ipcMain.on('say-hello', (event) => {
sub.webContents.send('hello')
})

レンダー側

/* front.vue */
ipcRenderer.send('say-hello')
/* sub.vue */
ipcRenderer.on('hello', () => {
console.log('hello')
})

レンダープロセスAのコンポーネントから別のレンダープロセスBに通信します。

プロセス間で共通の変数を使う

Electron はウィンドウ毎にプロセスが分かれているため普通にシェア用のオブジェクトを作ったけだとプロセスを跨いで共有できないのでやりとりは上に書いた ipc で出来ます。メインプロセスを通して送受信するのが一般的になります。今回は vuex を使わないのでシェア用の vue インスタンスを作ってこれを ipc で連動出来るようにしました。

import Vue from 'vue'
export default new Vue({
data: {
counter: 0
},
methods: {}
})

これを vue に登録するだけでプロセス内では勝手にリアクティブに作用してくれます。大きなアプリを作る場合はちゃんと vuex を使ったほうがいいですね!

メインプロセスを通して他のレンダープロセスに反映するようにします。

ipcMain.on('store-commit', (event, key, val) => {
  if (event.sender != win1.webContents) {
    win1.webContents.send('store-commit', key, val)
  }
  if (event.sender != win2.webContents) {
    win2.webContents.send('store-commit', key, val)
  }
})

レンダープロセスの最初にセット用のイベントを作成します。

import appStore from '../lib/store.js'
ipcRenderer.on('store-commit', (event, key, val) => {
  appStore.$set(appStore, key, val)
})

各コンポーネントからセットするときは ipc を使います。

import appStore from '../../lib/store.js'
export default {
  name: 'page-timer',
  data() {
    return {
      appStore: appStore
    }
  },
  computed: {
    counter() { return appStore.counter }
  },
  methods: {
    action() {
      ipcRenderer.send('store-commit', 'counter', appStore.counter + 1)
    }
}
}

非同期なのでコールバックとか出来ないかなぁと思ったんですけど、一応 appStore の watch に登録すれば値が更新されたあとに何すをるみたいな事も出来るので小さいアプリだし今のところこんな感じでまた必要になったら考えます。

例えばアプリ設定ウィンドウの「設定を保存」を押すなど他のプロセスに反映させたい段階になったら ipc で今の状況をコミットするという感じで使っています。

グローバル変数でも問題ないのかなと思ったけど、グローバル変数はキャッシュの関係上あまり使わない方がいいんだぜみたいな情報がたくさん出てきたので、内容が更新されるようなものに使うのはやめた方がいいみたいです。

const appStore = remote.getGlobal('appStore') // ×
const appPath = remote.getGlobal('appPath') // ○

プロセス間で vuex を共有する記事をいくつか見つけたので、vuex を使う際は参考にさせて頂こうと思います。

外部プロセスの標準出力を取得する

Node.js の child_process を使って外部プロセスを実行できるみたいです。

import { spawn } from 'child_process'
const child = spawn('app.exe')
child.stdout.on('data', (data) => {
console.log(data.toString())
})

標準出力がある度に内容を書き出します。

AdobeAIR で作っていた時意図せず外部プロセスが終了してしまう事があったので再起動させる処理をしたんですけど同じように出来るか調べてます。(まだ同じタイミングで終了するか確認してませんが)

はまったところ

ipcRenderer もちゃんと削除しないといけない!

開発中にレンダーを編集するとホットリロードしてくれるのですが、この時 ipc イベントなどを登録しているとその都度登録されるためどんどん重複していきます。ビルドしたら気にしなくてもいいのかもしれませんが一応ちゃんと削除するようにしました。

たとえば、コンポーネントの created で以下のように登録しているとホットリロードの度に hello が増えていきます。

ipcRenderer.on('hello', () => {
 console.log('hello')
})

これは他のイベントリスナ等でも一緒だと思いますが ipcRenderer も beforeDestroy でちゃんと削除しないといけないのでした。

beforeDestroy() {
ipcRenderer.removeListener('hello', this.hello)
}

テストとビルド時でパスが違う

テスト時はアプリケーションは node_module/electron の中にできた default_app.asar を指しているもののアイコンなどが表示されないため開発用のフォルダを指すようにしました。圧縮するかしないかでまた変わりそうです。

const iconPath = process.env.NODE_ENV === 'development' ?
path.join(__dirname, '../../icon.png') : app.getAppPath() + '/icon.png'

ちなみに .asar にまとめたくないものは config.js に asar.unpack を追加すると resources/app.asar.unpacked に静的ファイルが残ります\(*⁰▿⁰*)/

asar: { unpack: '**/app/{bin,sound}/**' }

モジュールを使わずに mp3 の再生を video タグで軽く済ませたかったのですが、ビルドした時は unpack の中のファイルを参照出来るけど、dev-server の時 audio や video タグでローカルファイルは参照できない…!!ので結局テスト時は別のローカルサーバーのファイルを参照しました(´•ω•`)

global.soundDir = process.env.NODE_ENV === 'development' ? 
'http://project.localhost/rizatimer/sound/' :
path.join(app.getAppPath(), '../app.asar.unpacked/sound/')

ウィンドウ内の透明な場所をクリック出来ないようにする

作ろうとしているアプリは登録しているタイマーの数によってメインウィンドウの大きさが変わります。この時最初から最大の高さのウィンドウを作っておいて使わないエリアは透明にしておく…という方法を AIR ではやっていたんですけど、Electron でこれを実装するには setIgnoreMouseEvents が Windows で使えるようになっても結構難しいみたいです。

  • setIgnoreMouseEvents(true) を設定するとマウスイベントが完全にスルーされてしまう!除外する範囲を設定できるものの丸型とかデスクトップマスコットみたいな変形ウィンドウとかには対応出来ない。
  • transparent: true を設定していて透明のエリアをクリックしてもアプリをクリックしている判定になって透明部分の下の何かしらがクリックできない。

公式 issue のこのページでも議論されてましたが下の方にある対応策のクリックした時 1px 四方の色を取得して透明かでクリック可能か判断するというのがよさそうな感じでした。

ただ、今回はそこまで変形してない角丸だけのウィンドウなので、タイマーの数が変わる度に setSize でウィンドウのサイズ自体を変えるようにしました。

remote.getCurrentWindow().setContentSize(600, 600)

とりあえずこんな感じでした。進歩があればまた書きます(•ө•)ノ

次回こそスルーしてた vuex のお勉強していきたいです。