React + Electron 实战项目

用 Vite + React 搭 Electron 应用:结构、IPC 类型、状态与打包。

Advanced
Key Points和 Vue 版结构一致:主进程管窗口、预加载暴露 API、React 只负责界面与调用。

脚手架

Bash
npm create @quick-start/electron@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev

结构

Text
my-app/
├── electron.vite.config.ts
├── src/
│   ├── main/index.ts
│   ├── preload/index.ts
│   └── renderer/
│       ├── index.html
│       └── src/
│           ├── main.tsx
│           ├── App.tsx
│           └── types.d.ts

共享类型

TypeScript
// 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;
}
TypeScript
// 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.

预加载

TypeScript
// 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);

主进程

TypeScript
// 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 组件

TSX
// 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.

TSX
useEffect(() => {
  const handler = (p: number) => setProgress(p);
  window.api.onProgress(handler);
  return () => window.api.offProgress(handler);   // 预加载里补一个 off
}, []);

打包

YAML
# 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]
Bash
npm run build
Text
out/
├── 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.

In development __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.

Friends & Partners