3
1

Electron+Vue3でマルチウィンドウを理解したい

Last updated at Posted at 2024-07-31

viviONグループでは、DLsiteやcomipoなど、二次元コンテンツを世の中に届けるためのサービスを運営しています。
ともに働く仲間を募集していますので、興味のある方はこちらまで。

Electronでマルチウィンドウを制御したい

知り合いの自転車屋さんに、商品のポップを簡単にデザインできるツールを作れないか相談を受けたので、Electronを使ったデスクトップアプリで作ってみることにしました :bike:
ポップのプレビュー画面が必要になるのですが、その際のマルチウィンドウ制御に少し悩まされたので、自身の備忘録も兼ねて検証内容をまとめてみました。

検証環境

Windows10
Node:20.10.0
Vue CLI:5.0.8

環境構築

ある程度これに倣えば同じように検証ができるように記載しています。

今回は、VueでElectronを利用するためにVue CLI Plugin Electron Builderを導入します。
※公式ページではVue CLIのバージョン3か4を推奨していますが、あくまで検証なのでVue CLIのバージョン5で試しています。

Open a terminal in the directory of your app created with Vue-CLI 3 or 4 (4 is recommended).

まずはVueプロジェクトを作成します。

vue create multi-window-app

Vue3を選択。
image.png

プロジェクトディレクトリに移動しelectron-builderを追加。

cd multi-window-app
vue add electron-builder

Electronのバージョンは13を選択。
image.png

ただ、13はあまりにも古すぎるため、まだサポート期間内の29をインストールしました。

npm install electron@29.0.0

Electronを起動してみます。

npm run electron:serve

無事に立ち上がりました。
image.png

親ウィンドウから子ウィンドウを開く

まず別ウィンドウを開くところから試します。
親ウィンドウのレンダラープロセスが、メインプロセスに対して子ウインドウの起動処理を呼び出します。
image.png

メインプロセスでは、レンダラープロセスからの通知を待ち受けて新しいウィンドウを開く処理を追加しています。

background.js
// 説明に関係ない一部コードは省略しています
'use strict'

import path from "path";
import { app, protocol, BrowserWindow } from 'electron'
import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'
import installExtension, { VUEJS3_DEVTOOLS } from 'electron-devtools-installer'
const isDevelopment = process.env.NODE_ENV !== 'production'

protocol.registerSchemesAsPrivileged([
  { scheme: 'app', privileges: { secure: true, standard: true } }
])

let mainWindow, subWindow;

async function createWindow() {
  // Create the browser window.
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
      contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION,
      preload: path.join(__dirname, 'preload.js')
    }
  })

  if (process.env.WEBPACK_DEV_SERVER_URL) {
    await mainWindow.loadURL(process.env.WEBPACK_DEV_SERVER_URL)
    if (!process.env.IS_TEST) mainWindow.webContents.openDevTools()
  } else {
    createProtocol('app')
    mainWindow.loadURL('app://./index.html')
  }
}

app.on('ready', async () => {
  if (isDevelopment && !process.env.IS_TEST) {
    // Install Vue Devtools
    try {
      await installExtension(VUEJS3_DEVTOOLS)
    } catch (e) {
      console.error('Vue Devtools failed to install:', e.toString())
    }
  }

  // ****** ↓ココを追加 ******
  // レンダラープロセスから 'open-sub-window' チャンネルへ着信
  ipcMain.handle("open-sub-window", () => {
    // 子ウィンドウを作成
    subWindow = new BrowserWindow({
      title: "Sub Window",
      webPreferences: {
        nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
        contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION,
        preload: path.join(__dirname, "preload.js"),
      },
    });
    // 子ウィンドウ用 HTML
    subWindow.loadURL(process.env.WEBPACK_DEV_SERVER_URL)
    if (!process.env.IS_TEST) subWindow.webContents.openDevTools()
  })
  // ****** ↑ココを追加 ******

  createWindow()
})

レンダラープロセスからメインプロセスを呼び出すためにはプリロードスクリプトというものが必要になります。
プリロードスクリプトは、ウェブコンテンツの読み込み開始前にレンダラープロセス内で実行されるコードです。レンダラーのコンテキスト内で実行はされますが、Node.jsのAPI にアクセス可能なので、レンダラーとメインプロセスの橋渡し役のようなイメージになります。
ここでは、メインプロセスの"open-sub-window"というチャンネルを呼び出すipcRenderer.invoke()を定義しています。

preload.js
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('myAPI', {
  // メインプロセスのサブウィンドウ起動を呼び出し
  openSubWindow: () => ipcRenderer.invoke("open-sub-window")
})

1点ポイントとして、Electronのビルド成果物にプリロードスクリプトを含めるためvue.config.jsに指定が必要となります。

vue.config.js
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  // コレを追加
  pluginOptions: {
    electronBuilder: {
      preload: "src/preload.js",
    },
  },
})

最後に、レンダラープロセスからプリロードスクリプトで定義したopenSubWindow()呼び出すことで一通りの処理が完成です。

views/MainWindow.vue
<template>
  <button @click="openSubWindow">Open Sub Window</button>
</template>

<script>
export default {
  name: 'MainWindow',
  methods: {
    openSubWindow() {
      // レンダラープロセス (mainWindow)からサブウィンドウを開く
      window.myAPI.openSubWindow()
    }
  }
}
</script>

アプリを起動し子ウィンドウを開くと新しい画面が立ち上がります。
※子ウィンドウも親と同じページを指定しているので開くページの中身は変わりません。
image.png

無題.jpg

今回はレンダラープロセスからメインプロセスを呼び出して子ウィンドウを生成していますが、レンダラープロセスから直接子ウィンドウを開くこともできるようです。

Electronでは、WebネイティブのChrome WindowとElectronのBrowserWindowがペアリングされるため、JavaScriptでのwindow.open()でBrowserWindowを生成することができるとのこと。
ただ、今回はウィンドウのインスタンスをメインプロセスに集約しておいた方が扱いやすいだろうという判断で、メインプロセスからウィンドウの生成を行っています。

親子ウィンドウ間でデータを受け渡す

子ウィンドウを開くことができたので、次はデータの送信を試してみます。
わかりやすくするためにVue Routerを導入してページを作成し、そのページ間(親子ウィンドウ間)でデータを受け渡します。

Vue Routerで親子ページを作成

npm install vue-router

ルートパス(初期画面)は親ウィンドウ、/sub-windowを子ウィンドウとしてrouter/index.jsを作成します。

router/index.js
import { createRouter, createWebHistory } from "vue-router";

const routes = [
  {
    path: "/",
    name: "MainWindow",
    component: () => import(/* webpackChunkName: "window" */ "../views/MainWindow.vue"),
  },
  {
    path: "/sub-window",
    name: "SubWindow",
    component: () => import(/* webpackChunkName: "window" */ "../views/SubWindow.vue"),
  },
];

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes,
});

export default router;

main.jsApp.vueもVue Routerに合わせて修正します。

main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from "./router"

createApp(App).use(router).mount('#app')
App.vue
<template>
  <router-view />
</template>

メインプロセスの子ウィンドウを開く際のパスを'sub-window'に変更。

background.js
// レンダラープロセスから 'open-sub-window' チャンネルへ着信
  ipcMain.handle("open-sub-window", () => {
    // 子ウィンドウを作成
    subWindow = new BrowserWindow({
      title: "Sub Window",
      webPreferences: {
        nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
        contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION,
        preload: path.join(__dirname, "preload.js"),
      },
    });
    // 子ウィンドウ用 HTML
    subWindow.loadURL(process.env.WEBPACK_DEV_SERVER_URL + 'sub-window')
    if (!process.env.IS_TEST) subWindow.webContents.openDevTools()
  })

子ウィンドウのvueファイルを作成。

views/SubWindow.vue
<template>
  <h1>Sub Window</h1>
</template>

<script>
export default {
  name: 'SubWindow',
}
</script>

アプリを起動して子ウィンドウを開くと別ページが表示されるはずです。
image.png

image.png

親子ウィンドウ間でデータ送信

いよいよ本題の親子ウィンドウ間でのデータ送信を試します。

プリロードスクリプトについて

先ほどはさらっと流していましたが、プリロードスクリプトという一見冗長な仕組みを使うのにはElectronのIPC通信(Inter-Process Communication)の歴史が絡んでいました。

当初のElectronにはプリロードスクリプトは存在せず、レンダラーから直でNode.jsのAPIにアクセスしていたようです。BrowserWindowのwebPreferencesにnodeIntegrationという項目がありますが、これをtrueにしてあげることでレンダラーからでもNode.jsのAPIへのアクセスを可能にしていたんですね。
image.png

しかし、レンダラーからNode.jsやElectron APIにアクセスできるというのはセキュリティ的なリスクを伴うことになるため、contextBridgeというものが導入されました。
image.png
contextBridgeはメインプロセスから切り離されているため、これを経由することによって直接Node.jsやElectron APIにアクセスすることを防いでいます。コンテキストの分離を有効にするにはBrowserWindowのwebPreferencesのcontextIsolationtrueにしましょう。(現在はデフォルトでtrueです)

親ウィンドウからメインプロセスへのデータ送信

前述のcontextBridgeに、メインプロセスに対してデータを送信するための新たな関数を追加します。

preload.js
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('myAPI', {
  // メインプロセスのサブウィンドウ起動を呼び出し
  openSubWindow: () => ipcRenderer.invoke("open-sub-window")
  // メインプロセスの 'send-event' にメッセージを送信
  sendEvent: (msgObj) => ipcRenderer.send('send-event', msgObj)
})

親ウィンドウのレンダラープロセスにも処理を追加。

views/MainWindow.vue
<template>
  <h1>Main Window</h1>
  <button @click="openSubWindow">Open Sub Window</button>
  <div>
    <label>メッセージ:</label>
    <input type="text" v-model="message" />
  </div>
  <button @click="sendEvent">Send Message</button>
</template>

<script>
export default {
  name: 'MainWindow',
  data() {
    return {
      message: '',
    }
  },
  methods: {
    openSubWindow() {
      // レンダラープロセス(MainWindow)からサブウィンドウを開く
      window.myAPI.openSubWindow();
    },
    sendEvent() {
      // レンダラープロセス(MainWindow)からイベントを送信する
      window.myAPI.sendEvent(this.message)
    }
  }
}
</script>

メインプロセスには'send-event'のリスナーを定義することで、IPC通信を待ち受けできます。
受け取ったデータはコンソールにログ出力してみましょう。

background.js
// 説明に関係ない一部コードは省略しています
app.on('ready', async () => {
  if (isDevelopment && !process.env.IS_TEST) {
    // Install Vue Devtools
    try {
      await installExtension(VUEJS3_DEVTOOLS)
    } catch (e) {
      console.error('Vue Devtools failed to install:', e.toString())
    }
  }

  // レンダラープロセスから 'open-sub-window' チャンネルへ着信
  ipcMain.handle("open-sub-window", () => {
    // 子ウィンドウを作成
    subWindow = new BrowserWindow({
      title: "Sub Window",
      webPreferences: {
        nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
        contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION,
        preload: path.join(__dirname, "preload.js"),
      },
    });
    // 子ウィンドウ用 HTML
    subWindow.loadURL(process.env.WEBPACK_DEV_SERVER_URL + 'sub-window')
    if (!process.env.IS_TEST) subWindow.webContents.openDevTools()
  })
  // ****** ↓ココを追加 ******
  // レンダラープロセスからのイベントをリッスンする
  ipcMain.on('send-event', (_event, msgObj) => console.log(msgObj))
  // ****** ↑ココを追加 ******
  createWindow()
})

アプリを起動してメッセージを入力し送信!
image.png

うまくいけばターミナル側のコンソールにメッセージの内容が表示されているはずです。

こんにちは

メインプロセスから子ウィンドウにデータ送信

最後にメインプロセスから子ウィンドウのレンダラーにデータを送信します。
contextBridgeにメインプロセスからのIPC通信を待ち受ける関数を追加します。

preload.js
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('myAPI', {
  // メインプロセスのサブウィンドウ起動を呼び出し
  openSubWindow: () => ipcRenderer.invoke("open-sub-window"),
  // メインプロセスの 'send-event' にメッセージを送信
  sendEvent: (msgObj) => ipcRenderer.send('send-event', msgObj),
  // ****** ↓ココを追加 ******
  reciveEvent: (listener) => ipcRenderer.on('recive-event', listener)
  // ****** ↑ココを追加 ******
})

子ウィンドウ側ではcontextBridgeのリスナーを購読します。
配信されたデータを画面表示するようにしてみます。

views/SubWindow.vue
<template>
  <h1>Sub Window</h1>
</template>

<script>
export default {
  name: 'SubWindow',
  data() {
    return {
      message: '',
    }
  },
  mounted() {
    window.myAPI.reciveEvent((_event, msgObj) => {
      // メインプロセスからのイベントリッスン
      this.message = msgObj;
    })
  }
}
</script>

メインプロセスでは、子ウィンドウのcontextBridgeに対してデータを送信する処理を追加しました。

background.js
// 説明に関係ない一部コードは省略しています
app.on('ready', async () => {
  if (isDevelopment && !process.env.IS_TEST) {
    // Install Vue Devtools
    try {
      await installExtension(VUEJS3_DEVTOOLS)
    } catch (e) {
      console.error('Vue Devtools failed to install:', e.toString())
    }
  }

  // レンダラープロセスから 'open-sub-window' チャンネルへ着信
  ipcMain.handle("open-sub-window", () => {
    if (subWindow) return;
    // 子ウィンドウを作成
    subWindow = new BrowserWindow({
      title: "Sub Window",
      webPreferences: {
        nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
        contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION,
        preload: path.join(__dirname, "preload.js"),
      },
    });
    // 子ウィンドウ用 HTML
    subWindow.loadURL(process.env.WEBPACK_DEV_SERVER_URL + 'sub-window')
    if (!process.env.IS_TEST) subWindow.webContents.openDevTools();
    subWindow.on('closed', () => (subWindow = null));
  })
  // レンダラープロセスからのイベントをリッスンする
  ipcMain.on('send-event', (_event, msgObj) => {
    console.log(msgObj)
    subWindow.webContents.send('recive-event', msgObj)
  })
  createWindow()
})

アプリから子ウィンドウを開いた状態でメッセージを送ると…
image.png

子ウィンドウ側のテキストinputにメッセージが表示されました:tada:
image.png

補足

Electronのドキュメントを読んでいるとcontextBridge使っていればなんでも良いという訳ではないようです。

preload.js
// ❌ 悪いコード
contextBridge.exposeInMainWorld('myAPI', {
  send: ipcRenderer.send
})

上記のように形だと、引数のフィルタリングもなくIPC通信がほぼ貫通状態になってしまい、contextBridgeを使っている意味がありません。
下記のように、1つの処理に対して1つの関数を用意して、不要なIPC通信をさせないことが大切ということですね。

preload.js
// ✅ 良いコード
contextBridge.exposeInMainWorld('myAPI, {
  loadPreferences: () => ipcRenderer.invoke('load-prefs')
})

参考文献

一緒に二次元業界を盛り上げていきませんか?

株式会社viviONでは、フロントエンドエンジニアを募集しています。

また、フロントエンドエンジニアに限らず、バックエンド・SRE・スマホアプリなど様々なエンジニア職を募集していますので、ぜひ採用情報をご覧ください。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1