yarn create vite my-app --template react
cd my-app
yarn install
yarn add -D electron electron-builder concurrently wait-on cross-env
my-app/
├── electron/
│ ├── main.js # 主进程
│ └── preload.js # 预加载脚本
├── src/
│ ├── App.jsx
│ ├── main.jsx
│ └── components/
├── index.html
├── package.json
└── vite.config.js
{
"name": "my-app",
"version": "1.0.0",
"main": "electron/main.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"electron": "wait-on tcp:5173 && cross-env NODE_ENV=development electron .",
"electron:dev": "concurrently \"yarn dev\" \"yarn electron\"",
"electron:build": "vite build && electron-builder"
},
"build": {
"appId": "com.example.myapp",
"productName": "我的应用",
"directories": {
"output": "dist-electron"
},
"files": [
"dist/**/*",
"electron/**/*"
],
"mac": {
"target": ["dmg"]
},
"win": {
"target": ["nsis"]
},
"linux": {
"target": ["AppImage"]
}
}
}
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
server: {
port: 5173
},
build: {
outDir: 'dist'
}
})
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const isDev = process.env.NODE_ENV === 'development'
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true
}
})
if (isDev) {
win.loadURL('http://localhost:5173')
win.webContents.openDevTools()
} else {
win.loadFile(path.join(__dirname, '../dist/index.html'))
}
}
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
ipcMain.handle('get-app-version', () => {
return app.getVersion()
})
ipcMain.handle('get-app-path', (event, name) => {
return app.getPath(name)
})
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getAppPath: (name) => ipcRenderer.invoke('get-app-path', name),
openFile: () => ipcRenderer.invoke('open-file'),
saveFile: (content) => ipcRenderer.invoke('save-file', content),
showNotification: (title, body) => ipcRenderer.send('show-notification', title, body),
onUpdateAvailable: (callback) => {
ipcRenderer.on('update-available', (event, info) => callback(info))
}
})
import { createContext, useContext, useEffect, useState } from 'react'
const ElectronContext = createContext(null)
export function ElectronProvider({ children }) {
const [isElectron, setIsElectron] = useState(false)
const [appVersion, setAppVersion] = useState('')
useEffect(() => {
if (window.electronAPI) {
setIsElectron(true)
window.electronAPI.getAppVersion().then(version => {
setAppVersion(version)
})
}
}, [])
const value = {
isElectron,
appVersion,
api: window.electronAPI
}
return (
<ElectronContext.Provider value={value}>
{children}
</ElectronContext.Provider>
)
}
export function useElectron() {
const context = useContext(ElectronContext)
if (!context) {
throw new Error('useElectron must be used within ElectronProvider')
}
return context
}
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { ElectronProvider } from './contexts/ElectronContext'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ElectronProvider>
<App />
</ElectronProvider>
</React.StrictMode>
)
import { useElectron } from './contexts/ElectronContext'
import FileManager from './components/FileManager'
import './App.css'
function App() {
const { isElectron, appVersion } = useElectron()
return (
<div className="app">
<header>
<h1>React + Electron 应用</h1>
{isElectron && <span>v{appVersion}</span>}
</header>
<main>
{isElectron ? (
<FileManager />
) : (
<p>请在 Electron 环境中运行</p>
)}
</main>
</div>
)
}
export default App
const { dialog } = require('electron')
const fs = require('fs').promises
ipcMain.handle('open-file', async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{ name: 'Text Files', extensions: ['txt', 'md', 'json'] },
{ name: 'All Files', extensions: ['*'] }
]
})
if (result.canceled) {
return { success: false, canceled: true }
}
try {
const filePath = result.filePaths[0]
const content = await fs.readFile(filePath, 'utf-8')
return {
success: true,
filePath,
fileName: path.basename(filePath),
content
}
} catch (error) {
return { success: false, error: error.message }
}
})
ipcMain.handle('save-file', async (event, { filePath, content }) => {
try {
let saveFilePath = filePath
if (!saveFilePath) {
const result = await dialog.showSaveDialog({
filters: [
{ name: 'Text Files', extensions: ['txt', 'md'] }
]
})
if (result.canceled) {
return { success: false, canceled: true }
}
saveFilePath = result.filePath
}
await fs.writeFile(saveFilePath, content, 'utf-8')
return {
success: true,
filePath: saveFilePath,
fileName: path.basename(saveFilePath)
}
} catch (error) {
return { success: false, error: error.message }
}
})
import { useState } from 'react'
import { useElectron } from '../contexts/ElectronContext'
import './FileManager.css'
function FileManager() {
const { api } = useElectron()
const [currentFile, setCurrentFile] = useState(null)
const [content, setContent] = useState('')
const [isModified, setIsModified] = useState(false)
const handleOpenFile = async () => {
const result = await api.openFile()
if (result.success) {
setCurrentFile({
path: result.filePath,
name: result.fileName
})
setContent(result.content)
setIsModified(false)
} else if (!result.canceled) {
alert('打开文件失败: ' + result.error)
}
}
const handleSaveFile = async () => {
const result = await api.saveFile({
filePath: currentFile?.path,
content
})
if (result.success) {
setCurrentFile({
path: result.filePath,
name: result.fileName
})
setIsModified(false)
api.showNotification('保存成功', '文件已保存')
} else if (!result.canceled) {
alert('保存文件失败: ' + result.error)
}
}
const handleContentChange = (e) => {
setContent(e.target.value)
setIsModified(true)
}
return (
<div className="file-manager">
<div className="toolbar">
<button onClick={handleOpenFile}>打开文件</button>
<button onClick={handleSaveFile} disabled={!isModified}>
保存 {isModified && '*'}
</button>
<span className="file-name">
{currentFile ? currentFile.name : '未打开文件'}
</span>
</div>
<textarea
className="editor"
value={content}
onChange={handleContentChange}
placeholder="打开文件或开始输入..."
/>
<div className="status-bar">
<span>{content.length} 字符</span>
<span>{content.split('\n').length} 行</span>
</div>
</div>
)
}
export default FileManager
yarn add -D typescript @types/react @types/react-dom @types/node
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
export interface ElectronAPI {
getAppVersion: () => Promise<string>
getAppPath: (name: string) => Promise<string>
openFile: () => Promise<FileResult>
saveFile: (data: SaveFileData) => Promise<FileResult>
showNotification: (title: string, body: string) => void
onUpdateAvailable: (callback: (info: UpdateInfo) => void) => void
}
export interface FileResult {
success: boolean
canceled?: boolean
error?: string
filePath?: string
fileName?: string
content?: string
}
export interface SaveFileData {
filePath?: string
content: string
}
declare global {
interface Window {
electronAPI: ElectronAPI
}
}
import { useState } from 'react'
import { useElectron } from '../contexts/ElectronContext'
interface FileInfo {
path: string
name: string
}
function FileManager() {
const { api } = useElectron()
const [currentFile, setCurrentFile] = useState<FileInfo | null>(null)
const [content, setContent] = useState<string>('')
const [isModified, setIsModified] = useState<boolean>(false)
const handleOpenFile = async (): Promise<void> => {
const result = await api.openFile()
if (result.success && result.filePath && result.fileName) {
setCurrentFile({
path: result.filePath,
name: result.fileName
})
setContent(result.content || '')
setIsModified(false)
}
}
return (
)
}
export default FileManager
import { create } from 'zustand'
interface FileState {
currentFile: { path: string; name: string } | null
content: string
isModified: boolean
setCurrentFile: (file: { path: string; name: string } | null) => void
setContent: (content: string) => void
setIsModified: (modified: boolean) => void
}
export const useFileStore = create<FileState>((set) => ({
currentFile: null,
content: '',
isModified: false,
setCurrentFile: (file) => set({ currentFile: file }),
setContent: (content) => set({ content, isModified: true }),
setIsModified: (modified) => set({ isModified: modified })
}))
import { useFileStore } from '../stores/fileStore'
function FileManager() {
const { currentFile, content, isModified, setContent } = useFileStore()
}
import { lazy, Suspense } from 'react'
const FileManager = lazy(() => import('./components/FileManager'))
function App() {
return (
<Suspense fallback={<div>加载中...</div>}>
<FileManager />
</Suspense>
)
}
import { FixedSizeList } from 'react-window'
function FileList({ files }) {
const Row = ({ index, style }) => (
<div style={style}>
{files[index].name}
</div>
)
return (
<FixedSizeList
height={600}
itemCount={files.length}
itemSize={35}
width="100%"
>
{Row}
</FixedSizeList>
)
}
import { useMemo } from 'react'
import { debounce } from 'lodash-es'
function Editor() {
const handleChange = useMemo(
() => debounce((value) => {
localStorage.setItem('draft', value)
}, 1000),
[]
)
return (
<textarea onChange={(e) => handleChange(e.target.value)} />
)
}
yarn electron:build
yarn electron:build --mac
yarn electron:build --win
yarn electron:build --linux
{
"version": "0.2.0",
"configurations": [
{
"name": "Electron: Main",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"windows": {
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
},
"args": ["."],
"outputCapture": "std",
"env": {
"NODE_ENV": "development"
}
}
]
}
export default defineConfig({
server: {
cors: true
}
})
const isDev = process.env.NODE_ENV === 'development'
if (isDev) {
win.loadURL('http://localhost:5173')
} else {
win.loadFile(path.join(__dirname, '../dist/index.html'))
}
yarn add -D electron-reloader
try {
require('electron-reloader')(module)
} catch {}