viviONグループでは、DLsiteやcomipoなど、二次元コンテンツを世の中に届けるためのサービスを運営しています。
ともに働く仲間を募集していますので、興味のある方はこちらまで。
Electronでマルチウィンドウを制御したい
知り合いの自転車屋さんに、商品のポップを簡単にデザインできるツールを作れないか相談を受けたので、Electronを使ったデスクトップアプリで作ってみることにしました
ポップのプレビュー画面が必要になるのですが、その際のマルチウィンドウ制御に少し悩まされたので、自身の備忘録も兼ねて検証内容をまとめてみました。
検証環境
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
プロジェクトディレクトリに移動しelectron-builderを追加。
cd multi-window-app
vue add electron-builder
ただ、13はあまりにも古すぎるため、まだサポート期間内の29をインストールしました。
npm install electron@29.0.0
Electronを起動してみます。
npm run electron:serve
親ウィンドウから子ウィンドウを開く
まず別ウィンドウを開くところから試します。
親ウィンドウのレンダラープロセスが、メインプロセスに対して子ウインドウの起動処理を呼び出します。
メインプロセスでは、レンダラープロセスからの通知を待ち受けて新しいウィンドウを開く処理を追加しています。
// 説明に関係ない一部コードは省略しています
'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()
を定義しています。
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('myAPI', {
// メインプロセスのサブウィンドウ起動を呼び出し
openSubWindow: () => ipcRenderer.invoke("open-sub-window")
})
1点ポイントとして、Electronのビルド成果物にプリロードスクリプトを含めるためvue.config.js
に指定が必要となります。
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
// コレを追加
pluginOptions: {
electronBuilder: {
preload: "src/preload.js",
},
},
})
最後に、レンダラープロセスからプリロードスクリプトで定義したopenSubWindow()
呼び出すことで一通りの処理が完成です。
<template>
<button @click="openSubWindow">Open Sub Window</button>
</template>
<script>
export default {
name: 'MainWindow',
methods: {
openSubWindow() {
// レンダラープロセス (mainWindow)からサブウィンドウを開く
window.myAPI.openSubWindow()
}
}
}
</script>
アプリを起動し子ウィンドウを開くと新しい画面が立ち上がります。
※子ウィンドウも親と同じページを指定しているので開くページの中身は変わりません。
今回はレンダラープロセスからメインプロセスを呼び出して子ウィンドウを生成していますが、レンダラープロセスから直接子ウィンドウを開くこともできるようです。
Electronでは、WebネイティブのChrome WindowとElectronのBrowserWindowがペアリングされるため、JavaScriptでのwindow.open()
でBrowserWindowを生成することができるとのこと。
ただ、今回はウィンドウのインスタンスをメインプロセスに集約しておいた方が扱いやすいだろうという判断で、メインプロセスからウィンドウの生成を行っています。
親子ウィンドウ間でデータを受け渡す
子ウィンドウを開くことができたので、次はデータの送信を試してみます。
わかりやすくするためにVue Routerを導入してページを作成し、そのページ間(親子ウィンドウ間)でデータを受け渡します。
Vue Routerで親子ページを作成
npm install vue-router
ルートパス(初期画面)は親ウィンドウ、/sub-window
を子ウィンドウとしてrouter/index.js
を作成します。
import { createRouter, createWebHashHistory } 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: createWebHashHistory(),
routes,
});
export default router;
main.js
やApp.vue
もVue Routerに合わせて修正します。
import { createApp } from 'vue'
import App from './App.vue'
import router from "./router"
createApp(App).use(router).mount('#app')
<template>
<router-view />
</template>
メインプロセスの子ウィンドウを開く際のパスを'sub-window'
に変更。
// レンダラープロセスから '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ファイルを作成。
<template>
<h1>Sub Window</h1>
</template>
<script>
export default {
name: 'SubWindow',
}
</script>
アプリを起動して子ウィンドウを開くと別ページが表示されるはずです。
親子ウィンドウ間でデータ送信
いよいよ本題の親子ウィンドウ間でのデータ送信を試します。
プリロードスクリプトについて
先ほどはさらっと流していましたが、プリロードスクリプトという一見冗長な仕組みを使うのにはElectronのIPC通信(Inter-Process Communication)の歴史が絡んでいました。
当初のElectronにはプリロードスクリプトは存在せず、レンダラーから直でNode.jsのAPIにアクセスしていたようです。BrowserWindowのwebPreferencesにnodeIntegration
という項目がありますが、これをtrue
にしてあげることでレンダラーからでもNode.jsのAPIへのアクセスを可能にしていたんですね。
しかし、レンダラーからNode.jsやElectron APIにアクセスできるというのはセキュリティ的なリスクを伴うことになるため、contextBridge
というものが導入されました。
contextBridge
はメインプロセスから切り離されているため、これを経由することによって直接Node.jsやElectron APIにアクセスすることを防いでいます。コンテキストの分離を有効にするにはBrowserWindowのwebPreferencesのcontextIsolation
をtrue
にしましょう。(現在はデフォルトでtrue
です)
親ウィンドウからメインプロセスへのデータ送信
前述のcontextBridge
に、メインプロセスに対してデータを送信するための新たな関数を追加します。
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('myAPI', {
// メインプロセスのサブウィンドウ起動を呼び出し
openSubWindow: () => ipcRenderer.invoke("open-sub-window")
// メインプロセスの 'send-event' にメッセージを送信
sendEvent: (msgObj) => ipcRenderer.send('send-event', msgObj)
})
親ウィンドウのレンダラープロセスにも処理を追加。
<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通信を待ち受けできます。
受け取ったデータはコンソールにログ出力してみましょう。
// 説明に関係ない一部コードは省略しています
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()
})
うまくいけばターミナル側のコンソールにメッセージの内容が表示されているはずです。
こんにちは
メインプロセスから子ウィンドウにデータ送信
最後にメインプロセスから子ウィンドウのレンダラーにデータを送信します。
contextBridgeにメインプロセスからのIPC通信を待ち受ける関数を追加します。
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のリスナーを購読します。
配信されたデータを画面表示するようにしてみます。
<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
に対してデータを送信する処理を追加しました。
// 説明に関係ない一部コードは省略しています
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()
})
子ウィンドウ側のテキストinputにメッセージが表示されました
補足
Electronのドキュメントを読んでいるとcontextBridge
使っていればなんでも良いという訳ではないようです。
// ❌ 悪いコード
contextBridge.exposeInMainWorld('myAPI', {
send: ipcRenderer.send
})
上記のように形だと、引数のフィルタリングもなくIPC通信がほぼ貫通状態になってしまい、contextBridge
を使っている意味がありません。
下記のように、1つの処理に対して1つの関数を用意して、不要なIPC通信をさせないことが大切ということですね。
// ✅ 良いコード
contextBridge.exposeInMainWorld('myAPI, {
loadPreferences: () => ipcRenderer.invoke('load-prefs')
})
参考文献
一緒に二次元業界を盛り上げていきませんか?
株式会社viviONでは、フロントエンドエンジニアを募集しています。
また、フロントエンドエンジニアに限らず、バックエンド・SRE・スマホアプリなど様々なエンジニア職を募集していますので、ぜひ採用情報をご覧ください。