TypeScript+React+Electronでデスクトップアプリを作る
TypeScript+React+Electronでデスクトップアプリケーションを作ってみる
環境構築
まずTypeScript+React+Electronをビルドできる環境を作る。作るのはpackage.json/tsconfig.json/vite.config.js
それぞれを作っていく
package.json
{
"main": "dist/main.js",
"license": "MIT",
"scripts": {
"check": "tsc --noEmit",
"build": "npm run check && vite build",
"start": "electron ."
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react-swc": "^4.1.0",
"electron": "^39.0.0",
"typescript": "^5.9.3",
"vite": "^7.1.9",
"vite-plugin-electron": "^0.29.0"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.4"
}
}
dependenciesで実行に必要なReactの依存性を設定。devDependenciesでTypeScriptやviteでビルドする環境に必要なのを設定しておく。今回は普通にvite-plugin-electronを使って作る
mainにはElectronアプリが起動するのに使うエントリーポイントファイルを指定する
tsconfig.json
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "node_modules/.tsBuildInfo",
"noEmit": true,
"noUnusedLocals": true,
"skipLibCheck": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"isolatedModules": true,
"types": ["node", "electron"]
},
"include": ["src/**/*"]
}
typesでnodeとelectronの型定義を有効にしておく。あとは省略
vite.config.js
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
import electron from 'vite-plugin-electron';
import react from '@vitejs/plugin-react-swc';
export default defineConfig({
base: './',
plugins: [
react(),
electron([
{
entry: 'src/main/main.ts',
vite: {
build: {
outDir: 'dist'
}
}
},
{
entry: 'src/ipc/preload.ts',
vite: {
build: {
outDir: 'dist'
}
}
}
])
],
build: {
minify: 'esbuild',
outDir: './dist',
emptyOutDir: true,
copyPublicDir: false,
rollupOptions: {
onwarn(warning, warn) {
if (warning.code === 'MODULE_LEVEL_DIRECTIVE') {
return;
}
warn(warning);
},
input: {
index: resolve(__dirname, 'index.html'),
}
},
},
});
vite-plugin-electornを使ってElectronアプリをビルドするように設定
これでビルドできる環境はできたので上記にあるアプリのファイルを作っていく
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/renderer/index.tsx"></script>
</body>
</html>
<script type=module
>でReactで表示するエントリーポイントなファイルを指定
あとはガシガシ実装を書いていくだけ。ちなみに以下のファイルを作っていくがrenderer/index.tsxはapp.tsxを描画してるだけなので省略する
├── ipc
│ └── preload.ts
├── main
│ └── main.ts
└── renderer
├── app.tsx
├── electron.d.ts
└── index.tsx
作るアプリについて
ざっくりとElectronで画面を作ってそれをReactでレンダリング。でReactでレンダリングする際にReact側からElectronのプロセス側にデータを要求してElectron側からデータを返す。でそれを描画するだけ
src/main/main.ts
Electronアプリケーションのエントリーポイントファイル
import { app, BrowserWindow, ipcMain } from 'electron';
import * as path from 'node:path';
app.disableHardwareAcceleration();
const setupIPCListener = (): void => {
ipcMain.handle('file:request-file', async (event) => {
return '/path/to/test/test.jpg';
});
};
const createWindow = async (): Promise<BrowserWindow> => {
const win = new BrowserWindow({
darkTheme: true,
autoHideMenuBar: true,
center: true,
webPreferences: {
preload: path.resolve(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
},
});
await win.loadFile(path.resolve(__dirname, 'index.html'));
return win;
};
app.whenReady().then(async () => {
setupIPCListener();
const win = await createWindow();
}).catch(() => app.quit());
file:request-fileっていうのはReact側からElectronプロセス側にデータを要求する際に使うチャネル名
でElectronアプリプロセス側とReactでレンダリングしている側と繋げるためのipcスクリプトが必要なのでそれをipc/preload.tsに定義する
src/ipc/preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
requestFile: (): Promise<string> => {
return ipcRenderer.invoke("file:request-file");
}
});
main.ts側で定義したElectronメインプロセス側にデータ要求リクエストを送信してデータを受信するだけ。invokeの引数にはElectronメインプロセスで指定したチャネル名を指定する
これでElectronアプリ側の実装は終わり。あとはReactでレンダリングする側のアプリケーションの実装を作る
src/renderer/electron.d.ts
まあただの型定義なんで(ry
type ElectronAPI = {
requestFile: () => Promise<string>
};
declare global {
interface Window {
electronAPI: ElectronAPI
}
}
export {}
src/renderer/app.tsx
import { useEffect, useState } from 'react';
const App = (): React.JSX.Element => {
const [ file, setFile ] = useState<string | null>(null);
// renderer(React) -> electron -> renderer(React)
useEffect(() => {
(async () => {
const requestFile = await window.electronAPI.requestFile();
setFile(requestFile);
})();
}, []);
if (file === null) {
return (<div>loading...</div>);
}
return (
<img src={file} />
);
};
export default App;
終わり。これでビルドするとReact側でwindow.electronAPI.requestFile();でElectronメインプロセスにデータを要求する、そしてメインプロセス側のipcMain.handleが対応するチャネルで処理されて値がReact側に返されそれが描画される(今回はただ画像を表示しているだけなのでそれが表示されるだけ)
んまあという感じでElectronでデスクトップアプリを作ってそのUIとかをReactとかで作ったりすることもできるよ〜ってことで終わり
余談1
今回のは紹介用に一部を切り出して作ったやつなので切り出してないやつはhttps://github.com/kinjouj/typescript-react-electron-photoviewerにあります
余談2: ElectronメインプロセスからReactレンダラープロセスに値をぶんなげる方法
上記で書いた方法はReact側からメインプロセス側にデータ要求リスエストを出してそれを受け取るような形でやったがメインプロセス側からReactなどのレンダリング側にデータを受け取らせるようなこともできる。なのでそういう方法を取りたい場合は以下のように修正する
src/ipc/preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
onReceived: (callback: (data: string) => void): void => {
ipcRenderer.on('file:request-file', (event, data: string) => callback(data));
}
});
んまあReact側からElectronメインプロセスから送られてくるデータを受信するAPIを定義
src/renderer/electron.d.ts
type ElectronAPI = {
onReceived: (data) => Promise<string>
};
declare global {
interface Window {
electronAPI: ElectronAPI
}
}
export {}
単純にElectronAPI側の定義を設定
src/renderer/app.tsx
import { useEffect, useState } from 'react';
const App = (): React.JSX.Element => {
const [ file, setFile ] = useState<string | null>(null);
useEffect(() => {
window.electronAPI.onReceived((data: string) => {
setFile(data);
});
});
if (file === null) {
return (<div>loading...</div>);
}
return (
<img src={file} />
);
};
export default App;
定義したonReceivedを使ってデータを受信してそれを描画する
src/main/main.ts
app.whenReady().then(async () => {
const win = await createWindow();
setTimeout(() => {
win.webContents.send('file:request-file', '/path/to/test/test.jpg');
}, 3000);
}).catch(() => app.quit());
BrowserWindow.webContents.sendを使ってチェネル名でデータを送信。これでElectronアプリ側からReactなどで表示してるレンダラープロセス側でデータを受信したりできるようになる