Electronの複数ウィンドウ間で共通のVuex Storeを扱う

  • 10
    いいね
  • 0
    コメント

Advent Calendar 2016 Electron 18日目です。

この記事は以下のdependenciesでお送りします。

package.json
"dependencies": {
  "electron": "1.4.15",
  "vue": "2.1.10",
  "vuex": "2.1.2"
}

複数のBrowserWindowを持ったアプリケーション

Electronで複数のBrowserWindowが連携するアプリケーションを作る場合、メインプロセスを中継してお互いにデータをやりとりする場面が多々存在する。

しかし正直言ってWebアプリケーションと同じノリで書きたい身としては、中継部分を細かく管理するのは避けたい。そのため、処理を一度書くだけでなんとかなる構造を以下二種類紹介する。

1. メインプロセスを汎用的な中継として扱う

メインプロセスを何も考えないDispatcherとして考える。

Dispatcher.png

それぞれのRendererをコンポーネントとして扱い、ipcMainipcRendererでメインプロセスに渡されたイベントをそのまま全てのBrowserWindowに流す。
dispatch-connectなどのイベント名を決めておき、それに加えてStore間で扱うイベント名とペイロードをやり取りする記述を一回書けば、メイン-レンダラ間の通信は考えなくても良くなる。

main-process.js
ipcMain.on('dispatch-connect', (event, typeName, payload) => {
  window1.webContents.send('dispatch-connect', typeName, payload)
  window2.webContents.send('dispatch-connect', typeName, payload)
})
renderer-process-windows.js
const store = new Vuex.Store({
  state: {
    data: 0
  },
  mutations: {
    eventname (state, payload) {
      state.data += payload.data
    }
  }
})

ipcRenderer.on('dispatch-connect', (event, typeName, payload) => {
  store.commit(typeName, payload)
})

new Vue({
  el: '#app',
  store,
  methods: {
    onButtonClick () {
      ipcRenderer.send('dispatch-connect', 'eventname', { data: 10 })
    }
  }
})

このような構造では、window1とwindow2で初期状態が同じStoreとMutationを持つならば、理論上それぞれのStoreは同じ状態を保てる。

すごい勢いで飛び続けるイベントと処理を組み合わせる必要がある場合は、確実に状態を担保するフォローを考えるのが少し面倒。
それと片方だけリロードするような操作を行うとデータの齟齬が起きるので、よくある困った時にリロードしてしまうボタンでは同時に更新する必要がある。

2. メインプロセスをStoreとして扱う

VueもVuexもNodeプロセスの中で動ける。
これを利用して、MainをStoreとして扱う場合。

ipc2.png

メインプロセスのVuex内でPluginを使い、mutationしたらstateを丸ごとレンダラプロセスに送る処理を記述する。

main.js
const state = { x: 0, y: 0 }

// mutationしたらそれぞれのwindowにstateを丸ごと投げる
const myPlugin = store => {
  store.subscribe((mutation, state) => {
    window1.webContents.send('state-connect', state)
    window2.webContents.send('state-connect', state)
  })
}

const store = new Vuex.Store({
  state: state,
  mutations: {
    mousemove (state, payload) {
      state.x = payload.x
      state.y = payload.y
    }
  },
  plugins: [myPlugin]
})

ipcMain.on('commit-connect', (event, typeName, payload) => {
  store.commit(typeName, payload)
})
renderer.js

const store = new Vuex.Store({
  state: ipcRenderer.sendSync('state-connect')
})

ipcRenderer.on('state-connect', (event, mainState) => {
  store.replaceState(mainState)
})

new Vue({
  el: '#app',
  store,
  computed: {
    x () { return this.$store.state.x },
    y () { return this.$store.state.y }
  },
  methods: {
    onMouseMove (e) {
      ipcRenderer.send('commit-connect', 'mousemove', { x: e.offsetX, y: e.offsetY })
    }
  }
})

上の例ではstore.replaceState()という豪快なことをしているが、実際は特定の一部分を更新対象にすれば良いと思う。

レンダラプロセスはStoreへのcommitの代わりにメインプロセスへのイベント送信を行い、
メインプロセス側で持つStoreでMutationが発生したらレンダラプロセスにデータをbroadcastする。 
すごい勢いでレンダラプロセスに向かってデータが飛んでいく。
Mutationが起こる度にStateを丸ごと投げるわけなので、Stateのデータが巨大だと一体何kbpsだよという量になる。巨大Stateでの運用はおすすめできない。

この方法だとメイン-レンダラ間の通信量以外は気を配らなくても良い。
ブラウザウィンドウを更新してもデータはメインプロセスに残り続けるので、データを初期化したい場合は初期化ボタンなどの設置が必要となる。

まとめ

実際にはメインプロセスがイベントを受け取った時点でフックして、メインプロセス特有の処理を行うブロックが挟まれたり、ipcRenderer.send('commit-connect', ~と一々書くのは面倒なのでwrapしたりするが、1,2どちらの場合でも概ね通常のWebアプリケーションと同じ気分で開発が可能だ。
かなりすっきりした複数BrowserWindowのアプリが書けるのではないだろうか。

2の場合で「Storeが巨大だとおすすめできない」と書いたものの、まだブンブン投げっぱなしで困ったことがないのでどの辺が限界なのか誰か試すか教えてほしい。

あとAdvent Calendarブッチしてすみませんでした。

この投稿は Electron Advent Calendar 201618日目の記事です。