React + Electron 实战项目
用 Vite + React 搭 Electron 应用:结构、IPC 类型、状态与打包。
脚手架
npm create @quick-start/electron@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev结构
my-app/
├── electron.vite.config.ts
├── src/
│ ├── main/index.ts
│ ├── preload/index.ts
│ └── renderer/
│ ├── index.html
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ └── types.d.ts共享类型
// src/shared/api.ts
export interface FileInfo {
name: string;
path: string;
size: number;
}
export interface AppApi {
getVersion(): Promise<string>;
openFile(): Promise<FileInfo | null>;
saveFile(path: string, text: string): Promise<boolean>;
onProgress(cb: (percent: number) => void): void;
}// src/renderer/src/types.d.ts
import type { AppApi } from '../../shared/api';
declare global {
interface Window {
api: AppApi;
}
}
export {};Keep types in shared so the main process, preload and renderer use one definition. Change the contract and all three fail to compile — the biggest win of TypeScript in Electron.
预加载
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';
import type { AppApi } from '../shared/api';
const api: AppApi = {
getVersion: () => ipcRenderer.invoke('app:version'),
openFile: () => ipcRenderer.invoke('file:open'),
saveFile: (path, text) => ipcRenderer.invoke('file:save', { path, text }),
onProgress: (cb) => {
ipcRenderer.on('progress', (_e, p: number) => cb(p));
}
};
contextBridge.exposeInMainWorld('api', api);主进程
// src/main/index.ts
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron';
import { join } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
ipcMain.handle('app:version', () => app.getVersion());
ipcMain.handle('file:open', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile']
});
if (canceled) return null;
const p = filePaths[0];
const text = await readFile(p, 'utf8');
return { name: p.split(/[\\/]/).pop()!, path: p, size: text.length };
});
ipcMain.handle('file:save', async (_e, { path, text }: { path: string; text: string }) => {
await writeFile(path, text, 'utf8');
return true;
});
function createWindow(): void {
const win = new BrowserWindow({
width: 1100, height: 720, show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false
}
});
win.once('ready-to-show', () => win.show());
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
if (process.env.ELECTRON_RENDERER_URL) {
win.loadURL(process.env.ELECTRON_RENDERER_URL);
} else {
win.loadFile(join(__dirname, '../renderer/index.html'));
}
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});React 组件
// src/renderer/src/App.tsx
import { useEffect, useState } from 'react';
import type { FileInfo } from '../../shared/api';
export default function App() {
const [version, setVersion] = useState('');
const [file, setFile] = useState<FileInfo | null>(null);
const [progress, setProgress] = useState(0);
useEffect(() => {
window.api.getVersion().then(setVersion);
window.api.onProgress(setProgress);
}, []);
return (
<main style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
<h1>React + Electron</h1>
<p>版本 {version || '…'}</p>
<button onClick={async () => setFile(await window.api.openFile())}>
选择文件
</button>
{file && <p>{file.name}({file.size} 字节)</p>}
{progress > 0 && <progress value={progress} max={100} />}
</main>
);
}Clean up listeners registered in useEffect on unmount, or hot reload registers them repeatedly and one message is handled many times — the progress bar then jumps erratically.
useEffect(() => {
const handler = (p: number) => setProgress(p);
window.api.onProgress(handler);
return () => window.api.offProgress(handler); // 预加载里补一个 off
}, []);打包
# electron-builder.yml
appId: com.example.reactapp
productName: React App
directories:
output: release
files:
- out/**/*
- '!**/*.map'
asar: true
win:
target: [nsis]
mac:
target: [dmg]
linux:
target: [AppImage, deb]npm run buildout/
├── main/index.js
├── preload/index.js
└── renderer/
├── index.html
└── assets/The build produces three bundles: main, preload and renderer. files must include all of out, or the packaged main process cannot find the preload script and the window stays blank.
__dirname is out/main, so ../preload/index.js is correct; after packaging the structure holds but files live in app.asar. Never build resource paths from process.cwd() — after packaging it points at the user's directory.Vue 版与 React 版的选择
| React | ||
|---|---|---|
| Learning curve | 平缓,模板直观 | 需理解 JSX 与 Hooks |
| TypeScript | 支持良好 | 生态更深 |
| Ecosystem | 组件库丰富(Element Plus) | 组件库更丰富(Ant Design、MUI) |
| Desktop fit | 两者都够用 | 两者都够用 |
At the Electron layer the two are identical: main process, preload and IPC are written the same way. The choice depends only on team familiarity and does not limit what the desktop app can do.
脚手架
npm create @quick-start/electron@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev结构
my-app/
├── electron.vite.config.ts
├── src/
│ ├── main/index.ts
│ ├── preload/index.ts
│ └── renderer/
│ ├── index.html
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ └── types.d.ts共享类型
// src/shared/api.ts
export interface FileInfo {
name: string;
path: string;
size: number;
}
export interface AppApi {
getVersion(): Promise<string>;
openFile(): Promise<FileInfo | null>;
saveFile(path: string, text: string): Promise<boolean>;
onProgress(cb: (percent: number) => void): void;
}// src/renderer/src/types.d.ts
import type { AppApi } from '../../shared/api';
declare global {
interface Window {
api: AppApi;
}
}
export {};类型放在 shared 目录,主进程、预加载、渲染三处共用同一份定义。改了接口,三边同时报错——这是 TypeScript 在 Electron 项目里最大的价值。
预加载
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';
import type { AppApi } from '../shared/api';
const api: AppApi = {
getVersion: () => ipcRenderer.invoke('app:version'),
openFile: () => ipcRenderer.invoke('file:open'),
saveFile: (path, text) => ipcRenderer.invoke('file:save', { path, text }),
onProgress: (cb) => {
ipcRenderer.on('progress', (_e, p: number) => cb(p));
}
};
contextBridge.exposeInMainWorld('api', api);主进程
// src/main/index.ts
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron';
import { join } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
ipcMain.handle('app:version', () => app.getVersion());
ipcMain.handle('file:open', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile']
});
if (canceled) return null;
const p = filePaths[0];
const text = await readFile(p, 'utf8');
return { name: p.split(/[\\/]/).pop()!, path: p, size: text.length };
});
ipcMain.handle('file:save', async (_e, { path, text }: { path: string; text: string }) => {
await writeFile(path, text, 'utf8');
return true;
});
function createWindow(): void {
const win = new BrowserWindow({
width: 1100, height: 720, show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false
}
});
win.once('ready-to-show', () => win.show());
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
if (process.env.ELECTRON_RENDERER_URL) {
win.loadURL(process.env.ELECTRON_RENDERER_URL);
} else {
win.loadFile(join(__dirname, '../renderer/index.html'));
}
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});React 组件
// src/renderer/src/App.tsx
import { useEffect, useState } from 'react';
import type { FileInfo } from '../../shared/api';
export default function App() {
const [version, setVersion] = useState('');
const [file, setFile] = useState<FileInfo | null>(null);
const [progress, setProgress] = useState(0);
useEffect(() => {
window.api.getVersion().then(setVersion);
window.api.onProgress(setProgress);
}, []);
return (
<main style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
<h1>React + Electron</h1>
<p>版本 {version || '…'}</p>
<button onClick={async () => setFile(await window.api.openFile())}>
选择文件
</button>
{file && <p>{file.name}({file.size} 字节)</p>}
{progress > 0 && <progress value={progress} max={100} />}
</main>
);
}useEffect 里注册的监听器在组件卸载时要清理,否则开发期热更新反复注册,同一条消息会被处理多次——表现是进度条跳得飞快。
useEffect(() => {
const handler = (p: number) => setProgress(p);
window.api.onProgress(handler);
return () => window.api.offProgress(handler); // 预加载里补一个 off
}, []);打包
# electron-builder.yml
appId: com.example.reactapp
productName: React App
directories:
output: release
files:
- out/**/*
- '!**/*.map'
asar: true
win:
target: [nsis]
mac:
target: [dmg]
linux:
target: [AppImage, deb]npm run buildout/
├── main/index.js
├── preload/index.js
└── renderer/
├── index.html
└── assets/构建产物分三份:主进程、预加载、渲染。electron-builder 的 files 要包含整个 out,否则打包后主进程找不到预加载脚本,应用直接白屏。
__dirname 指向 out/main,所以 ../preload/index.js 才正确;打包后路径结构不变,但文件在 app.asar 里。任何时候都不要用 process.cwd() 拼资源路径——打包后它指向用户目录。Vue 版与 React 版的选择
| Vue | React | |
|---|---|---|
| 上手 | 平缓,模板直观 | 需理解 JSX 与 Hooks |
| 类型 | 支持良好 | 生态更深 |
| 生态 | 组件库丰富(Element Plus) | 组件库更丰富(Ant Design、MUI) |
| 桌面适配 | 两者都够用 | 两者都够用 |
Electron 层面两者完全一样:主进程、预加载、IPC 的写法一字不差。选哪个只取决于团队熟悉度,不影响桌面能力的上限。