vue+electron项目实战总结(遇到了哪些问题,是如何解决的,哪个阶段需要做什么,附带一些常用方法)
electron作为一个将网页打包成桌面应用的工具 非常强大,在使用electron的时候 要相信 它可以实现所有现代软件能够支撑的功能,下面我总结一下我在 vue+electron经过4次 大版本更新才趋于稳定的开发经验。
一、开发套路: 消息通信+数据驱动
使用 vue + electron
开发 分为主进程(main.js
) 和 渲染进程(vue
网页)
消息通信
通过主进程和渲染进程间通信机制 可以将功能划分,网页能够实现的功能交给vue ,需要获取系统参数、操作系统文件、使用 nodejs API 等等 的功能交给主进程 ,如果想通过网页来执行主进程实现的功能,则在渲染进程向主进程发消息,由主进程来实现。
数据驱动
vue等等框架 是由组件化+数据驱动dom 来实现快速开发,必要时为了避免接口调用闭环问题需要用事件驱动…。
二、遇到的问题 (最大的问题就是兼容性。。。)
1. vscode调试控制台乱码。。。
打开 vscode 快捷键 ctrl+J 打开控制台 选择终端 输入
chcp 65001
2. win7 打开electron打包的exe 安装后提示缺少dll文件。。。
通过降低electron版本 我使用的electron版本是:
"electron": "^11.0.0",
nodejs版本v16.17.0
electron-builder帮助我们解决了很多兼容性问题,只是不同的electron版本我还需要研究都有哪些新特性,对操作系统都有哪些要求。
3. 大概1/3的电脑看不到界面。。。
因为我在package.json=> build属性中配置了
"win": {
"requestedExecutionLevel": "requireAdministrator"
},
用于设置应用程序的执行级别为管理员权限。这意味着应用程序将以管理员身份运行,如果不是以管理员身份打开的应用程序,不会提示报错,也不会弹出页面。。。
我的electron打包构建配置
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"start": "electron .",
"packager64": "electron-builder --win --x64",
"packager32": "electron-builder --win --ia32",
"asar64": "node ./app.js 64",
"asar32": "node ./app.js 32"
},
"build": {
"asar": true,
"icon": "./public/favicon.ico",
"productName": "appName",
"appId": "appId123",
"nsis": {
"oneClick": false,
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"installerIcon": "./public/favicon.ico",
"uninstallerIcon": "./public/favicon.ico",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "appName",
"artifactName": "${productName}-setup-${version}_${os}_${arch}.${ext}"
},
"directories": {
"output": "build"
},
"files": [
"!src/**/*",
"!public/**/*",
"dist/**/*",
"input/**/*",
"electronJs/**/*"
],
"extraResources": [
{
"from": "./public/down",
"to": "."
}
]
},
"dependencies":{
//在线上需要用到的包 都要放在这里,不然打包后运行 提示缺少各种功能包
},
"devDependencies":{
//用于存放你的 打包构建、代码格式化、babel、 electron等等依赖包。
"electron": "^11.0.0",
"electron-builder": "^23.6.0",
}
打包出来的文件目录包含:
dist: vue 打包文件
main.js 主进程相关代码文件
node_modules 主进程需要的依赖包
"./public/down" 存放需要热更新的文件 更新随后讲解
4. 用主进程nodejs启动exe 因为路径包含空格: C:\Program Files (x86)\you.exe 所以执行失败还报错。。。
使用
cmd /c start
打开带空格的路径
//执行exe (带空格的路径)
exec(
`cmd /c start "" "C:\Program Files (x86)\you.exe"`,
{ detached: true, stdio: 'ignore' },
function (err) {
if (err) {
console.log('cmd /c start=> ', err);
}
},
)
5. 将项目配置文件放到了安装根目录,导致卸载重装后不同用户存储的临时数据丢失(你的应用程序是一次性的!!!)
使用
nodejs API
获取系统盘AppData
路径 将config文件 放到c盘的AppData
文件夹中(这是个隐藏文件夹)
const path = require('path');
const os = require('os');
const appDataPath = path.join(os.homedir(), 'AppData', 'Local');
6. 使用ipcHelper 发消息时,接收第一条消息,响应一次,接收第二条消息,响应两次,因为消息管道未关闭。。。
监听消息的事件 用
.once
而不用.on
// main.js 接收收消息并回发
const { ipcMain } = require('electron');
ipcMain.on('window-pay', function (event) {
//消息回发
event.sender.send('window-pay-msg', 123);
});
// 使用vue 的 main.js 注册 electron 对象 供全局实用
const electron = window?.require?.('electron');
//electron 进程通信
Vue.prototype.$electron = electron;
// vue组件内使用
this.$electron?.ipcRenderer?.send('window-pay');
// 监听信息返回 使用 once param1:回发消息
this.$electron?.ipcRenderer?.once('window-pay-msg', async (event, param1) => {
//接收回发消息
message.success(param1); //123
});
7. 我的自定义更新 不能更新自己(app.asar文件不能在程序运行时替换)。。。
检测更新 我们准备的更新 分为
热更新
冷更新
全量更新
热更新:替换 app.asar 文件 因为不能在运行时替换 所以要现将app.asar文通过nodejs npm asar包将新 app.asar 反编译 在压缩 最后上传到服务器,传好之后将自定义的 latest 文件修改版本 触发用户应用程序热更新。 所以我们准备将热更新来更新一些通过应用程序启动的其他exe文件。 需要在主进程的检查更新功能中配置
"./public/down"
文件夹内的exe等等文件复制到指定文件夹下。
我复制在了resources
文件夹中。
冷更新:新建更新器,专门负责更新,由应用程序触发更新器.exe 更新在运行时热更新不能替换的文件,包括程序启动文件…,在程序入口处检查服务端更新配置文件的版本号 和本地版本号的差异 看是否需要更新,如果需要更新 则强制更新。
全量更新:新建更新器,专门负责更新,由应用程序触发更新器.exe 下载新安装包 下载之后自动触发安装包安装程序。
所以。之后会先进入更新器,然后通过更新器启动应用程序
综上所述:热更新不行 用冷更新 冷更不行用全量更新
8. 更新版本拉齐!!!
经过几次版本更新失败后,我们需要将用户电脑的应用程序版本拉齐,更新失败的重新安装,所以之后的热更新 会静默,用户感知不到,只是在更新完毕后,重新进入应用程序会发现新增了几个页面和功能…
9. 你的应用程序是32位的吗?!!!
如果不是,那么许多32位的老电脑是不支持你的exe的,所以你打开控制面板,找到程序和功能,检查你的电脑安装的应用程序,凡是被广泛使用的软件都是32位的,所以你需要将你的应用程序打包成32位的。
10. 打包成32位的之后,发现nodejs -> child_process 解构出来的方法 spawn 不支持32位的应用程序。。。
使用 nodejs -> child_process 的另外一个方法
execFile
与spawn用法相同,如果还不行,需要检查你打开的 exe 是不是32位的…
const { execFile } = require('child_process');
//路径要换成绝对路径 !!!
const child = execFile(`./resources/you.exe`, ['参数1','参数2','参数3']);
// 监听子进程的输出
child.stdout.on('data', (data) => {
console.log(`start_exe子进程输出:${data}`);
});
// 监听子进程的错误输出
child.stderr.on('data', (data) => {
console.log(`start_exe子进程错误输出:${data}`);
});
// 监听子进程的退出事件
child.on('close', (code) => {
console.log(`start_exe子进程退出,退出码 ${code}`);
event.sender.send(`window-down-back`, {
msgType: 'success',
msg: '准备完毕,请稍后',
});
});
11. 为execFile 配置环境变量。。。
每个应用程序都有他自己的环境路径,如果找不到则一些相关的资源文件也会找不到,结果就是你的应用程序,打不开别的exe所以我们需要通过 nodejs 配置环境变量。
// 执行 exe 文件 并配置环境变量 需要转为绝对路径
const mainDir = path.dirname(downUrl);
const env = Object.assign({}, process.env, {
PATH: mainDir + ';' + process.env.PATH,
});
const child = execFile(downUrl, [downId], {
cwd: mainDir,
env: env,
shell: true,
});
12. 记录日志: 当你的代码量即将破万,一旦线上出现问题,你会花很多时间排错
我们不仅要及时的提示一些重大错误,还要在应用程序内创建程序运行日志 log.txt ,也可以下载一些npm包 去记录详细且专业的日志,但我是自定义的记录日志的方法
新建txtConsole.js文件
const fs = require('fs');
const moment = require('moment');
const mainData = require('./mainData');
const txtConsole = {
log(p1 = '', p2 = '', p3 = '', p4 = '', p5 = '') {
let logPath = `${mainData?.rootPath}/log.txt`;
try {
//创建config文件
if (!fs.existsSync(logPath)) {
//新建文件
fs.writeFileSync(logPath, '');
}
//追加到log文件
fs.appendFileSync(
logPath,
`\n ${p1} ${p2} ${p3} ${p4} ${p5} ${moment().format('Y-MM-DD HH:mm:ss')}`,
);
console.log(p1, p2, p3, p4, p5);
} catch (err) {
console.log('txtConsole: ', err);
}
},
clearLog() {
let logPath = `${mainData?.rootPath}/log.txt`;
try {
if (fs.existsSync(logPath)) {
let stat = (fs.statSync(logPath)?.size || 1) / 1024;
txtConsole.log(`当前log文件大小:${parseInt(stat)}KB`);
if (parseInt(stat) > 8192) fs.unlinkSync(logPath);
}
} catch (err) {
console.log(err);
}
},
}; //日志文件
module.exports = txtConsole;
在主进程中使用它
const txtConsole = require('./txtConsole');
// 当运行日志文件体积达到将近1M 则删除并重新创建日志文件log.txt(log.txt放到程序根路径即可)。
txtConsole.clearLog();
//正常使用:
txtConsole.log('已设置当前版本号:', '0.0.6');
定义日志文件后 既方不影响我们本地调试,又可以快速确定用户电脑的应用程序在什么时间那个模块出现了什么问题。
13. 代码压缩
你会发现 vue代码是经过build压缩的 而 app.asar 反编译后你的主进程代码会一览无遗,所以我们需要将主进程代码压缩,这个因人而异,通过安装npm包 进行压缩。
"html-minifier": "^4.0.0",
"uglify-js": "^3.17.4",
//生成 反编译app.asar 并生成压缩包
const fs = require('fs');
const path = require('path');
const uglify = require('uglify-js');
const moment = require('moment');
//压缩主进程 main.js 相关代码
function zipMainJS() {
try {
const dir = path.resolve(`你的主进程路径文件夹 我做了模块拆分/electronJs`);
const files = fs.readdirSync(dir);
files.forEach((file) => {
const filePath = `${dir}/${file}`;
let dirJs = fs.readFileSync(filePath, 'utf8');
const result = uglify.minify(dirJs);
fs.writeFileSync(filePath, result.code);
});
return true;
} catch (err) {
console.log(err);
return false;
}
}
//压缩main.js相关代码
let zipRes = zipMainJS();
if (!zipRes) {
console.log('!!!压缩main.js主进程代码失败!' + ' ' + moment().format('HH:mm:ss'));
return;
}
14. 造轮子:活得越干越少!!!
当检查更新功能出现之后 会有很多细小步骤的工作,这时你发现 如果用一个app.js文件 定义一些代码 用代码来实现打包时的所有工作,你只需要等待即可。
我的项目打包一次需要做的工作:
- npm run build 打包vue文件
- npm run packager32 应用程序打包
- 我的exe需要在打包后将一些文件放入打包后的文件夹中,在用Inno Setup 再次打包,所以步骤比较繁琐,大家根据实际情况来造轮子即可。
- 将
win-ia32-unpacked/resources/app.asar
app.asar文件反编译,在进行代码压缩(如何压缩看第13条),通过反编译后的文件夹生成app.zip 更新包。- 通过一些文件打包生成冷更新zip包。
- 将安装图标等等资源文件放入安装包根目录中(
win-ia32-unpacked
)。- 检查一些文件的有效性(latest文件版本号,是否能被正常解析JSON,main.js文件是否是生产模式)
- 通过vscode打开Inno Setup执行最终打包。
- 删除上一次打包后的残留文件,避免缓存。
- 将远端app.zip更新包更新,latest文件更新。
下面是一些轮子常用方法
//大家根据自己项目的实际情况 有所选择即可
const asar = require('asar');
const fs = require('fs');
const fsExtra = require('fs-extra');
const zlib = require('zlib');
const archiver = require('archiver');
const path = require('path');
const uglify = require('uglify-js');
const moment = require('moment');
const minify = require('html-minifier').minify;
const { exec, execSync, spawn } = require('child_process');
const startTime = moment().unix();
const mainData = require('./electronJs/mainData');
execSync('chcp 65001');
const rootPath = path.resolve(__dirname);// 获取项目根路径
const asarPath = './build/win-ia32-unpacked/resources/app.asar';// 获取 app.asar 文件路径
const asarAppPath = './app/apps';// asar反编译文件的存放路径
const buildPath = './build';//electron 打包后的build文件夹
const distPath = './dist';//vue 构建后的build文件夹
const sourceDir = './app';// 要压缩的文件夹路径
const sourcePatchDir = './build/win-ia32-unpacked/resources';// 要压缩的冷更文件夹路径
const outputPath = './Output';// innel setup output
const destFile = './app.zip';// 压缩后的文件路径
const destPatchFile = './ShanHeBoxUpdate.zip';// 冷更压缩后的文件路径
const downAppPath = './down/app';
const downLogPath = './down/log.txt';
// 配置环境路径为项目根路径
const env = Object.assign({}, process.env, {
PATH: rootPath + ';' + process.env.PATH,
npm_config_prefix: 'C:\\Program Files\\nodejs\\npm', // 这里是你的 npm 安装路径
});
//执行 构建命令 需要配置好环境变量
console.log(
'=============== 正在执行【npm run build】命令' + ' ' + moment().format('HH:mm:ss'),
);
execSync('npm run build', env);
执行 打包命令 需要配置好环境变量:执行出错要关掉vscode 手动将你的build文件夹删掉
console.log(
'=============== 正在执行【npm run packager32】命令' + ' ' + moment().format('HH:mm:ss'),
);
exec('npm run packager32', env, (error, stdout, stderr) => {
if (error) {
console.log(`执行出错: ${error}`);
return;
}
stderr && console.log('【npm run packager32】 stderr=>', stderr);
//开始执行其他步骤...
init();
});
执行app.asar 反编译 压缩相关逻辑
console.log(
'=============== 正在执行app.asar 反编译 压缩相关逻辑' + ' ' + moment().format('HH:mm:ss'),
);
// 将 app.asar 解压缩到指定文件夹中 asarPath: app.asar文件路径 asarAppPath: 解压后的文件夹路径
asar.extractAll(asarPath, asarAppPath);
文件操作
//复制
fsExtra.copySync('./down', './build/win-ia32-unpacked/resources');
//删除 可以删除文件夹 和文件
fsExtra.removeSync(buildPath);
检查更新配置文件有效性
console.log(
`=============== 更新配置文件latest ${checkLatestInfo() ? '【有效】' : '【无效】'}` +
moment().format('HH:mm:ss'),
);
function checkLatestInfo() {
try {
let latestData = fs.readFileSync('./down/latest', 'utf8');
if (latestData) {
let latest = JSON.parse(latestData);
fs.writeFileSync('./down/latest', JSON.stringify(latest));
}
return true;
} catch (err) {
return false;
}
}
压缩代码
function zip(){
// 创建一个可写流,将压缩后的文件写入到目标文件中
const destStream = fs.createWriteStream(destFile);
// 创建一个 archiver 实例
const archive = archiver('zip', {
zlib: { level: zlib.constants.Z_BEST_COMPRESSION },
});
// 将可写流传递给 archiver 实例
archive.pipe(destStream);
// 将要压缩的文件夹添加到 archiver 实例中
archive.directory(sourceDir, false);
// 完成压缩并关闭可写流
archive.finalize();
// 监听可写流的 'close' 事件,表示压缩完成
destStream.on('close', () => {
console.log(
`=============== 压缩完毕,压缩包路径:【${path.resolve(__dirname, destFile)}】` +
' ' +
moment().format('HH:mm:ss'),
);
console.log('共用时:' + (moment().unix() - startTime) + '秒');
});
}
打开Inno Setup iss文件
spawn('D:/soft/Inno Setup 6/Compil32.exe', [path.resolve('./ShanHe32.iss')], {
stdio: ['pipe', 'inherit', 'inherit'], // 将子进程的标准输入流传递给父进程
});
常用功能模块
//获取操作系统盘符 以及剩余空间
function onGetSystemSize() {
if (os.platform() === 'win32') {
const output = execSync('wmic logicaldisk get Caption,FreeSpace,Size');
const disks = output.toString().split('\r\r\n').slice(1, -1);
diskInfo = disks.map((disk) => {
const [caption, freeSpace, size] = disk.split(/\s+/);
return {
caption,
freeSpace: +(Number(freeSpace) / 1024 / 1024 / 1024).toFixed(2),
size: +(Number(size) / 1024 / 1024 / 1024).toFixed(2),
};
});
}
return diskInfo;
}
onGetSystemSize();
if (!diskInfo || diskInfo.length === 0) {
let txt = '磁盘信息获取失败';
console.log(txt);
return;
}
//修改xml文件属性值
function onUpdateXML() {
let gameConfig = fs.readFileSync(gameConfigpath, 'utf8');
xml2js.parseString(gameConfig, (err, result) => {
if (err) {
txtConsole.log(err);
return;
} else {
// 获取 SA 属性值
const SA = result.configuration.appSettings[0].add.find(
(item) => item.$.key === 'SA',
);
// 修改 SA 属性值
SA.$.value = lineInfo.ip;
// 获取 SP 属性值
const SP = result.configuration.appSettings[0].add.find(
(item) => item.$.key === 'SP',
);
// 修改 ServerPort 属性值
SP.$.value = lineInfo.server_port;
// 将 JavaScript 对象转换回 XML 字符串
const builder = new xml2js.Builder();
const xml = builder.buildObject(result);
// 将修改后的 XML 字符串写回文件
fs.writeFileSync(gameConfigpath, xml);
}
});
}
//获取本机MAC地址 和路由器的MA地址 谨慎使用!!!!!!
const arp = require('arp');
const os = require('os');
const { exec } = require('child_process');
const txtConsole = require('./txtConsole');
function onGetMACAddress(callback) {
txtConsole.log('------------------------------------------------------------');
const networkInterfaces = os.networkInterfaces();
// const newworkKeys = Object.keys(networkInterfaces);
const PCMAC = networkInterfaces?.['WLAN']?.find((item) => item?.family === 'IPv4').mac; //电脑MAC
// 获取本机的 IP 默认网关 地址
exec('chcp 65001 && ipconfig', (err, stdout, stderr) => {
if (err) {
callback?.(err);
return;
}
const ipMatch = stdout.match(/IPv4 Address[\\.\s:]+(\d+\.\d+\.\d+\.\d+)/);
const defaultGateway = stdout.match(/Default Gateway[\\.\s:]+(\d+\.\d+\.\d+\.\d+)/);
if (!ipMatch) {
callback?.('无法获取本机 IP 地址');
return;
}
if (!defaultGateway) {
callback?.('无法获取本机 默认网关 IP');
return;
}
const ip = ipMatch[1];
const defaultFatewayIP = defaultGateway[1];
txtConsole.log('本机IP地址: ', ip);
txtConsole.log('本机默认网关IP: ', defaultFatewayIP);
// 获取路由器的 IP 地址
exec('chcp 65001 && ping -c 1 192.168.1.1', (err, stdout, stderr) => {
if (err) {
callback?.('chcp 65001 && ping -c 1 192.168.1.1:' + err);
return;
}
txtConsole.log('ping信息 chcp 65001 && ping -c 1 192.168.1.1: ', stdout);
//判断是否ping通
const pattern = new RegExp(`Reply from ${defaultFatewayIP}`);
const match = stdout.match(pattern);
if (!match) {
callback?.('检测是否ping通: ping失败');
return;
}
txtConsole.log('检测是否ping通:', 'ping成功!!!');
// 获取路由器的 MAC 地址
arp.getMAC(defaultFatewayIP, (err, RouterMAC) => {
if (err) {
callback?.('获取路由器的 MAC 地址: 失败');
return;
}
txtConsole.log('------------------------------------------------------------');
// RouterMAC:路由器MAC
callback?.(null, { PCMAC, RouterMAC });
});
});
});
}
module.exports = onGetMACAddress;
更多推荐
所有评论(0)