Vue.js と Electron でタイマーアプリを作ったまとめ

最終更新日
2017.10.26

前回 Electron と Vue.js でタイマーアプリを作り始めたのですが、だいたい完成したので色々まとめます(•ө•)ノ
※一応リポジトリはここに作ってあるんですけどまだ手直し中です。ブランチDevで作業中。

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

最終的にこんな感じになりました。ストアはプロセス毎の Vuex をメインプロセスを通して同期しています。

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

初期設定からいくつか変更しましたが現状の最新のバージョンで特に問題ないようす。Webpack は 2.2.1 です。

  • electron-json-storage 3.0.1
  • vue 2.3.2
  • vue-electron 1.0.6
  • vuex 2.3.1
  • lodash 4.17.4

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 でそれぞれのファイルだけ読み込みます。チャンクは entry で設定した名前を使います。

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
}),

インスタンスを1個にして [name] で作成出来ないかなーと調べたらどうやら出来ないそうです。

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

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

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

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

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

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

設定がまだ読み込まれて無ければ json をロードして ipc でウィンドウに伝達します。タイミングが同時だと2回読み込む可能性があるかもですが、そんな大きなファイルじゃないので気にしないのです。基本的に Electron では複数のウィンドウを使うアプリを作る事は少なさそうなのでここらへんはあんまり追求しないでおこうと思います。

ストアまわり

最初は Vue インスタンスを使った劣化 Vuex で同じことをしてましたが素直に Vuex を使用しました。

各プロセスの状態同期

Electron はウィンドウごとにプロセスが違うので別のウィンドウにストア内容が反映されません。プロセス間通信用の IPC を使用しメインプロセス経由で dispatch を実行してウィンドウに反映させるようにしました。

ウィンドウA → メイン → ウィンドウB という流れです。

src/main/index.js メインプロセス側

  import { ipcMain } from 'electron'
  // レンダーからメインのstore-dispatchを発火すると
  ipcMain.on('store-dispatch', (event, type, payload) => {
    // メインが各レンダーのstore-dispatchを発火する
    mainWindow.webContents.send('store-dispatch', type, payload)
    configWindow.webContents.send('store-dispatch', type, payload)
  })

src/renderer/index.js レンダープロセス側

import { ipcRenderer } from 'electron'
import Vue from 'vue'
import store from '@/vuex'
import App from './components/App.vue'
// メインプロセスからstore-dispatchが発火されると
// ローカルのストアのアクションを実行してウィンドウに反映される
ipcRenderer.on('store-dispatch', (event, type, payload) => {
  store.dispatch(type, payload)
})
new Vue({
  store,
  ...App
}).$mount('#app')

src/vuex/index.js

import { ipcRenderer } from 'electron'
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
const store = new Vuex.Store({
  state: {
    config: {
      piyo: true
    }
  },
  mutations: {
    setConfig(state, payload) {
      state.config = payload
    }
  },
  actions: {
    // IPCを送信するアクション
    ipcSaveConfig({ state }) {
      // 設定ファイルに保存する処理とか
      // ...
      // 現在のローカル設定を別のウィンドウにも反映
      ipcRenderer.send('store-dispatch', 'configSetup', state.config)
    },
    // ↑のIPCを送信するとメインプロセス経由で↓のアクションが折り返し呼ばれる
    configSetup({ commit }, config) {
      commit('setConfig', config)
    }
  }
})
export default store

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

export default {
  name: 'config',
  computed: {
    config: function() { return this.$store.config }
  },
  methods: {
    save() {
      // 他ウィンドウにも反映したい段階でIPCを送信するアクションを呼ぶ
      this.$store.dispatch('ipcSaveConfig')
    }
  }
}

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

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

config: {
  sets: [
    { name:'Set1', timers: [{timer1},{timer2},{timer3}...] },
    { name:'Set2', timers: [{timer1},{timer2}] }
  ]
}

上のような感じで state.config.sets に各タイマーの設定があって、使用中のタイマーセット sets[ idx ].timers と同期した現在のタイマーのカウント数などを保持している state.forge.timersForge というのを動的に作りたかったのですが、timersForge[ timerIdx ] を言う感じに使用している場所で config.defaultSet が更新された直後 timersForge が同期するまでの間そんなプロパティは無いよ~とエラーになるのでストアのウォッチャで timersForge が作成されるようにしました。コンポーネントのウォッチャで作ると手遅れになるみたいです。


store.watch((state) => state.config.defaultSet, () => {
store.dispatch('forge/init')
})

別のモジュールのステートを参照するのに rootState を使いました。

ストアの forge モジュール

const forge = {
  actions: {
    init({ state, commit, rootState }) {
      const set = rootState.config.sets[rootState.config.defaultSet].timers
      // config.sets.timersと同期したデータを作る処理
    }
  }
}

ウィンドウの設定

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

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 以上の余裕を持たせる必要があるのでした。

.config-icon { -webkit-app-region: no-drag; padding: 3px; }

コンテキストメニューをクリックするとその場でアニメーションが停止してしまうので、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 したあとは消えてくれるのです。すごーい!

タイマーカウント管理

タイマー開始のトリガーになるテンキーの入力を拾う処理は Electron だとグローバルフックが使えなかったのでやむをえず外部プロセスを使いました。ホットキーが押されると開始用のミューテーションをコミットしてその中で setInterval を作成してます。

整合性を失うような処理ではないはずだけど一応アクションに書いたほうがよいのかどうか。

改善したいとこ

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

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

まとめ

Electron も Vue も経験が浅くて初めて Electron でアプリを作りましたが、やっぱり実際に何か作ると色々やり方を覚えて学習が捗る気がしますね!今回は使わなかったんですけど、完全に未知だった Vuex と Vue-router も少し使い方を覚えてきたので楽しくなってきました。状態管理もっとちゃんと覚えたいわ~(*⁰▿⁰*)/ →使って直しました。

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

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

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