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

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

相关推荐
Boilermaker199217 分钟前
【Java EE】SpringIoC
前端·数据库·spring
中微子28 分钟前
JavaScript 防抖与节流:从原理到实践的完整指南
前端·javascript
天天向上102443 分钟前
Vue 配置打包后可编辑的变量
前端·javascript·vue.js
芬兰y1 小时前
VUE 带有搜索功能的穿梭框(简单demo)
前端·javascript·vue.js
好果不榨汁1 小时前
qiankun 路由选择不同模式如何书写不同的配置
前端·vue.js
小蜜蜂dry1 小时前
Fetch 笔记
前端·javascript
拾光拾趣录1 小时前
列表分页中的快速翻页竞态问题
前端·javascript
小old弟1 小时前
vue3,你看setup设计详解,也是个人才
前端
Lefan1 小时前
一文了解什么是Dart
前端·flutter·dart
Patrick_Wilson1 小时前
青苔漫染待客迟
前端·设计模式·架构