Vue.js を使って Electron でタイマーアプリを作った

前回 Electron と Vue.js でタイマーアプリを作り始めたのですが、だいたい完成したので色々まとめます(•ө•)ノ

プロセスとコンポーネントの構成

最終的にこんな感じになりました。ストアは Vue インスタンスで作ってあってメインプロセスを通して各プロセス間で同期できます。

プロジェクト設定やビルド設定の変更

初期設定からいくつか変更しましたが、結局 Vuex も Vue-router も使いませんでした。現状の最新のバージョンで特に問題ないようす。Webpack は 2.2.1 です。

  • electron-json-storage 3.0.1
  • vue 2.2.2
  • vue-electron 1.0.6

Vue-router をやめて2ウィンドウ構成にする設定

画面遷移がないのに各レンダープロセスの html の振り分けだけにルーターを使うのもどうかなぁと思って Vue-router は使わないことにしました。この辺は Webpack の設定を少し修正する必要があります。renderer/main.js を削除して代わりに main.timer.js (タイマーウィンドウ)と main.config.js (設定ウィンドウ)の2つのエントリポイントを作成します。内容は main.js コピーして不要な vue-router 関連のコードを削除しました。

main.timer.js

import App from './components/Timer.vue'

main.config.js

import App from './components/Config.vue'

という感じでそれぞれのルート用コンポーネントを呼んでいます。

webpack.renderer.config.js を編集します。まずは上で作ったエントリポイントを指定します。

entry: {
timer: path.join(__dirname, 'app/src/renderer/main.timer.js'),
config: path.join(__dirname, 'app/src/renderer/main.config.js'),
},

次に下の方のにある HtmlWebpackPlugin を増やします。index.ejs は特に違いはないので使いまわして chunks でそれぞれのファイルだけ読み込みます。

new HtmlWebpackPlugin({
filename: 'index.html',
chunks: ['timer'],
template: './app/index.ejs',
appModules: process.env.NODE_ENV !== 'production' ?
path.resolve(__dirname, 'app/node_modules') : false,
}),
new HtmlWebpackPlugin({
filename: 'config.html',
chunks: ['config'],
template: './app/index.ejs',
appModules: process.env.NODE_ENV !== 'production' ?
path.resolve(__dirname, 'app/node_modules') : false,
}),

app.asar から除外するファイルの設定

config.js の設定に asar.unpack を追加して glob 形式で除外ファイルを指定します。

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

初期設定だと resources/app.asar.unpacked の中に作成されます(•ө•)ノ

アプリのユーザー設定ファイル

タイマー秒数などのユーザー設定ファイルの保存には electron-json-storage を使いました。

起動時の読み込みはメインウィンドウで json ファイルを読み込んで準備が出来たら設定ウィンドウをセットアップしてそっちにも設定を反映させるようにしたんですけど、デメリットで開発中に完全リロードしたい時に設定ウィンドウで F5 を押してもセットアップ指示がなくて更新できないのでメインウィンドウでも F5 を押さないといけないのが面倒です(´•ω•`) ホットリロードは効くんですけど、ここらへんはもうちょっとうまい同期方法を考えたいなーと思います。

ストアまわり

ストアには Vue のインスタンスを使いました。 computed や ウォッチャーなどの機能も使えてすごい捗るぅ~…みたいな気がしただけで、ただの Vuex の劣化版なので事前に勉強さえ出来ていれば素直に Vuex を使えば良かったと思います。今まで勉強してきた事を全て無駄にするかのように何も考えずプロパティを書き換えてアクションなんて無かった状態なので、大きくなっていくとこれが致命的になりそうなので小さいアプリからきちんと書けるようにしたいです。

store.js

import Vue from 'vue'
export default new Vue({
  data: {
    counter: 0
  },
  // 以下にストア共通の算出プロパティやウォッチャー
})

コンポーネントで使用する

import appStore from '@/store.js'
export default {
  name: 'app',
  computed: {
    counter: function() { return appStore.counter }
  },
  watch: {
    counter: function () {
      // ストアのカウンタが動いた時のなにか処理
    }
  },
  methods: {
    action() {
      ipcRenderer.send('store-commit', 'counter', appStore.counter + 1)
    }
  },
}

ipc で送信するまではストアのデータはそのプロセス内だけでリアクティブです。

configと同期した別データを作成

appStore.config にタイマーのユーザー設定があって、この設定の「使用中のタイマーセット」と同期した現在のタイマーの状態やカウント数を保持している appStore.countrForge というのを作りたかったのですが、appStore.config が更新された直後 appStore.countrForge が同期するまでの間、countrForge[ timerIdx ] を言う感じに使用している場所でそんなプロパティは無いよ~とエラーが起きてしまうので config のウォッチャーで countrForge が更新されるようにしました。

export default {
  computed: {
    config: function () { return appStore.config }
  },
  watch: {
    config: function () {
      appStore.initCountrForge()
    }
  },
}

ウィンドウの設定

タイマーウィンドウは半透明のフレームレスでドラッグアンドロップで移動できるようにしました。

mainWindow = new BrowserWindow({
width: 200,
height: 70,
frame: false, // フレームをなくす
transparent: true, // 背景を透明にする
alwaysOnTop: true, // 最前面に表示する
resizable: false, // リサイズ不可にする
useContentSize: true,
})

あとからコンテンツサイズに合わせてウィンドウサイズを調整するので useContentSize を設定しています。

ウィンドウをドラッグで移動可能にするには CSS で以下のように指定します。

-webkit-app-region: drag;
-webkit-user-select: none;

メニューまわり

コンテキストメニュー

タイマー画面は基本全面ドラッグ&ドロップでウィンドウを移動できるようにしているのですが -webkit-app-region: drag; を指定してる箇所はイベントを受け付けてくれません。CSS の :hover なども効きません。コンテキストメニューを表示させるエリアに -webkit-app-region: no-drag; を指定する事でイベントを発生させる事が出来ます。

次にこのコンテキストメニュー表示用のアイコンに :hover でアニメーションをつけようと思ったのですが、アイコンに直接 no-drag を指定していると素早くマウスアウトすると元に戻らない場合があります。

イベントを発生させるアイコンの外側で no-drag を指定して padding などで 1px 以上の余裕を持たせる必要があるのでした。

<div style="-webkit-app-region: no-drag; padding: 3px;">アイコン</div>

コンテキストメニューをクリックするとその場でアニメーションが停止してしまうので、CSS じゃなくて JavaScript で制御するのが良いかも。

タスクトレイメニュー

開発時とビルド時でアイコンのパスが変わってしまうので振り分けます。

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

ちなみにこの process.env.NODE_ENV === 'development' という分岐処理は Webpack したあとは消えてくれるのです。すごーい!

タイマーカウント管理

タイマーのカウントダウン処理は現状全てのタイマーを一括して1秒に1回だけ処理するようにしています。上で作った appStore.countrForge の 算出プロパティのウォッチャーで t = setTimeout(カウント処理, 1000) を登録して t の値をチェックして重複しないようにしていますが、既にカウントが始まってる場合開始のタイミングで最大1秒程度の誤差がでるのでこの辺は個別にタイマーを持つように変える予定です。

改善したいとこ

ゲームのエンドコンテンツ実装日に間に合わせるために色々妥協してしまったので沢山あります。゚(゚´ω`゚)゚。

  • やっぱり状態管理と全体的なソースコードを綺麗にしていきたい。
  • 初回起動時間がかかる場合コンテンツのサイズがきちんと取得できない?
  • コンテキストメニューを表示している間カウンターが停止してしまう。
  • 起動時に設定ファイルが古かった場合マージしたり、壊れていた時のリカバリとかをしっかりさせたい。
  • あと機能の実装がメインだったためデザインが全然イケてないのを自覚しているので、折角 HTML なんだしもうちょっとカッコよくしたいところ。

まとめ

Electron も Vue も経験が浅くて初めて Electron でアプリを作りましたが、やっぱり実際に何か作ると色々やり方を覚えて学習が捗る気がしますね!このサイトよりもインタラクティブな動きがあるので Electron と Vue の組み合わせも凄く楽でした。今回は使わなかったんですけど、完全に未知だった Vuex と Vue-router も少し使い方を覚えてきたので楽しくなってきました。状態管理もっとちゃんと覚えたいわ~(*⁰▿⁰*)/

あと、ホットリロードがすごく楽!これがないと毎回確認で electron . しないといけないので、Webサイトを作るときよりも恩恵を感じます。

それから最初から分かってて始めたものの Electron ビルド後サイズ大きすぎませんかね?実行に必要なファイルが全部入ってるから仕方ないかもしれないけど、こんなちまっこいアプリに 130M 圧縮しても50M はつらみあるです。お手軽に作れて良いんですけどお手軽なアプリを作るならやっぱり C とかの方がいいのかも。同梱しているホットキー用の EXE は C を使って簡単なコードで作ってるんですけど、なんだか C# の勉強もしてみたいです。C とか C++ とか C# とか D とか色々あってよく分かってませんが C# が比較的新しくて改善されてるっぽいので…。

でも Electron の UI 周りは Web 関係やってきていると本当に楽なので、次は Electron か他の何かでもうちょっと大きめのアプリを作ってみたいです。

Edited on 2017.03.19 Created on 2017.03.13 Vue.js JavaScript Electron