Electron+Vue+Python全栈项目打包实战指南

🌟 引言:从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 后端启动流程

  1. 调用 startPythonBackend() 函数启动后端服务
less 复制代码
// 使用Python查找工具查找可用的Python后端
    const pythonInfo = findPythonExecutable(
      app.getAppPath(), 
      isDev ? path.join(app.getAppPath(), '..') : process.resourcesPath
    );
  1. 系统检查 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 进程
  1. 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}`
    );
  }
};
  1. 获取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入口文件在前端

  1. 准备阶段 - 创建虚拟环境

脚本命令 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')}`);
}
  1. 打包阶段 - 将环境纳入应用

npm run build:complete - 调用准备好的Python环境进行打包

json 复制代码
// 读取package.json中的构建配置
 "extraResources": [{
   "from": "../backend",
   "to": "backend",
   "filter": ["**/*", "!**/__pycache__/**", ...]
 }]
  1. 运行阶段 - 使用虚拟环境
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;
  }
}
  1. 关闭 - 退出/结束

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("/")

以上就是前段时间写着玩的一些个分享, 写着感觉挺有意思、挺好玩就整理一下, 理性看待, 可以互相交流

相关推荐
Mintopia9 分钟前
3D Quickhull 算法:用可见性与冲突图搭建空间凸壳
前端·javascript·计算机图形学
Mintopia10 分钟前
Three.js 三维数据交互与高并发优化:从点云到地图的底层修炼
前端·javascript·three.js
陌小路15 分钟前
5天 Vibe Coding 出一个在线音乐分享空间应用是什么体验
前端·aigc·vibecoding
成长ing1213823 分钟前
cocos creator 3.x shader 流光
前端·cocos creator
Alo36531 分钟前
antd 组件部分API使用方法
前端
BillKu34 分钟前
Vue3数组去重方法总结
前端·javascript·vue.js
江城开朗的豌豆1 小时前
Vue+JSX真香现场:告别模板语法,解锁新姿势!
前端·javascript·vue.js
这里有鱼汤1 小时前
首个支持A股的AI多智能体金融系统,来了
前端·python
袁煦丞1 小时前
5分钟搭建高颜值后台!SoybeanAdmin:cpolar内网穿透实验室第648个成功挑战
前端·程序员·远程工作
摸鱼仙人~1 小时前
Vue.js 指令系统完全指南:深入理解 v- 指令
前端·javascript·vue.js