🌟 引言:从0到1构建跨平台桌面应用 你是否曾想过将Web应用打包成原生桌面程序?是否遇到过前后端分离项目打包困难的问题?本文将带你深入Electron打包的核心机制,通过一个真实项目案例,详解如何将Vue前端与Python后端无缝整合,构建专业级跨平台桌面应用。
在ai 横行霸道
的这段时间, 我也没闲着自己鼓捣一些小玩意, 之前写了 几个vscode的插件感觉还挺有意思的,看着自己写的小玩意,到了一个公开的 市场
中还有些许雀跃, 好像在这里面找到了一些乐趣所在, 不在是为了敲代码而敲代码🔨.
本文基于最近写的一个项目总结所得, 可供大家参考和把玩🥣

📦 项目架构
dart
├── 🌐 前端:Vue3 + Vite + Element Plus
├── 🐍 后端:Python 3.8+ + FastAPI
├── 🖥️ 桌面框架:Electron 30+
└── 📦 打包工具:electron-builder + PyInstaller
核心依赖解析
- 前端 : vue@3.5.17 、 vite@7.0.0 、 element-plus@2.10.2
- 后端 : fastapi@0.100.0 、 uvicorn@0.23.2 、 python-multipart@0.0.6
- 打包 : electron-builder@24.13.0 、 pyinstaller@5.13.2
你一定会想, 前端项目可以打包, 那python服务写的接口如何与前端交互 ?
服务端口如何正常启动 ?
python虚拟环境如何依赖 ?
端口占用情况如何解决 ?
没错, 这些问题在写的时候也是存在的, 但问题为什么叫问题呢, 因为总会有各种各样的解决方法, 没有最好方案, 只有更高的方案, 只提供一个思路💡
electron
一个强大的跨平台桌面应用开发框架, 当你写了不想通过服务器分享给别人的时候, 那么他出现了, 无需部署、也不需要买服务器、一键打包、多端可用, 感兴趣又没接触过的朋友可以带着你的兴趣🌍, 和疑问去翻阅一个electron的官方文档 或者 相关资料. ---- 此处不在过多赘述
项目文件结构
本文只概述分享一下,前端 + 后端 如何基于electron打包和过程中的一些问题, 相当于就是一个壳子, 壳子里面的内容/功能各位彦祖可以自行填充 **只贴出核心代码, 代码太多就显得太干巴了, 图文结合思路打开💡**

前端项目结构
概述一下主要的文件内容, vite.config.ts、router、main.ts等这些页面就不过多赘述了.
流转时序图
先对数据流转有个概念,在去套下面的代码
壳子代码
既然是electron打包前后端项目,肯定先上 DJ
, electron.js 文件📃
首先点击应用就会触发创建窗口
scss
app.whenReady().then(async () => {
try {
// 先启动Python后端
await startPythonBackend();
// 然后创建窗口
createWindow();
} catch (error) {
console.error('Failed to initialize:', error);
app.quit();
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
启动Python后端
javascript
function startPythonBackend() {
return new Promise((resolve, reject) => {
console.log('Starting Python backend...');
// 确定Python后端路径
const isWindows = process.platform === 'win32';
const backendDir = path.join(app.getAppPath(), '..', 'backend');
const pythonExecutable = path.join(backendDir, isWindows ? 'venv\\Scripts\\python.exe' : 'venv/bin/python');
const mainScript = path.join(backendDir, 'main.py');
// 检查Python可执行文件是否存在
if (!fs.existsSync(pythonExecutable)) {
console.error(`Python executable not found: ${pythonExecutable}`);
reject(new Error(`Python executable not found: ${pythonExecutable}`));
return;
}
// 启动Python进程
pythonProcess = spawn(pythonExecutable, [mainScript], {
cwd: backendDir,
env: { ...process.env, ELECTRON_RUN: '1' }
});
// 处理Python进程输出
pythonProcess.stdout.on('data', (data) => {
console.log(`Python stdout: ${data}`);
if (data.toString().includes('正在启动XMind冒烟测试用例导出工具API服务器')) {
console.log('Python backend started successfully');
resolve();
}
});
pythonProcess.stderr.on('data', (data) => {
console.error(`Python stderr: ${data}`);
});
pythonProcess.on('error', (error) => {
console.error(`Failed to start Python process: ${error}`);
reject(error);
});
pythonProcess.on('close', (code) => {
console.log(`Python process exited with code ${code}`);
pythonProcess = null;
});
// 设置超时,避免无限等待
setTimeout(() => {
resolve(); // 即使没看到特定输出也继续
}, 5000);
});
}
创建主窗口
javascript
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
});
// 加载应用
const startUrl = isDev
? 'http://localhost:5173' // 开发环境使用Vite服务
: `file://${path.join(process.resourcesPath, 'dist/index.html')}`; // 生产环境使用构建后的文件
mainWindow.loadURL(startUrl);
// 开发环境打开开发者工具
if (isDev) {
mainWindow.webContents.openDevTools();
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
所有窗口关闭时退出应用
dart
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
应用退出前清理
ini
app.on('will-quit', () => {
// 终止Python进程
if (pythonProcess) {
console.log('Terminating Python backend...');
pythonProcess.kill();
pythonProcess = null;
}
});
处理IPC通信
javascript
ipcMain.handle('get-api-base-url', () => {
return 'http://localhost:8000'; // 返回API基础URL
});
先看了时序图, 在结合代码看一下 是不是清晰了很多 是不是以为就完事了, no ! 才刚刚开始, 那只是本地启动electron:dev的入口, 打包的入口还得多加几道程序

多加的这个几道逻辑, 即启动了后端服务, 也避免了端口被占用无法杀掉的case
Python 后端启动流程
- 调用 startPythonBackend() 函数启动后端服务
less
// 使用Python查找工具查找可用的Python后端
const pythonInfo = findPythonExecutable(
app.getAppPath(),
isDev ? path.join(app.getAppPath(), '..') : process.resourcesPath
);
- 系统检查 Python 环境和必要依赖(fastapi, uvicorn, lxml)
javascript
// 检查Python环境
const checkPythonEnv = spawn(pythonInfo.executablePath, ['-c', 'import fastapi, uvicorn, lxml']);
checkPythonEnv.on('error', (err) => {
console.error('Python环境检查失败:', err);
dialog.showErrorBox('后端启动失败',
'无法启动Python环境。\n\n' +
'错误原因:\n' +
`${err.message}\n\n` +
'请确保Python环境中安装了所有必要的依赖。'
);
reject(err);
});
- 如果检查失败,显示错误对话框并终止应用
- 如果检查成功,继续启动 Python 进程
- Python 进程启动后,创建 .port 文件存储随机分配的端口号(主要解决端口占用问题)
javascript
// 等待端口文件出现并读取端口号
const portFile = path.join(pythonInfo.backendDir, '.port');
let retries = 30; // 最多等待30秒
// 提取重试逻辑为高阶函数
const withRetry = async (fn, maxRetries = 5, delayMs = 1000) => {
for (let retries = maxRetries; retries > 0; retries--) {
try {
return await fn();
} catch (error) {
if (retries === 1) throw error; // 最后一次重试失败则抛出错误
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
};
// 优化后的端口检查函数
const checkPort = async (portFile, maxRetries = 5) => {
// 检查端口文件是否存在
const fileExists = await fs.promises.access(portFile)
.then(() => true)
.catch(() => false);
if (!fileExists) {
throw new Error('端口文件不存在');
}
// 读取并解析端口号
const portContent = await fs.promises.readFile(portFile, 'utf8');
const pythonPort = parseInt(portContent.trim(), 10);
if (isNaN(pythonPort) || pythonPort < 1 || pythonPort > 65535) {
throw new Error(`无效的端口号: ${portContent}`);
}
console.log(`Python后端使用端口: ${pythonPort}`);
// 测试端口连接
const response = await fetch(`http://127.0.0.1:${pythonPort}/health`);
if (!response.ok) {
throw new Error(`健康检查失败: ${response.status}`);
}
console.log('后端服务启动成功');
return pythonPort;
};
// 使用方式
const startBackendCheck = async () => {
try {
const port = await withRetry(
() => checkPort('/path/to/portFile'),
5, // 最大重试次数
1000 // 重试间隔(ms)
);
// 后端启动成功后的逻辑
} catch (error) {
console.error('后端服务启动失败:', error.message);
dialog.showErrorBox('后端启动失败',
`后端服务启动超时。
详细错误: ${error.message}`
);
}
};
- 获取API基础URL的IPC处理器
javascript
// 这就是动态获取到的端口 pythonPort
ipcMain.handle('get-api-base-url', () => {
return `http://127.0.0.1:${pythonPort}`;
});
利用主进程做一层转发, 将端口暴露给渲染进程, 前端页面就能顺势而为的调用 python服务的接口
javascript
const { contextBridge, ipcRenderer } = require('electron');
// 暴露给渲染进程的API
contextBridge.exposeInMainWorld('electronAPI', {
// 获取API基础URL
getApiBaseUrl: () => ipcRenderer.invoke('get-api-base-url'),
// 检查后端状态
checkBackendStatus: async () => {
try {
const baseUrl = await ipcRenderer.invoke('get-api-base-url')
const response = await fetch(`${baseUrl}/health`)
if (response.ok) {
return { status: 'online' }
}
return {
status: 'error',
error: `健康检查失败: ${response.status}`
}
} catch (error) {
return {
status: 'error',
error: error.message
}
}
}
});
到现在为止,前端就可以正常请求服务端的接口来,处理的异常边界还有很多, 过多就不往出贴了, 等我随后专门checkout一个分支出来供大家把玩 get-api-base-url()/api/xxxxxx当然接口层面还可以在封装一层, 这个就不过多赘述
Python 虚拟环境打包📦
首先虚拟环境创建肯定是在后端服务中, 但是调用脚本实现, 那就得在前端完成了, 因为electron入口文件在前端
- 准备阶段 - 创建虚拟环境

脚本命令 npm run prepare:python
javascript
/**
* 创建Python虚拟环境
*/
async function createVirtualEnv() {
console.log(`创建Python虚拟环境: ${venvDir}`);
// 创建虚拟环境,使用--copies选项创建独立的Python解释器副本
await runCommand(pythonCommand, ['-m', 'venv', '--copies', venvDir]);
console.log('虚拟环境创建成功');
// 安装依赖
console.log('安装Python依赖...');
// 在Windows上使用不同的激活命令
const activateCmd = isWindows ?
`${venvDir}\\Scripts\\activate.bat && ` :
`source "${venvDir}/bin/activate" && `;
// 获取虚拟环境中的Python和pip路径
const venvPython = isWindows ?
path.join(venvDir, 'Scripts', 'python.exe') :
path.join(venvDir, 'bin', 'python3');
const venvPip = isWindows ?
path.join(venvDir, 'Scripts', 'pip.exe') :
path.join(venvDir, 'bin', 'pip3');
// 安装wheel(避免某些包安装失败)
if (isWindows) {
await runCommand('cmd', ['/c', `${activateCmd} ${venvPip} install wheel`]);
} else {
await runCommand('bash', ['-c', `${activateCmd} ${venvPip} install wheel`]);
}
// 安装依赖
if (isWindows) {
await runCommand('cmd', ['/c', `${activateCmd} ${venvPip} install -r "${requirementsPath}"`]);
} else {
await runCommand('bash', ['-c', `${activateCmd} ${venvPip} install -r "${requirementsPath}"`]);
}
console.log('依赖安装完成');
// 创建环境准备标记
fs.writeFileSync(path.join(pythonEnvDir, '.ready'), 'ready');
console.log(`创建环境准备标记: ${path.join(pythonEnvDir, '.ready')}`);
}
- 打包阶段 - 将环境纳入应用

npm run build:complete - 调用准备好的Python环境进行打包
json
// 读取package.json中的构建配置
"extraResources": [{
"from": "../backend",
"to": "backend",
"filter": ["**/*", "!**/__pycache__/**", ...]
}]
- 运行阶段 - 使用虚拟环境

javascript
// 工具函数:将子进程操作转为Promise
const spawnAsync = (command, args, options = {}) => {
return new Promise((resolve, reject) => {
const process = spawn(command, args, options);
let output = '';
let errorOutput = '';
process.stdout.on('data', data => output += data.toString());
process.stderr.on('data', data => errorOutput += data.toString());
process.on('error', reject);
process.on('exit', (code, signal) => {
if (code === 0) {
resolve({ output, errorOutput });
} else {
reject(new Error(`进程退出码: ${code}, 信号: ${signal || 'none'}
输出: ${output}
错误: ${errorOutput}`));
}
});
});
};
// 工具函数:带重试机制的异步操作
const withRetry = async (fn, maxRetries = 30, delayMs = 1000) => {
for (let retries = maxRetries; retries > 0; retries--) {
try {
return await fn();
} catch (error) {
if (retries === 1) throw error;
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
};
// 工具函数:统一错误处理
const handleBackendError = (error, title = '后端启动失败') => {
console.error(`${title}:`, error);
dialog.showErrorBox(title, `错误原因:
${error.message}`);
throw error;
};
// 检查Python环境依赖
const checkPythonDependencies = async (pythonPath) => {
try {
await spawnAsync(pythonPath, ['-c', 'import fastapi, uvicorn, lxml']);
return true;
} catch (error) {
throw new Error(
'Python环境缺少必要的依赖。\n请确保以下包已安装:\n- fastapi\n- uvicorn\n- lxml\n\n' + error.message
);
}
};
// 读取并验证端口文件
const readAndValidatePortFile = async (portFilePath) => {
if (!await fs.promises.access(portFilePath).then(() => true).catch(() => false)) {
throw new Error('端口文件不存在');
}
const portContent = await fs.promises.readFile(portFilePath, 'utf8');
const port = parseInt(portContent.trim(), 10);
if (isNaN(port) || port < 1 || port > 65535) {
throw new Error(`无效的端口号: ${portContent}`);
}
return port;
};
// 健康检查请求(带超时)
const healthCheck = async (port, timeoutMs = 5000) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`http://127.0.0.1:${port}/health`, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`健康检查失败: ${response.status} ${response.statusText}`);
}
return true;
} finally {
clearTimeout(timeoutId);
}
};
// 主函数:启动Python后端
async function startPythonBackend() {
console.log('Starting Python backend...');
let pythonProcess;
try {
// 1. 查找Python可执行文件
const pythonInfo = findPythonExecutable(
app.getAppPath(),
isDev ? path.join(app.getAppPath(), '..') : process.resourcesPath
);
// 2. 检查Python环境依赖
await checkPythonDependencies(pythonInfo.executablePath)
.catch(error => handleBackendError(error, 'Python环境检查失败'));
// 3. 启动后端服务
const portFile = path.join(pythonInfo.backendDir, '.port');
// 确保清理之前的端口文件
if (await fs.promises.access(portFile).then(() => true).catch(() => false)) {
await fs.promises.unlink(portFile);
}
// 启动Python进程
pythonProcess = spawn(pythonInfo.executablePath, [
path.join(pythonInfo.backendDir, 'main.py')
], {
env: { ...process.env, ELECTRON_RUN: '1' },
cwd: pythonInfo.backendDir
});
// 捕获Python输出
let pythonOutput = '';
pythonProcess.stdout.on('data', data => pythonOutput += data.toString());
pythonProcess.stderr.on('data', data => pythonOutput += data.toString());
// 监听进程退出
pythonProcess.on('exit', (code, signal) => {
if (code !== 0) {
console.error(`Python进程异常退出,退出码: ${code}, 信号: ${signal}`);
console.error('Python输出:', pythonOutput);
}
});
// 4. 等待并验证端口
const pythonPort = await withRetry(
() => readAndValidatePortFile(portFile),
30, // 最多等待30秒
1000 // 每秒检查一次
).catch(error => handleBackendError(
new Error(`等待端口文件失败: ${error.message}\n\n详细输出:\n${pythonOutput}`)
));
console.log(`Python后端使用端口: ${pythonPort}`);
// 5. 健康检查
await withRetry(
() => healthCheck(pythonPort),
5, // 健康检查重试5次
1000 // 每次间隔1秒
).catch(error => handleBackendError(
new Error(`后端服务健康检查失败: ${error.message}\n\n详细输出:\n${pythonOutput}`)
));
console.log('后端服务启动成功');
return pythonProcess;
} catch (error) {
// 确保异常退出时终止Python进程
if (pythonProcess && !pythonProcess.killed) {
pythonProcess.kill();
}
throw error;
}
}
- 关闭 - 退出/结束

Electron 应用的 "一生":从启动到退场

文件映射总结
开发环境路径 | 打包环境位置 | 说明 |
---|---|---|
src/ | 编译到JS bundle | 前端源代码 |
electron.cjs | 复制到应用包 | Electron主进程脚本 |
preload.cjs | 复制到应用包 | Electron预加载脚本 |
public/ | 复制到应用根目录 | 静态公共资源 |
scripts/ | 复制到resources/scripts/ | 构建和辅助脚本 |
package.json | 影响应用元数据 | 项目配置 |
后端项目结构
为什么选python呢, 作为前端应该下意识选择node不是吗, 没错, 因为我做功能是分析类的, 所以python要比node在这方面突出一点, 而且 和 js 语言相仿, 就用到哪看到哪窥视一点皮毛, python就用来 1、创建虚拟环境 2、实现服务"开机自启动" 3、接口调用分析返回
主程序文件
- 开发环境:backend/main.py
- 打包环境:resources/backend/main.py
- 作用:FastAPI应用程序入口,定义了所有API路由和服务逻辑
依赖配置
- 开发环境:backend/requirements.txt
- 打包环境:resources/backend/requirements.txt
- 内容:所有Python依赖包,包括fastapi、uvicorn、lxml和openpyxl等
虚拟环境
- 开发环境:backend/python_env/venv/
- 打包环境:resources/backend/python_env/venv/
- 内容:完整的Python解释器副本和所有已安装的依赖包
- 排除项:构建时会排除__pycache__等缓存文件以减小体积
环境标记文件
- 路径:python_env/.ready
- 作用:标记虚拟环境配置完成,供运行时检测
完整映射总结
开发环境路径 | 打包环境路径 | 说明 |
---|---|---|
backend/ | resources/backend/ | 后端根目录 |
backend/main.py | resources/backend/main.py | 主程序入口 |
backend/python_env/venv/ | resources/backend/python_env/venv/ | Python虚拟环境 |
backend/requirements.txt | resources/backend/requirements.txt | 依赖列表 |
无(构建时创建) | resources/backend/python_env/.ready | 环境就绪标记 |
无(运行时创建) | resources/backend/.port | 运行时端口记录 |


后端项目启动
python
# 创建FastAPI应用
app = FastAPI(
title="xxxxxxxxx",
description="xxxxxxxxxxxxxx",
version="1.0.0"
)
def start_server():
"""启动FastAPI服务器"""
try:
logger.info("正在启动XMind冒烟测试用例导出工具API服务器...")
# 查找可用端口
port = find_free_port()
logger.info(f"使用端口: {port}")
# 将端口写入临时文件,供Electron读取
port_file = os.path.join(os.path.dirname(__file__), '.port')
with open(port_file, 'w') as f:
f.write(str(port))
uvicorn.run(
app,
host="127.0.0.1",
port=port,
log_level="info",
access_log=True
)
except Exception as e:
logger.error(f"服务器启动失败: {str(e)}")
logger.error(f"详细错误信息: {traceback.format_exc()}")
sys.exit(1)
finally:
# 清理端口文件
if os.path.exists(port_file):
os.remove(port_file)
if __name__ == "__main__":
start_server()
except Exception as e:
logger.error(f"后端服务初始化失败: {str(e)}")
logger.error(f"详细错误信息: {traceback.format_exc()}")
sys.exit(1)
// 接口
@app.get("/health")
async def health_check():
"""健康检查端点"""
return {"status": "ok"}
@app.get("/")
以上就是前段时间写着玩的一些个分享, 写着感觉挺有意思、挺好玩就整理一下, 理性看待, 可以互相交流