electron桌面端web化方案
需要将目前electron桌面端的一整套前端功能搬到web上,达到一套代码多端运行的效果,设计和输出一下前端调整方案
整体设计
electron分为主进程和渲染进程,渲染进程通过IpcRender与主进程进行通信实现一部分功能,如接口请求 窗口设置等。
在渲染进程中除了引入electron相关api外其他基本和web项目差距不太大。有没有一种方法可以在不修改渲染层代码的情况下让渲染层代码直接在web上运行呢?
渲染进程通过ipcRenderer
通信,运行在渲染进程,暂时称为请求层:
import { ipcRenderer } from './compatible/index';
export const login = (data: any) => {
return ipcRenderer.invoke('login', data);
};
中间主进程进行handle,运行在nodejs环境,暂时称为通信层:
import {ipcMain} from 'electron';
import { sendCode } from '../api/login';
export default {
init: async () => {
ipcMain.handle('sendCode', async (event, data: any) => {
let res = await sendCode(data);
return res;
});
}
}
login.ts,运行在nodejs环境,暂时称之为接口层:
有时存在在接口层调用nodejs依赖时,可以使用dyImport
来条件选择加载哪一个平台的文件进行加载
root/
│
├── api/
│ ├── browser/
│ │ └── login.ts
│ ├── electron/
│ │ └── login.ts
│ └── login.ts
import { dyImport } from './tools/utils';
import request from '../config/index';
export const sendCode = (data: any) => {
return request.post({
url: "/ams/sendFactorCode",
data,
headers: { noToken: true },
});
};
// 兼容
export function fileExport(data: any){
return dyImport('login','fileExport',data)
};
import { isElectron } from './method/isElectron';
export function dyImport<T = any,R = any>(fileName: string, funName: string, ...fnData: Array<T>) {
return new Promise<R>(async (resolve, reject) => {
let fn;
// #if [platform=browser]
fn = (await import(`../browser/${fileName}.ts`))[funName];
// #endif
// #if [platform != browser]
if (isElectron()) {
fn = (await import(`../electron/${fileName}.ts`))[funName];
}
// #endif
resolve(fn(...fnData));
});
}
我们发现除了通信层外,api接口层代码可以说没任何区别。那么我们可以考虑绕过中间通信层,同时将主进程中使用node模块的文件区分开,通过条件编译分别运行在桌面端或web。
项目修改为多个入口:electron入口和web入口,vite编译配置需改为vite.electron.ts和vite.browser.ts。
-
添加条件编译插件,让vite支持条件编译:
-
Vite.browser.ts:
import { defineConfig } from 'vite'; import vitePluginConditionalCompile from 'vite-plugin-conditional-compile'; // 浏览器编译时会根据根据变量条件编译文件 export default defineConfig(({ mode }) => { return { plugins: [ vitePluginConditionalCompile({ env: { platform: 'browser' }, include: [ // ...file ], exclude: ['node_modules'] }), ] }; });
-
vite.electron.ts:
import { defineConfig, loadEnv } from 'vite'; import electron from 'vite-plugin-electron'; import vitePluginConditionalCompile from 'vite-plugin-conditional-compile'; export default ({ mode }) => { return defineConfig({ plugins: [ vue(), vitePluginConditionalCompile({ env: { platform: 'electron' }, // 运行在渲染进程,对渲染层面的代码进行条件编译 include: [ // 相关文件 ], exclude: ['node_modules'] }), electron({ entry: 'src/electron/index.ts', vite: { plugins: [ vitePluginConditionalCompile({ env: { platform: 'electron' }, include: [ // 运行在主进程,对主进程相关的代码进行条件编译 // 相关文件 ], exclude: ['node_modules'] }) ] } }) ], }); };
-
通过策略模式让请求层直接和接口层进行处理通信:
import { isElectron } from './utils/method/isElectron';
// 之所以需要 isElectron() 是为了防止 代码在 tree-shaking时 给优化掉了。
// 不过理论上应该不需要,主要是为了提高代码阅读性
function invoke(...params: Array<any>) {
return new Promise<ReturnType<any>>(async (resolve, reject) => {
let config;
// #if [platform=browser]
config = (await import('./browser')).default;
// #endif
// #if [platform != browser]
if (isElectron()) {
config = (await import('./electron')).default;
}
// #endif
let result;
try {
result = config.invoke(...params)
} catch (error) {
throw error;
}
resolve(result);
});
}
function sendSync(...params: Array<any>) {
return new Promise<ReturnType<any>>(async (resolve, reject) => {
let config;
// #if [platform=browser]
config = (await import('./browser')).default;
// #endif
// #if [platform != browser]
if (isElectron()) {
config = (await import('./electron')).default;
}
// #endif
resolve(config.sendSync(...params));
});
}
function send(...params: Array<any>) {
return new Promise<ReturnType<any>>(async (resolve, reject) => {
let config;
// #if [platform=browser]
config = (await import('./browser')).default;
// #endif
// #if [platform != browser]
if (isElectron()) {
config = (await import('./electron')).default;
}
// #endif
resolve(config.send(...params));
});
}
function on(...params: Array<any>) {
return new Promise<ReturnType<any>>(async (resolve, reject) => {
let config;
// #if [platform=browser]
config = (await import('./browser')).default;
// #endif
// #if [platform != browser]
if (isElectron()) {
config = (await import('./electron')).default;
}
// #endif
resolve(config.on(...params));
});
}
function removeAllListeners(...params: Array<any>) {
return new Promise<ReturnType<any>>(async (resolve, reject) => {
let config;
// #if [platform=browser]
config = (await import('./browser')).default;
// #endif
// #if [platform != browser]
if (isElectron()) {
config = (await import('./electron')).default;
}
// #endif
resolve(config.removeAllListeners(...params));
});
}
export const ipcRenderer = {
invoke,
sendSync,
send,
on,
removeAllListeners
};
electron文件则引入对应的依赖
import { ipcRenderer } from 'electron';
export default ipcRenderer
browser文件则引入所有api通过变量名调用,变量名要求是每个接口的唯一key值
import loadAllApi from './browserAllApi';
let ws: any;
const eventMap: Record<string, any[]> = {};
const ipcRenderer = {
invoke: (fnName: string, ...params: Array<any>) => {
return new Promise((resolve, reject) => {
loadAllApi().then(async allApi => {
console.log(allApi);
const iDB = await db.getInstance();
if (!allApi) {
reject();
}
// 兼容层 在这里处理兼容electron在浏览器中主进程和渲染进程的兼容,
// 比如存在一些异步但是不是接口的功能,接口名和调用名不一样的情况
const apiResult: any = {
...allApi,
// TODO 监听网络情况 待兼容
'start-network-status': async () => {
return Promise.resolve();
},
'get-network-status': async () => {
return Promise.resolve();
},
'end-network-status': async () => {
return Promise.resolve();
},
websoketInit: () => {
return allApi.getWebSocketManager().then((wsM: any) => {
ws = new wsM();
// 通过订阅自定义事件来处理接收到的消息
ws.on('MESSAGE', (data: any) => {
eventMap['SOCKET_MESSAGE'].forEach((item: any) => {
item(data);
});
});
// 通过订阅自定义事件来处理接收到的消息
ws.on('ERROR', async (data: any) => {
// setTimeout(() => {
// ws = new CustomWebSocket();
// }, 3000);
});
resolve(ws);
});
},
connect: async () => {
console.log('连接websocket');
let isConnected = ws.checkConnectionStatus();
if (isConnected) {
await ws.connect();
}
return Promise.resolve();
},
};
const reqFn = apiResult![fnName];
if (!reqFn) {
reject(new Error(`没有找到对应的${fnName}`));
}
resolve(reqFn(...params));
});
});
},
/**
* 客户端electron 多窗口等相关通信 监听窗口
* 浏览器端没必要实现
*/
sendSync: (fnName: string, ...params: Array<any>) => {
return new Promise((resolve, reject) => {
resolve(true);
});
},
send: (channel: string, ...args: any[]) => {
console.log(`[Browser Mock] ipcRenderer.send: ${channel}`, args);
},
on: (channel: string, listener: Function) => {
console.log(`[Browser Mock] ipcRenderer.on: ${channel}`);
},
removeAllListeners: (channel: string) => {
console.log(`[Browser Mock] ipcRenderer.removeAllListeners: ${channel}`);
}
};
export default ipcRenderer;
browserAllApi:
/**
* 仅支持在浏览器中使用
*/
let modules: Record<string, any> = {};
// 动态导入函数
async function loadModules() {
try {
// 浏览器环境:假设文件列表通过其他方式提供(例如 vite 的 import.meta.glob)
// @ts-ignore
const globModules = import.meta.glob(['../*.ts',],
{ eager: false }
);
for (const [path, importer] of Object.entries(globModules)) {
const moduleName = path.replace(/\.\.\/|\.ts$/g, '');
// @ts-ignore
const module = await importer();
// modules[moduleName] = module.default || module;
modules = {
...modules,
...module
};
}
return modules;
} catch (error) {
console.error('Error loading modules:', error);
}
return modules;
}
export default loadModules;
在vue文件有时候也会需要条件编译,用法是一样的
<template>
<!-- #if [platform != browser] -->
<webview ref="webviewOrIframeRef" :src="`${mapHref}&platform=electron`" class="main-map"></webview>
<!-- #endif -->
<!-- #if [platform = browser] -->
<iframe ref="webviewOrIframeRef" :src="`${mapHref}&platform=browser`" class="main-map"></iframe>
<!-- #endif -->
</template>
有时候为了考虑做某些功能或业务场景需要环境变量来区分不同web的测试环境和正式环境,为了不影响electron项目原有的环境变量(需要用 -mode 环境变量区分不同的场景),新增一个注入浏览器环境变量的脚本:
setupBrowserEnv.js
const fs = require('fs');
const path = require('path');
// 获取命令行参数
const [filename, varName, varValue] = process.argv.slice(2);
if (!filename || !varName || !varValue) {
console.error('Usage: node set-env.js <filename> <varName> <varValue>');
process.exit(1);
}
const filePath = path.resolve(process.cwd(), filename);
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
console.error('ERROR: File does not exist.');
process.exit(1);
}
// 读取文件内容
let content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
// 检查变量是否已存在
let exists = false;
const newLines = lines.map(line => {
const match = line.match(/^([^#=]+?)\s*=/); // 忽略行首空格和注释
if (match && match[1].trim() === varName) {
exists = true;
return `${varName}=${varValue}`; // 更新现有变量
}
return line;
});
// 添加新变量(如果不存在)
if (!exists) {
newLines.push(`${varName}=${varValue}`);
}
// 写入文件
fs.writeFileSync(filePath, newLines.join('\n'), 'utf8');
console.log(`✅ Added ${varName}=${varValue} to ${filename}`);
使用方法:
"dev:browser-stg": " node distribute/setupBrowserEnv.js .env VITE_USER_NODE_ENV stg && vite --config vite.browser.ts --mode XXX",
"build:browser-stg": "node distribute/setupBrowserEnv.js .env VITE_USER_NODE_ENV stg && vite build --config vite.browser.ts --mode XXX",
方案优劣
这套方案的优势就是能最大程度不去修改渲染层的代码,不影响原有逻辑。
劣势就是依然需要去修改和兼容原来的主进程代码,后续功能开发上需要尽量考虑到差异性和熟悉架构代码,才好进行开发,不然可能在开发新功能后导致另一端运行出现问题。