⚛️
React+viteとelectronでデスクトップアプリっぽいものを作りたい①
経緯
- その昔デスクトップアプリをWindowsFormで作っていた
- C#系のWindowsFormやWPFでモダンなUIを表現できないか模索したが難しそう
- Webアプリをデスクトップアプリ化してはどうか?
プロジェクト作成
- ノードパッケージマネージャーよりviteでプロジェクト作成を行う
- 今回は適当にプロジェクト名は「DesktopTest」とした
$ npm create vite@latest
> npx
> create-vite
│
◇ Project name:
│ DesktopTest
│
◇ Package name:
│ desktoptest
│
◇ Select a framework:
│ React
│
◇ Select a variant:
│ TypeScript + SWC
│
◇ Scaffolding project in C:\Users\yamamura\Documents\MyDevelop\DesktopTest...
│
└ Done. Now run:
cd DesktopTest
npm install
npm run dev
npm notice
npm notice New major version of npm available! 10.8.1 -> 11.2.0
npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.2.0
npm notice To update run: npm install -g npm@11.2.0
npm notice
- 続いて必要なパッケージをインストールします。
npm install
added 140 packages, and audited 141 packages in 25s
40 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
- さらに開発環境で実行させます。http://localhost:5173/にアクセスしたら画面が表示されています。
$ npm run dev
> desktoptest@0.0.0 dev
> vite
VITE v6.2.1 ready in 346 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help

※参考
electronで実行できるようにする
- electronを開発環境にインストール
$ npm install electron --save-dev
- Electron アプリは「メインプロセス」と「レンダラープロセス」に分かれます。ここでは、React+Vite で作成したフロントエンド(レンダラープロセス)を読み込むためのメインプロセスファイル(例:main.js)を作成します。
- ./main.js
// main.js
import { app, BrowserWindow } from 'electron';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function createWindow() {
// ブラウザウィンドウの作成
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
// セキュリティ向上のため nodeIntegration は無効にする
nodeIntegration: false,
contextIsolation: true,
},
});
// 開発環境の場合は Vite の開発サーバー、ビルド済みの場合はローカルファイルを読み込む
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:3000');
} else {
mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'));
}
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
// macOS 以外は全ウィンドウが閉じたらアプリを終了する
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
// macOS では Dock アイコンがクリックされた際、ウィンドウがなければ再作成する
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
- package.jsonにビルドコマンド用にelectronとelectron:buildを追加する
- ./package.json
"main": "main.js", // ←ここを追加
"scripts": {
"dev": "vite",
"build": "vite build",
"electron": "set NODE_ENV=development && electron .", // ←ここを追加
"electron:build": "npm run build && electron .", // ←ここを追加
}
- ./vite.config.json
import react from '@vitejs/plugin-react-swc'
import { defineConfig } from 'vite'
export default defineConfig({
base: './', // ←ここを追加
plugins: [react()],
})
- ビルド実行する
$ npm run electron:build
> desktoptest@0.0.0 electron:build
> npm run build && electron .
> desktoptest@0.0.0 build
> tsc -b && vite build
vite v6.2.1 building for production...
✓ 32 modules transformed.
dist/index.html 0.47 kB │ gzip: 0.30 kB
dist/assets/react-CHdo91hT.svg 4.13 kB │ gzip: 2.05 kB
dist/assets/index-D8b4DHJx.css 1.39 kB │ gzip: 0.71 kB
dist/assets/index-uZHFss2t.js 186.93 kB │ gzip: 59.03 kB
✓ built in 1.40s

カスタムタイトルバーの実装
-
デフォルトのままでは嫌なのでカスタムタイトルバーを実装する
-
./src/CustomTitleBar.tsx
import React from 'react';
interface CustomDragStyle extends React.CSSProperties {
WebkitAppRegion?: 'drag' | 'no-drag';
}
export const CustomTitleBar: React.FC = () => {
const handleMinimize = () => {
window.electronAPI?.minimizeWindow();
};
const handleMaximize = () => {
window.electronAPI?.maximizeWindow();
};
const handleClose = () => {
window.electronAPI?.closeWindow();
};
return (
<div
style={{
WebkitAppRegion: 'drag',
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: '35px',
margin: 0,
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#f0f0f0'
} as CustomDragStyle}
>
<div
style={{
WebkitAppRegion: 'no-drag',
marginLeft: '10px',
color: '#000',
} as CustomDragStyle}
>
タイトル
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
style={{
margin: '5px',
padding: '0',
WebkitAppRegion: 'no-drag',
width: '50px',
verticalAlign: 'middle'
} as CustomDragStyle}
onClick={handleMinimize}
>-</button>
<button
style={{
margin: '5px',
padding: '0',
WebkitAppRegion: 'no-drag',
width: '50px',
verticalAlign: 'middle'
} as CustomDragStyle}
onClick={handleMaximize}
>□</button>
<button
style={{
margin: '5px',
padding: '0',
WebkitAppRegion: 'no-drag',
width: '50px',
verticalAlign: 'middle'
} as CustomDragStyle}
onClick={handleClose}
>X</button>
</div>
</div>
);
};
- App.tsxで実装
- ./src/App.tsx
import { useState } from 'react';
import './App.css';
import reactLogo from './assets/react.svg';
import { CustomTitleBar } from './CustomTitleBar';
import viteLogo from '/vite.svg';
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<CustomTitleBar />
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App
- セキュリティ対策として、レンダラープロセスで Node.js の機能に直接アクセスしないようにするため、preload.js を作成します。たとえば以下のように、レンダラープロセス側に限定した API を公開できます。
- ./preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
minimizeWindow: () => ipcRenderer.send('minimize-window'),
closeWindow: () => ipcRenderer.send('close-window'),
maximizeWindow: () => ipcRenderer.send('maximize-window')
});
- ./src/global.d.ts
export { };
declare global {
interface Window {
electronAPI?: {
minimizeWindow: () => void;
closeWindow: () => void;
maximizeWindow: () => void;
};
}
}
- main.jsの更新
import { app, BrowserWindow, ipcMain } from 'electron';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function createWindow() {
// ブラウザウィンドウの作成
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
// セキュリティ向上のため nodeIntegration は無効にする
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
},
frame: false,
titleBarStyle: 'hidden',
});
// 開発環境の場合は Vite の開発サーバー、ビルド済みの場合はローカルファイルを読み込む
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:3000');
} else {
mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'));
}
}
ipcMain.on('minimize-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
win?.minimize();
});
ipcMain.on('close-window', (event) => {
BrowserWindow.fromWebContents(event.sender)?.close();
});
ipcMain.on('maximize-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win?.isMaximized()) {
win?.unmaximize();
} else {
win?.maximize();
}
});
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
// macOS 以外は全ウィンドウが閉じたらアプリを終了する
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
// macOS では Dock アイコンがクリックされた際、ウィンドウがなければ再作成する
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});

Discussion