Electron+Vue+Webpack项目框架架构教程与源码
摘要:随着前端技术的飞速发展,越来越多的技术领域开始被前端工程师踏足。从NodeJs问世至今,各种前端工具脚手架、服务端框架层出不穷,“全栈工程师”对于前端开发者来说,再也不只是说说而已。在NodeJs及其衍生技术高速发展的同时,Nw和Electron的问世,更是为前端发展提速不少,依稀记得哪位前辈说过,“能用Js改写的,终将用Js改写”,这不,客户端来了!使用Electron也有一段时间了,各种
摘要:随着前端技术的飞速发展,越来越多的技术领域开始被前端工程师踏足。从NodeJs问世至今,各种前端工具脚手架、服务端框架层出不穷,“全栈工程师”对于前端开发者来说,再也不只是说说而已。在NodeJs及其衍生技术高速发展的同时,Nw和Electron的问世,更是为前端发展提速不少,依稀记得哪位前辈说过,“能用Js改写的,终将用Js改写”,这不,客户端来了!使用Electron也有一段时间了,各种简单复杂的问题,也都或多或少的遇见过,下决心整理出一套客户端模板出来,一是加深一下自己的理解,二是供小伙伴们参考指正。本文选择Electron6.x+Webpack4+vue全家桶为技术栈,一套代码可以分别打包在客户端和web端,结合webpack,支持热更新,打包为exe安装包,过程中会涉及vue全家桶、electron的常见问题、配置和优化,webpack的对应配置等。从零开始,把electron、vue、webpack统统纳入自己的知识体系!
说明:本着模拟从零开始的过程,最开始的架构或者代码设计可能不是最优解,有可能只适用于当前情况,后续会一步步完善,也可能会部分重构,关键是体会这个从零到一,再到完善的过程。
一、新建工程
1、说好的从零开始,就从新建文件夹开始吧,新建electron-vue-template文件夹。
2、cmd进入文件夹,执行npm init,初始化一个node项目。
3、完善工程目录结构:
项目根目录的结构大致就是上面这个样子,后续完善过程中,会在对应目录下增加相应的子目录,后面会有讲到。下面介绍一下各个目录的作用:
app:webpack编译后的整个项目的代码,包括主进程和渲染进程,使用electron-builder打包exe安装包时,会把这部分代码打进去;
builder:webpack打包脚本,包括打包主进程、渲染进程,打包各个环境的exe安装包,启动各个环境的devServer等;
config:配置文件,包括环境配置、版本等;
dist:构建出的静态文件,exe,zip等;
src:源码目录;
main:主进程源码;
renderer:渲染进程源码;
4、执行npm i electron -D,下载electron,如果7.0.0版本安装不成功的话,可尝试cnpm i electron@6.1.2 -D安装6.1.2版本,我是尝试了好多次都无法下载7.0.0版本,所以这里使用的是6.1.2。
二、窗口配置,启动一个最简单的electron应用
1、进入src下的main文件夹,新建index.html和main.js文件;
2、index.html文件,除了常规的结构之外,随便写点简单的内容即可,本文只在body标签内写入一下代码:
<h1>Welcome to electron-vue-template!</h1>
3、Electron文档中说,您应当在 main.js
中创建窗口,并处理程序中可能遇到的所有系统事件。不过,随着我们的应用逐渐复杂,可能不止存在一个窗口,在main.js写入过多逻辑或者配置的话,会使我们的项目越来越难维护,所以正确的做法是,对应的窗口有自己专门的Js文件,负责这个窗口的配置和事件监听,而main.js文件只需要负责窗口的调度和系统级别的事件监听。当然,我们今天的目的是启动一个最简单的electron应用,所以直接写在了main.js文件里:
const url = require('url');
const path = require('path');
const { app, BrowserWindow } = require('electron');
function createWindow() {
let win = new BrowserWindow({
width: 800,
height: 600
});
// 获取index.html的file协议路径
const indexPath = url.pathToFileURL(path.join(__dirname, 'index.html')).href;
// 如果路径或者参数中含有中文,需要对路径进行编码处理
win.loadURL(encodeURI(indexPath));
// 打开开发者工具
win.webContents.openDevTools();
// 监听窗口的关闭事件,释放窗口对象
win.on('closed', () => {
win = null;
});
}
// 创建窗口
app.on('ready', createWindow);
// 当全部窗口关闭时退出。
app.on('window-all-closed', () => {
// 在 macOS 上,除非用户用 Cmd + Q 确定地退出,
// 否则绝大部分应用及其菜单栏会保持激活。
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
// 在macOS上,当单击dock图标并且没有其他窗口打开时,
// 通常在应用程序中重新创建一个窗口。
if (!win) createWindow();
});
Electron apps 使用JavaScript开发,其工作原理和方法与Node.js 开发相同。Electron模块包含了Electron提供的所有API和功能,引入方法和普通Node.js模块一样:Electron模块所提供的功能都是通过命名空间暴露出来的。 比如说:Electron.app负责管理Electron 应用程序的生命周期,Electron.BrowserWindow类负责创建窗口。
4、启动应用
废了这么多话,应用到底该怎么启动?那还不简单,在package.json的script标签里新增一个start命令,命令内容为node ./src/main/main.js,然后运行npm start,程序不就执行了嘛!于是马上添加了start命令,启动的时候命令行就报错了。WTF??什么鬼!看了报错信息,定位到了是在app.on('ready')这一行,这也能报错??
试着打印了一下app,undefined!!于是又加了两行代码:
const electron = require('electron');
console.log(electron)
打印了一下electron,竟然是个这玩意儿:E:\lh\demo\electron-vue-template\node_modules\_electron@6.1.2@electron\dist\electron.exe,不应该是个对象吗?
又翻了翻文档,恍然大悟,Electron并不属于node应用,通过node来执行入口文件当然是不行的,要用electron来执行,正确的命令为:electron ./src/main/main.js,再次运行npm start,看着命令行输出的内容以及刚刚启动的窗口,舒服的长出了一口气。
第一篇的内容就写到这里了,很少系统的去总结,总感觉有些内容写不出来,暂且做个引子吧,如果希望后续的文章对某部分详细讲解的话,欢迎留言,同时,如果有不恰当的地方,也欢迎批评指正!
摘要:上篇文章说到了如何新建工程,并启动一个最简单的Electron应用。“跑起来”了Electron,那就接着把Vue“跑起来”吧。有一点需要说明的是,webpack是贯穿这个系列始终的,我也是本着学习的态度,去介绍、总结一些常用到的配置及思路,有不恰当的地方,或者待优化的地方,欢迎留言。项目完整代码:https://github.com/luohao8023/electron-vue-template
下面开始~~~
一、安装依赖
vue、webpack:不多说了
vue-loader:解析、转换.vue文件
vue-template-compiler:vue-loader的依赖包,但又独立于vue-loader,简单的说,作用就是使用这个插件将template语法转为render函数
webpack-dev-server:快速搭建本地运行环境的工具
webpack-hot-middleware:搭配webpack-dev-server使用,实现热更新
chalk:命令行输出带有颜色的内容
依赖包就介绍这么多,后面需要什么可以自行下载,这里不多赘述了。
二、完善工程目录
webpack.render.config.js:渲染进程打包配置
dev.js:本地调试脚本
views:页面代码
index.js:vue工程入口文件
index.ejs:打包生成html文件时的模板
三、配置Vue工程
1、编写入口文件,render>index.js
import Vue from 'vue';
import index from './views/index.vue';
//取消 Vue 所有的日志与警告
Vue.config.silent = true;
new Vue({
el: '#app',
render: h => h(index)
});
2、编写根组件,render>views>index.vue
<template>
<div class="content">
<h1>Welcome to electron-vue-template!</h1>
</div>
</template>
<script>
export default {}
</script>
<style></style>
3、编写html模板文件,render>index.ejs,webpack解析、打包vue文件时,以此模板生成html文件
<!DOCTYPE html>
<html lang="zh-CN">
<!--template for 2019年10月30日-->
<!--<%= new Date().getFullYear()+'/'+(new Date().getMonth()+1)+'/'+new Date().getDate()+' '+new Date().getHours()+':'+new Date().getMinutes() %>-->
<head>
<meta charset="UTF-8">
<title>模板文件</title>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0">
<meta HTTP-EQUIV="pragma" CONTENT="no-cache">
<meta HTTP-EQUIV="Cache-Control" CONTENT="no-store, must-revalidate">
<meta HTTP-EQUIV="expires" CONTENT="Wed, 26 Feb 1997 08:21:57 GMT">
<meta HTTP-EQUIV="expires" CONTENT="0">
</head>
<body>
<div id="app"></div>
</body>
</html>
4、编写webpack配置文件,builder>webpack.render.config.js,建议按照本文这种方式,把配置文件单独抽出来,这样的话,本地调试和打包可以共用一套配置,只需要传递不同参数就可以了,不要把所有的配置和打包逻辑写在一个文件里,太长、太乱、太难维护
/*
Name: 渲染进程配置
Author: haoluo
Date: 2019-10-30
*/
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const devMode = process.env.NODE_ENV === 'development';
module.exports = {
mode: devMode ? 'development' : 'production',
entry: {
main: './src/render/index.js'
},
output: {
path: path.join(__dirname, '../app/'),
publicPath: devMode ? '/' : '',
filename: './js/[name].[hash:8].js'
},
module: {
rules: [
{
test: /\.vue$/,
exclude: /node_modules/,
loader: 'vue-loader'
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: './src/render/index.ejs',
filename: './index.html',
title: 'electron-vue-template',
inject: false,
hash: true,
mode: devMode
})
]
}
适当解释一下:
mode:环境参数,针对不同的环境,webpack内部有一些不同的机制,并对相应环境做相应的优化
entry:入口,webpack执行构建的第一步将从入口文件开始,递归查询并解析所有依赖的模块。配置方式有多种,可参考webpack文档,这里我们配置的路径是'./src/render/index.js',意思是src目录下,render文件夹下的index.js,而webpack配置文件是在builder文件夹下,那这个“./”的相对路径到底是相对于谁呢?这就得说一下webpack中的路径问题了,context 是 webpack 编译时的基础目录,入口起点(entry)会相对于此目录查找,那这个context又是个什么东西?webpack源码有关默认配置中有这么一句话
this.set("context", process.cwd());
这就是context的默认值,工程的根目录,那这个entry的配置就很好理解了。
output:打包的输入配置,路径建议设置为绝对路径。
module和plugins就不多说了。
5、编写本地调试脚本
/**
* Tip: 调试渲染进程
* Author: haoluo
* Data: 2019-10-30
**/
process.env.NODE_ENV = 'development';
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
const webpackHotMiddleware = require('webpack-hot-middleware');
const chalk = require('chalk');
const http = require('http');
function devRender() {
console.log('启动渲染进程调试......');
const webpackDevConfig = require('./webpack.render.config.js');
const compiler = webpack(webpackDevConfig);
new WebpackDevServer(
compiler, {
contentBase: webpackDevConfig.output.path,
publicPath: webpackDevConfig.output.publicPath,
open: true,//打开默认浏览器
inline: true,//刷新模式
hot: true,//热更新
quiet: true,//除第一次编译外,其余不显示编译信息
progress: true,//显示打包进度
setup(app) {
app.use(webpackHotMiddleware(compiler));
app.use('*', (req, res, next) => {
if (String(req.originalUrl).indexOf('.html') > 0) {
getHtml(res);
} else {
next();
}
});
}
}
).listen(8099, function(err) {
if (err) return console.log(err);
console.log(`Listening at http://localhost:8099`);
});
compiler.hooks.done.tap('doneCallback', (stats) => {
const compilation = stats.compilation;
Object.keys(compilation.assets).forEach(key => console.log(chalk.blue(key)));
compilation.warnings.forEach(key => console.log(chalk.yellow(key)));
compilation.errors.forEach(key => console.log(chalk.red(`${key}:${stats.compilation.errors[key]}`)));
console.log(chalk.green(`${chalk.white('渲染进程调试完毕\n')}time:${(stats.endTime-stats.startTime)/1000} s`));
});
}
function getHtml(res) {
http.get(`http://localhost:8099`, (response) => {
response.pipe(res);
}).on('error', (err) => {
console.log(err);
});
}
devRender();
都是一些常规操作,可以阅读一下代码。
6、配置启动命令,在package.json中新增dev命令,启动本地调试(先起了再说,报错什么的,见招拆招)
"scripts": {
"start": "electron ./src/main/main.js",
"dev": "node ./builder/dev.js"
},
然后命令行运行npm run dev。。。。。。反正我这儿是报错了。。。说是找不到html-webpack-plugin模块,那就运行npm i html-webpack-plugin -D安装一下,如果步骤一没有做的话,后面可能还会遇到很多模块找不到的情况,解决方法很简单,缺什么安装什么就好了。安装完所有的模块之后,启动,还是报错了。。。。。。
ModuleNotFoundError: Module not found: Error: Can't resolve 'vue' in ...
ModuleNotFoundError: Module not found: Error: Can't resolve 'vue-loader' in ...
检查了下package.json文件和node_modules,发现我的vue-loader没有装,然后就是装一下(如果没有遇到这个步骤,可以忽略)
再次运行
这个报错就很友好了吗,就是vue-loader告诉你,必须安装vue-template-compiler插件,不然就不工作,那就装一下。
接着运行,就知道没那么容易成功
vue-loader报错说缺少了插件,让检查是否配置了VueLoaderPlugin插件,搜一下这是个什么鬼,看这里,15+版本的vue-loader需要配合VueLoaderPlugin使用,然后看了一下我使用的vue-loader版本15.7.1,那就配一下这个东西。
接着运行,终于没有报错了,但是页面为啥子是白的,我的h1标签呢?冷静下来分析一下问题,页面没有东西说明我打包时生成的html文件有问题(devServer会把打包出来的静态文件保存在内存里),而html文件是根据ejs模板生成的,那会不会是模板配置有问题?
看一下我们的模板,结构是没什么问题啊,但是,没有引用css和js文件啊,也就是我们辛辛苦苦解析vue文件,打包css和js,最后却没有引用。。。好吧,那就再配置一下ejs模板,把相应的文件引入一下
<!DOCTYPE html>
<html lang="zh-CN">
<!--template for 2019年10月30日-->
<!--<%= new Date().getFullYear()+'/'+(new Date().getMonth()+1)+'/'+new Date().getDate()+' '+new Date().getHours()+':'+new Date().getMinutes() %>-->
<%
function getFilePath(filename,libsPath){
let _filenameSearchIndex=filename.indexOf("?");
let _libsPathSearchIndex=libsPath.indexOf("?");
let _filename=filename.substr(0,_filenameSearchIndex<1?filename.length:_filenameSearchIndex);
let _libsPath=libsPath.substr(0,_libsPathSearchIndex<1?libsPath.length:_libsPathSearchIndex);
let htmlfilename=path.relative(_filename,_libsPath);
return libsPath;
}
let path = require('path'),jsArr = [],cssArr = [];
let filename="./index.html";
//修正目录结构
for(let i=0;i<htmlWebpackPlugin.files.css.length;i++){
let name=getFilePath(filename,String(htmlWebpackPlugin.files.css[i]));
cssArr.push(name);
}
for(let i=0;i<htmlWebpackPlugin.files.js.length;i++){
let name=getFilePath(filename,String(htmlWebpackPlugin.files.js[i]));
jsArr.push(name);
}
%>
<head>
<meta charset="UTF-8">
<title><%= htmlWebpackPlugin.options.title %></title>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0">
<meta HTTP-EQUIV="pragma" CONTENT="no-cache">
<meta HTTP-EQUIV="Cache-Control" CONTENT="no-store, must-revalidate">
<meta HTTP-EQUIV="expires" CONTENT="Wed, 26 Feb 1997 08:21:57 GMT">
<meta HTTP-EQUIV="expires" CONTENT="0">
<% cssArr.forEach(css=>{ %><link rel="stylesheet" href="<%= css %>" />
<% }) %>
</head>
<body>
<div id="app"></div>
<% jsArr.forEach(js=>{ %><script type="text/javascript" src="<%= js %>"></script>
<% }) %>
</body>
</html>
我们可以在ejs中拿到html-webpack-plugin插件的一些信息,比如插件配置、生成的文件等,然后拿到js和css文件,并引入进来,这里建议看一下ejs模板语法。
我们接着运行,终于出来了。
7、配置打包脚本
在builder文件夹下新建build.js,引入配置,直接运行webpack打包即可,不需要devServer。
/**
* Tip: 打包
* Author: haoluo
* Data: 2019-10-30
**/
process.env.NODE_ENV = 'production';
const chalk = require("chalk");
const del = require("del");
const webpack = require('webpack');
const renderConfig = require('./webpack.render.config.js');
del(["./app/*"]); //删除历史打包数据
viewBuilder().then(data => {
console.log("打包输出===>", data)
}).catch(err => {
console.error("打包出错,输出===>", err);
process.exit(1);
});
function viewBuilder() {
return new Promise((resolve, reject) => {
console.log("打包渲染进程......");
const renderCompiler = webpack(renderConfig);
renderCompiler.run((err, stats) => {
if (err) {
console.log("打包渲染进程遇到Error!");
reject(chalk.red(err));
} else {
let log = "";
stats.compilation.errors.forEach(key => {
log += chalk.red(`${key}:${stats.compilation.errors[key]}`) + "\n";
})
stats.compilation.warnings.forEach(key => {
log += chalk.yellow(key) + "\n";
})
Object.keys(stats.compilation.assets).forEach(key => {
log += chalk.blue(key) + "\n";
})
log += chalk.green(`time:${(stats.endTime-stats.startTime)/1000} s\n`) + "\n";
resolve(`${log}`);
}
})
})
}
在package.json中新增打包命令
"scripts": {
"start": "electron ./src/main/main.js",
"dev": "node ./builder/dev.js",
"build": "node ./builder/build.js"
},
npm run build执行打包,这次还真是出奇的顺利啊,看一下app文件夹,已经生成了静态文件,然后直接在浏览器打开index.html文件,正常显示。
四、使用vuex,vue-router,axios
说好的全家桶呢,这里我们不用vue-resource了,使用axios。
1、使用vuex
安装vuex依赖,在src>render文件夹下新建store文件夹,并在store文件夹下新增:
actions.js
export default {}
index.js
import Vue from 'vue';
import Vuex from 'vuex';
import actions from './actions.js';
import mutations from './mutations.js';
Vue.use(Vuex);
// 这里为全局的,模块内的请在模块内动态注册
const store = new Vuex.Store({
strict: true,
state: {
userInfo: {
name: 'haoluo',
address: 'beijing'
}
},
getters: {},
mutations,
actions
});
export default store;
mutations.js
export default {
//设置用户信息
setUserInfo(state, config) {
if (!config) {
state.userInfo = {};
}
for (var objName in config) {
state.userInfo[objName] = config[objName];
}
}
}
以上三个文件的实力代码,比官网教程还简单,可以自行研究一下文档。
文件建好之后,需要把store挂载到vue实例上,找到vue工程的入口文件,src>render>index.js
import Vue from 'vue';
import store from './store/index.js';
import index from './views/index.vue';
//取消 Vue 所有的日志与警告
Vue.config.silent = true;
new Vue({
el: '#app',
store: store,
render: h => h(index)
});
然后我们就可以使用啦,找到根组件,src>render>views>index.vue
<template>
<div class="content">
<h1>Welcome to electron-vue-template!</h1>
<h2>name:{{userInfo.name}}</h2>
<h2>address:{{userInfo.address}}</h2>
</div>
</template>
<script>
import {mapState} from 'vuex';
export default {
computed: {
...mapState(['userInfo'])
}
}
</script>
<style></style>
mapState是state的辅助函数,是个语法糖,借助mapState我们可以更方面的获取属性,而不需要写一堆啰里吧嗦的东西,通过计算属性computed接收userInfo,然后就可以使用啦,运行本地调试,发现页面上已经可以正常显示了
属性有了之后我们可以使用,但如果想要改变vuex中存储的属性呢?为了保证单向数据流以及方便对数据的追踪等一些其他原因,不建议直接修改vuex的属性,而是需要通过mutations,这里也有一个辅助函数mapMutations,用法同mapState类似,只不过需要用methods去接收,作为一个全局方法使用
<!-- render>views>index.vue -->
<template>
<div class="content">
<h1>Welcome to electron-vue-template!</h1>
<h2>name:{{userInfo.name}}</h2>
<h2>address:{{userInfo.address}}</h2>
<button @click="changeAddress">设置address为tianjin</button>
</div>
</template>
<script>
import {mapState,mapMutations} from 'vuex';
export default {
computed: {
...mapState(['userInfo'])
},
methods: {
...mapMutations(['setUserInfo']),
changeAddress() {
this.setUserInfo({
address: 'tianjin'
});
}
}
}
</script>
<style></style>
当点击按钮的时候。userInfo中的address被修改了,页面渲染的值也相应的改变了
2、使用vue-router
安装vue-router依赖,在render文件夹下新增router文件夹,并在其中新增index.js
module.exports = [
{
path: '/index.html',
name: 'index',
meta: {
title: '首页',
author: '--',
parentRouter: '--'
},
component: (resolve) => {
require.ensure([], () => {
return resolve(require('../views/index.vue'))
}, "index")
},
children: []
}
];
在入口文件render>index.js中引入并挂载
// render>index.js
import Vue from 'vue';
import VueRouter from 'vue-router';
import store from './store/index.js';
import routers from './router/index.js';
import index from './views/index.vue';
Vue.use(VueRouter);
let router = new VueRouter({
routes: routers
})
//取消 Vue 所有的日志与警告
Vue.config.silent = true;
new Vue({
el: '#app',
router: router,
store: store,
render: h => h(index)
});
运行一下,页面可以正常显示,在地址栏输入http://localhost:8099/index.html时,也是没有问题的,现在新增加一个页面,订单页
<template>
<div class="content">
<h1>order page!</h1>
</div>
</template>
<script>
export default {}
</script>
<style></style>
再配置下路由
module.exports = [
{
path: '/index.html',
name: 'index',
meta: {
title: '首页',
author: '--',
parentRouter: '--'
},
component: (resolve) => {
require.ensure([], () => {
return resolve(require('../views/index.vue'))
}, "index")
},
children: []
},
{
path: '/order.html',
name: 'order',
meta: {
title: '订单页',
author: '--',
parentRouter: '--'
},
component: (resolve) => {
require.ensure([], () => {
return resolve(require('../views/order.vue'))
}, "order")
},
children: []
}
];
并在首页index.vue中增加跳转按钮,运行之后,发现跳不过去,尴尬~~~,路由跳转,需要有<router-view></router-view>去接收才行啊
改造一下吧,views下新增home.vue,把index.vue中的内容拷贝过去,index.vue改为下面这样
<!-- render>views>index.vue -->
<template>
<div>
<router-view></router-view>
</div>
</template>
<script>
export default {
methods: {},
mounted() {
this.$router.push({
name: 'home'
});
}
}
</script>
<style></style>
router文件改为下面这样
module.exports = [
{
path: '/index.html',
name: 'index',
meta: {
title: '首页',
author: '--',
parentRouter: '--'
},
component: (resolve) => {
require.ensure([], () => {
return resolve(require('../views/index.vue'))
}, "index")
},
children: [
{
path: '/home.html',
name: 'home',
meta: {
title: 'home页',
author: '--',
parentRouter: '--'
},
component: (resolve) => {
require.ensure([], () => {
return resolve(require('../views/home.vue'))
}, "home")
},
children: []
},
{
path: '/order.html',
name: 'order',
meta: {
title: '订单页',
author: '--',
parentRouter: '--'
},
component: (resolve) => {
require.ensure([], () => {
return resolve(require('../views/order.vue'))
}, "order")
},
children: []
}
]
}
];
再次运行,页面已经可以正常跳转了。
3、axios,这里暂时不说,后续electron和vue联调的时候会补上。
几点说明:
1、截止目前,webpack配置以及dev和build脚本仅仅是调通大致的流程,还有很多可优化可考究的地方,后续完善项目的过程中会对打包流程逐渐润色;
2、vue全家桶在实际项目中高级用法很多,细节也很多,这里只是最简单的用法,如若用到实际项目中,还要基于目前情况再做细化;
3、本系列文章旨在记录、回顾整个项目框架搭建流程,把握整体结构,很多地方需要根据实际项目再做处理;
4、如有错误或不当的地方,欢迎指出,共同进步!
摘要:前面两篇介绍了如何启动Electron和Vue项目,但到目前为止,虽然把Electron和Vue放到了一个工程里,但貌似它们之间并没有什么关系,通过不同的命令各自运行,Vue写出的页面也无法在Electron中展示,这跟我们的标题显然是不符合的。先喊个口号,从我做起,拒绝标题党!这篇就说一下,如何把Electron和Vue通过Webpack结合起来。项目完整代码:https://github.com/luohao8023/electron-vue-template
一、新建webpack.main.config.js,webpack打包electron的常规配置。
const path=require('path');
const webpack = require('webpack');
const { dependencies } = require('../package.json');
module.exports = {
mode: process.env.NODE_ENV,
entry: {
main: ['./src/main/main.js'],
},
output: {
path: path.join(process.cwd(), 'app'),
libraryTarget: 'commonjs2',
filename: './[name].js'
},
node: {
fs: 'empty',
__dirname: false
},
optimization: {
runtimeChunk: false,
minimize: true
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
}
]
},
externals: [
...Object.keys(dependencies || {})
],
resolve: {
extensions: ['.js']
},
plugins:[
new webpack.NoEmitOnErrorsPlugin(),
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"production"'
})
],
target: 'electron-main'
};
二、改造dev.js,主体思路是打包主进程、打包渲染进程,启动electron应用。
process.env.NODE_ENV = 'development';//开发模式
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
const webpackHotMiddleware = require('webpack-hot-middleware');
const chalk = require('chalk');
const http = require('http');
const { spawn } = require('child_process');
const electron = require('electron');
const path = require('path');
// 构建主进程
function buildMain() {
const mainWebpackConfig = require('./webpack.main.config.js');
return new Promise((resolve, reject) => {
console.log('打包APP主进程......');
let log = '';
// 删除历史打包数据
require('del')(['./app/main.js']);
const mainCompiler = webpack(mainWebpackConfig);
mainCompiler.run((err, stats) => {
let errorInfo = '';
if (err) {
console.log('打包主进程遇到Error!');
reject(chalk.red(err));
} else {
Object.keys(stats.compilation.assets).forEach(key => {
log += chalk.blue(key) + '\n';
})
stats.compilation.warnings.forEach(key => {
log += chalk.yellow(key) + '\n';
})
stats.compilation.errors.forEach(key => {
errorInfo += chalk.red(`${key}:${stats.compilation.errors[key]}`) + '\n';
})
log += errorInfo+ chalk.green(`time:${(stats.endTime-stats.startTime)/1000} s\n`) + "\n";
if(errorInfo){
reject(errorInfo)
}else{
resolve(log);
}
console.log('打包主进程完毕!', log);
}
});
});
}
// 构建渲染进程
function devRender() {
console.log('启动渲染进程调试......');
const webpackDevConfig = require('./webpack.render.config.js');
const compiler = webpack(webpackDevConfig);
new WebpackDevServer(
compiler, {
contentBase: webpackDevConfig.output.path,
publicPath: webpackDevConfig.output.publicPath,
open: true,//打开默认浏览器
inline: true,//刷新模式
hot: true,//热更新
quiet: true,//除第一次编译外,其余不显示编译信息
progress: true,//显示打包进度
setup(app) {
app.use(webpackHotMiddleware(compiler));
app.use('*', (req, res, next) => {
if (String(req.originalUrl).indexOf('.html') > 0) {
console.log(req.originalUrl)
getHtml(res);
} else {
next();
}
});
}
}
).listen(8099, function(err) {
if (err) return console.log(err);
console.log(`Listening at http://localhost:8099`);
});
compiler.hooks.done.tap('doneCallback', (stats) => {
const compilation = stats.compilation;
Object.keys(compilation.assets).forEach(key => console.log(chalk.blue(key)));
compilation.warnings.forEach(key => console.log(chalk.yellow(key)));
compilation.errors.forEach(key => console.log(chalk.red(`${key}:${stats.compilation.errors[key]}`)));
console.log(chalk.green(`${chalk.white('渲染进程调试完毕\n')}time:${(stats.endTime-stats.startTime)/1000} s`));
});
}
// 启动Electron
function startElectron() {
let electronProcess = spawn(electron, [path.join(process.cwd(), 'app/main.js')]);
electronProcess.stdout.on('data', data => {
// 正常输出为蓝色
electronLog(data, 'blue');
});
electronProcess.stderr.on('data', data => {
// 错误信息为红色
electronLog(data, 'red');
});
}
// 美化输出
function electronLog(data, color) {
let log = '';
data.toString().split(/\r?\n/).forEach(line => {
log += `\n${line}`;
});
if (/[0-9A-z]+/.test(log)) {
console.log(
chalk[color].bold('┏ Electron -------------------') +
log +
chalk[color].bold('┗ ----------------------------')
);
}
}
function getHtml(res) {
http.get(`http://localhost:8099`, (response) => {
response.pipe(res);
}).on('error', (err) => {
console.log(err);
});
}
// 构建
function build() {
Promise.all([buildMain(), devRender()]).then(() => {
startElectron();
}).catch(err => {
console.log(err);
process.exit();
});
}
build();
运行npm run dev,迎接即将出现的各种错误吧!以下不是运行一次时出现的错误,而是每次解决完报错之后默认运行一次。
1、找不到babel-loader,这个简单,装一个就行了,npm i babel-loader -D。
2、看样子是babel-loader还需要其他依赖,按提示安装,npm i @babel/core -D。
3、直接打开了浏览器有木有?!!在dev.js中62行,把打开默认浏览器的选项设为false,即open:false。
4、再运行,electron可以正常打开了,但显示但内容却不对,无论怎么修改vue文件重新运行,都不会起作用。找到src/main/main.js,把
win.loadURL(encodeURI(indexPath));
这一行改为
win.loadURL('http://localhost:8099');
发现之前代码的一个错误,win变量是在createWindow函数中声明但,但在函数外部却有调用,修改一下。
再次运行,可以正常启动,而且显示的是vue文件中的内容,尝试修改一下页面内容,热刷新也是正常的。
至此,我们已经可以正常的进行本地开发调试了,再说一下大致的思路:
第一篇和第二篇主要是把完整的Electron项目和Vue项目融合到一个工程里,本篇主要是增加了主进程的webpack配置,并改造了dev.js,在这个脚本中主要做了下面的事:启动渲染进程的打包(这里是devServer),打包主进程,以main.js为入口,输出位置为app文件夹,之后用node的spawn模块,以electron命令执行app文件夹下的main.js,启动electron,并把electron的页面地址只想devServer地址,这就是本地调试的大致逻辑了。文字不太多,主要是理解webpack配置及调试脚本。后面会介绍到构建整个项目,并打包exe安装文件,以及其他的一些配置优化、项目结构优化。
摘要:前面几篇介绍了如何启动electron和vue项目,并进行联合调试,这篇就来给我们的Electron配置润色一下,至少看起来不那么像一个‘demo’。项目完整代码:https://github.com/luohao8023/electron-vue-template。
一、’清理‘主进程文件main.js,提取窗口配置文件。
之前我们是把创建窗口以及窗口配置都放在了main.js中,这样会让我们的主进程看起来很乱,掺杂了各种配置、各方面的代码,而且一旦我们的项目稍微复杂一些,比如同时维护多个窗口,或者有很多针对某个窗口的事件监听等。这里所说的一个个窗口其实就是electron的渲染进程,不同的渲染进程由主进程来统一维护和调度。把渲染进程提取为单独的配置文件,对外只暴露方法,这样就能简化主进程代码,也让我们的项目结构更清晰、更合理,总之是好处多多。
新建文件:main>index.js
/**
* Tip: 主进程
* Author: haoluo
* Data: 2020-02-25
**/
const {
BrowserWindow,
dialog
} = require("electron");
const electron = require("electron");
const process = require("process");
const url = require("url");
const path = require("path");
const cookie = require('cookie');
const devMode = process.env.NODE_ENV === "development";
let mainWindow = null;
const filter = {
urls: ['http://*.kakayang.cn/*']
};
//创建窗口
function createWindow() {
// 首页路径,file协议,pathToFileURL得到编码过的URL
let filePath = url.pathToFileURL(path.join(__dirname, 'index.html')).href;
let indexUrl = 'http://localhost:8099/';
let winW = electron.screen.getPrimaryDisplay().workAreaSize.width,
winH = electron.screen.getPrimaryDisplay().workAreaSize.height;
let config = {
title: "electron-vue-template",
width: winW <= 1240 ? winW : 1240,
height: winH <= 730 ? winH : 730,
minWidth: winW <= 1240 ? winW : 1240,
minHeight: winH <= 730 ? winH : 730,
offscreen: true,
show: true,
center: true,
frame: false, //去掉窗口边框
autoHideMenuBar: true, //隐藏菜单栏
titleBarStyle: 'customButtonsOnHover',
simpleFullscreen: true,
resizable: process.platform === 'darwin', //可否调整大小
movable: true, //可否移动
minimizable: true, //可否最小化
maximizable: true, //可否最大化
fullscreen: false, //MAC下是否可以全屏
skipTaskbar: false, //在任务栏中显示窗口
acceptFirstMouse: true, //是否允许单击页面来激活窗口
transparent: process.platform === 'darwin', //允许透明
opacity: 1,//设置窗口初始的不透明度
closable: true,
backgroundColor: '#fff',
allowRunningInsecureContent: true,//允许一个 https 页面运行 http url 里的资源
webPreferences: {
devTools: true, //是否打开调试模式
webSecurity: false,//禁用安全策略
allowDisplayingInsecureContent: true,//允许一个使用 https的界面来展示由 http URLs 传过来的资源
allowRunningInsecureContent: true, //允许一个 https 页面运行 http url 里的资源
nodeIntegration: true//5.x以上版本,默认无法在渲染进程引入node模块,需要这里设置为true
}
};
// 增加session隔离配置
config.webPreferences.partition = `persist:${Date.now()}${Math.random()}`;
mainWindow = new BrowserWindow(config);
global.windowIds.main = mainWindow.webContents.id;
// 开发环境使用http协议,生产环境使用file协议
mainWindow.loadURL(devMode ? encodeURI(indexUrl) : filePath);
//监听关闭
mainWindow.on('closed', function () {
mainWindow = null;
}).on('close', function (event) {
mainWindow.send("close-window-render");
event.preventDefault();
}).on('ready-to-show', function () {
mainWindow.show();
});
try {
if (mainWindow.webContents.debugger.isAttached()) mainWindow.webContents.debugger.detach("1.1");
mainWindow.webContents.debugger.attach("1.1");
mainWindow.webContents.debugger.sendCommand("Network.enable");
} catch (err) {
console.log("无法启动调试", err);
dialog.showErrorBox("get", "无法启动调试");
}
// 拦截请求并处理cookie
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(filter, onBeforeSendHeaders);
mainWindow.webContents.session.webRequest.onHeadersReceived(filter, onHeadersReceived);
return mainWindow;
}
function onBeforeSendHeaders(details, callback) {
if (details.requestHeaders) {
details.requestHeaders['Cookie'] = global.cookie;
details.requestHeaders['Origin'] = details.url;
details.requestHeaders['Referer'] = details.url;
}
callback({ requestHeaders: details.requestHeaders });
}
function onHeadersReceived(details, callback) {
let cookieArr = [];
for (let name in details.responseHeaders) {
if (name.toLocaleLowerCase() === 'Set-Cookie'.toLocaleLowerCase()) {
cookieArr = details.responseHeaders[name];
}
}
let webCookie = "";
cookieArr instanceof Array && cookieArr.forEach(cookieItem => {
webCookie += cookieItem;
});
let webCookieObj = cookie.parse(webCookie);
let localCookieObj = cookie.parse(global.cookie || '');
let newCookie = Object.assign({}, localCookieObj, webCookieObj);
let cookieStr = "";
for (let name in newCookie) {
cookieStr += cookie.serialize(name, newCookie[name]) + ";";
}
global.cookie = cookieStr;
callback({ response: details.responseHeaders, statusLine: details.statusLine });
}
module.exports = {
create(_callback) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.destroy();
}
mainWindow = createWindow();
if (_callback instanceof Function) _callback(mainWindow);
return mainWindow;
}
}
修改main.js:
const { app, BrowserWindow } = require("electron");
let mainWindow = require("./index.js");
//注册全局变量
// 页面跟路径配置,优先使用此配置,考虑到小版本更新时,版本之间的切换
global.wwwroot = {
path: __dirname
};
global.cookie = "";
//主窗口id,在创建主窗口的js中获取并修改此处
global.windowIds = {
main: 0
};
app.on('ready', () => {
mainWindow.create();
});
app.on('window-all-closed', function() {
setTimeout(() => {
let allwindow = BrowserWindow.getAllWindows();
if (allwindow.length === 0 ) app.exit(1);
}, 500);
});
二、单实例检查,只允许启动一个客户端。
新建文件:main->libs->runCheck.js:
const {
app,
BrowserWindow
} = require("electron");
module.exports=()=>{
// 单实例检查
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) return app.quit();
app.on('second-instance', () => {
let myWindows = BrowserWindow.getAllWindows();
myWindows.forEach(win => {
if (win && !win.isDestroyed()) {
if (win.isMinimized()) win.restore();
win.focus();
}
});
});
}
在main.js中引入并执行check函数:
require("./libs/runCheck.js")(); //禁止打开多份
三、注册快捷键打开控制台:
细心的话可以发现,我们已经把控制台关掉了。以往的做法是在代码里打开控制台,打包发布时再把代码注释掉,某个环境的包出问题了,又要放开限制再打对应环境的包,相当的麻烦。这里的解决方案是:
通过注册快捷键的方式来操作控制台,而不是频繁的注释、取消注释代码。
新建文件:main->libs->shortcut.js:
const {
app,
BrowserWindow
} = require("electron");
const globalShortcut = require("electron").globalShortcut;
class Shortcut{
register(keys='Command+Control+Alt+F4'){
globalShortcut.register(keys, function () {
let allWindow = BrowserWindow.getAllWindows();
for(let index =0;index < allWindow.length ;index++){
let win=allWindow[index]
if(win.webContents && !win.webContents.isDevToolsOpened()){
win.webContents.openDevTools({mode: 'detach'});
}
}
})
}
}
app.on('will-quit', function () {
globalShortcut.unregisterAll()
});
module.exports=new Shortcut();
然后在主进程中引用并执行:
const shortcut = require("./libs/shortcut.js"); //注册快捷键
app.on('ready', () => {
//注册快捷键打开控制台事件
shortcut.register('Command+Control+Alt+F5');
mainWindow.create();
});
四、配置devServer:
写死端口可不是个好主意,得能配置才行,不然万一哪个端口被占用,要修改所有引用的地方,很是麻烦。
新建文件:config->devServerConfig.js:
/**
* Tip: devServer的配置
* Author: haoluo
* Data: 2020-02-25
* Tips: 使用以下命令启动各环境配置,npm run dev [dev|test|release]
**/
let envList = ['dev', 'test', 'release'];
let currentEnv = 'release';
let envArg = process.argv[2];
if (envArg && envList.includes(envArg)) {
currentEnv = envArg;
}
//导出服务配置
module.exports = {
url: '127.0.0.1',
port: 8098,
// 运行环境
currentEnv: currentEnv,
// 调试完打开浏览器
devComplateOpened: true
};
可以看到我们增加了启动参数,用来调试不同环境,这个参数可以用来标记不同环境的后端服务,对于后端接口地址我们也可以提取配置文件,跟这个环境参数相对应,这里就不多说了。
修改index.js:
const devServerConfig = require('@config/devServerConfig.js');
let indexUrl = `http://${devServerConfig.url}:${devServerConfig.port}/`;
修改dev.js中使用到端口信息的地方。
摘要:到目前为止,我们的项目已经具备了PC客户端该有的一些基础功能和调试环境,但是总感觉缺了灵魂,那就是结合实际项目、实际业务的细节处理,缺着吧。。。这篇文章就介绍一下预加载和自动更新,文字功底有限,如有介绍的不清楚的地方,欢迎留言指正,或者跳过文字,直接去看代码,项目完整代码:https://github.com/luohao8023/electron-vue-template,随博客更新。
一、预加载
1、什么是预加载?什么场景能用到?
preload String (可选) -在页面运行其他脚本之前预先加载指定的脚本 无论页面是否集成Node, 此脚本都可以访问所有Node API 脚本路径为文件的绝对路径。 当 node integration 关闭时, 预加载的脚本将从全局范围重新引入node的全局引用标志。
摘自electron官网的一段介绍,https://www.electronjs.org/docs/api/browser-window。
preload是BrowserWindow类的参数webPreferences的一个可选配置项,我们解读一下官网的介绍:
在页面运行其他脚本之前预先加载的指定的脚本:首先是个js文件没错了,再看加载时机,在页面运行其他脚本之前预先加载,这个页面不是普通的某个h5页面,而是指某个渲染进程(需要预加载js的渲染进程,因为渲染进程可能有多个,每个就是一个窗口),我们new一个BrowserWindow,打开了一个窗口,就是启动了一个渲染进程,如果我们不给这个窗口指定页面,那它就是空白的,如果指定了页面,那么窗口就会加载这个页面:
const win = new BrowserWindow({
width: 800,
height: 600
});
win.loadURL('https://www.baidu.com');
如上面代码,我们创建了一个窗口,然后加载百度首页,而preload脚本的加载时机就是窗口创建后,百度首页加载之前。如果有人问,如果不调用loadURL方法,不加载页面,preload脚本会加载吗?答案是会,但有什么用呢?你起个壳子不给人家看页面是什么鬼?不管这些,重要的是我们理解这个加载时机就好了;
无论页面是否集成Node,此脚本都可以访问所有Node API:首先要说明的一点是,Electron5.x以上版本,默认无法在渲染进程中访问Node API,如需使用,需要预先配置:
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true
}
});
然后还要清楚一点,preload脚本是运行在渲染进程中的,可以仔细考虑一下。再有一点就是,preload脚本中可以访问window对象(渲染进程其实就是起了个浏览器壳子),preload脚本运行在渲染进程,提前于页面和其他所有js的加载,又能访问Node API;
脚本文件路径为绝对路径,当node integration关闭时,预加载的脚本将从全局范围重新引入node的全局引用标志:结合前面两点理解就好了。
那么,到底什么是预加载?用白话定义一下:
某一个渲染进程,在页面加载之前加载一个本地脚本,这个脚本能访问所有Node API、能访问window对象。用法如下:
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
});
理解应该差不多了,但什么场景能用到这玩意儿呢?按正常的逻辑来想,主进程启动后启动渲染进程,渲染进程加载页面就完事儿了,哪会用到这个preolad呢?
想一下,如果我们有以下场景:
a、如果我们启动了一个窗口(渲染进程),加载了一个线上的页面,本地没有页面文件,但要做一些错误处理,比如网络错误,页面加载失败,然后在页面空白但时候插入一些元素;
b、如果我们的一套代码部署在web端和客户端,需要用一个变量判断是在web端还是客户端;
...........
感觉举的例子好勉强啊,不要见怪,就是大概这么个意思,没准哪天就遇到了非preload解决不了的问题呢,毕竟这玩意儿还是有它的特殊之处的;
上面两个场景如果用preload来解决的话,思路是利用prelaod中能访问window对象的特点,比如b,代码中可以用window.isClient来判断是否在客户端,默认为false,然后在preload中把window.isClient设置为true,而对于部署在web端的代码来说,这个值就是false。
2、怎么用?
上面说了怎么引用preload脚本,现在说一下怎么写,下面开始xxoo乱写乱画了:
// 访问electron对象
const {
remote,
ipcRenderer
} = require('electron');
// 访问node模块
const fs = require('fs');
const path = require('path');
// 访问window对象
window.isClient = true;
window.sayHello = function() {
console.log('hello');
};
// 操作dom
const div = document.createElement('div');
div.innerText = 'I am a div';
document.body.appendChild(div);
// ...
如果preoad里面逻辑比较复杂,有可能还要用webpack打包一下,单独拎出来打包就行了,webpack单文件打包,注意targer要"electron-renderer":
/*
Tip: preload 打包配置
*/
const path=require('path');
const { dependencies } = require('../package.json');
module.exports = {
mode:process.env.NODE_ENV,
entry: {
preload:['./src/preload/index.js']
},
output: {
path: path.join(__dirname, '../app/'),
libraryTarget: 'commonjs2',
filename: './[name].js'
},
optimization: {
runtimeChunk: false,
minimize: true
},
node: {
fs: 'empty',
__dirname:false
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
}
]
},
externals: [
...Object.keys(dependencies || {})
],
resolve: {
extensions: ['.js'],
alias: {
'@': path.resolve(__dirname, "../src"),
'@public': path.resolve(__dirname, "../public")
}
},
plugins:[],
target:"electron-renderer"
}
我相信,总会遇到使用preload就能迎刃而解的问题。
二、自动更新
我们都知道,electron其实是封了个chrome内核,抛开壳子不说,里面运行的其实就是我们的h5页面,而就算我们跑了个空项目,没有任何内容,打包后的安装包也得30M左右,我们希望自己的程序有自动更新功能,但是更新机制是怎样的呢?
如果我们只改动了页面某一处的文本,却要用户更新整个安装包,那显然太不合理了,一是体验不好,二是我们的流量啊......
基于这种考虑,加上electron主进程和渲染进程的划分,那我们可以考虑如下更新机制:
主进程有改动时,那没的说,用户需要更新整个客户端(当然有精力有条件的可以做动态更新,官方好像是说支持,主要是我不会);渲染进程有改动时,我们只需要把h5包下载到本地然后加载就行了,当然这需要我们打包的时候能把h5包区分出来,在更新后能打开对应版本的h5包。
这里我们称主进程的更新为大版本更新,渲染进程的更新为小版本更新。
1、打包配置修改
为什么突然扯到打包配置修改了呢,因为牵扯到小版本的更新,那我们打包的时候就得把这个“小版本”给打出来,不然更新个🔨。因为下面还有一篇文章是专门介绍这个Electron-vue项目的打包,所以这里呢就只讲一下怎么把小版本的压缩包给打出来。
修改build.js,思路是:使用webpack打包主进程、打包preload、打包渲染进程,得到可执行文件目录app,然后引入electrin-builder对app目录进行打包,产生一个安装包,然后把渲染进程的文件压缩并标记版本号。这里我们只拣和本节相关的说,就是打包渲染进程和压缩小版本文件,为什么能拆出来说呢,当然是分模块封装的好处啦,各个进程的打包逻辑封装一下拆出来,能随意组合还能复用,否则一个又臭又长的打包脚本文件,很难维护。
具体代码就不贴出来了,太占篇幅,也没什么用,可以到https://github.com/luohao8023/electron-vue-template看完整代码。
2、增加启动页,启动页显示欢迎语等,在这里检查更新
这里我们暂且叫它检查更新页,这个检查更新页是个独立的渲染进程,用户打开程序时首先显示检查更新窗口,但是这个窗口也不一定显示检查更新字样,偷偷的检查就行了,有新版本就提示更新,没有新版本就显示欢迎语。
这儿的逻辑是单独拆分出来的,不能是自动更新的时候把自动更新逻辑本身也给更新了,容易乱套。
修改主进程代码,程序启动时首先启动自动更新窗口:
app.on('ready', () => {
//注册快捷键打开控制台事件
shortcut.register('Command+Control+Alt+F5');
mainWindow = updateWin.create();
});
然后注册监听事件,因为自动更新窗口逻辑完成之后需要呼起主窗口,需要主进程来调度:
//启动主窗体
ipcMain.on('create-main',(event,arg) => {
// h5页面指向指定版本
// global.wwwroot.path = arg.newVersionPath ? arg.newVersionPath : __dirname;
// if (arg.version) setVal('version','smallVersion', arg.version);
indexWin.create();
mainWindow.destroy();
});
自动更新窗口只需专注于更新逻辑就行了,逻辑结束后呼起主窗口:
// 更新逻辑看下面伪代码
const v1 = getOnlineVersion();
const v2 = getLocalVersion();
const needUpdate = checkVersion(v1, v2);
if (needUpdate) {
downloadVersion();
}
this.runMain();
在呼起主窗口的同时给主窗口传递参数,并告知主窗口有没有更新版本,以及主窗口需要加载哪个小版本的包,而主窗口在loadURL时也要做下改动:
let wwwroot = global.wwwroot.path ? global.wwwroot.path : __dirname;
let filePath = url.pathToFileURL(path.join(wwwroot, 'index.html')).href;
而wwwrot就是当前小版本包的根路径,由主进程来维护,自动更新小版本后会修改这个值,以告诉主进程加载哪个版本。
好了,啰嗦了一大堆,好多地方没贴代码,感觉贴了代码的话,篇幅就不受控制了,还是去github看完整项目吧,自动更新这一块是伪代码,只实现了渲染进程的切换(即自动更新窗口呼起主窗口),具体的更新逻辑实现起来的话还要拿线上版本去比较,这个还是留给大家在实际项目中去调试吧,就是上面这个思路。
好啦,有什么问题可以留言交流,也可以直接去看代码https://github.com/luohao8023/electron-vue-template。
摘要:整个项目就剩最后一哆嗦了,但仅仅是当作demo模版来说,实际项目的话,还有很多需要细化的地方。项目完整代码:https://github.com/luohao8023/electron-vue-template,随博客更新。
一、打包客户端
首先是要改一下build.js,把上篇文章没做的事儿给做了。
上篇文章已经构建出了可执行文件目录app,这次我们要做的就是使用electron-builder把app目录打包为安装包。
在之前的基础上引入electron-builder,然后对app目录进行打包:
const builder = require('electron-builder');
// 在所有的打包逻辑执行完成之后,确认已经正确生成了app目录
builder.build().then(() => {
del(['./pack/*.yaml', './pack/*.blockmap']);
// 为了方便,打包完成之后我们打开文件管理器
openFileManager();
});
function openFileManager() {
// 打开文件管理器
let dirPath = path.join(__dirname, '..', 'pack');
if (process.platform === 'darwin') {
spawn('open', [dirPath]);
} else if (process.platform === 'win32') {
spawn('explorer', [dirPath]);
} else if (process.platform === 'linux') {
spawn('nautilus', [dirPath]);
}
}
然后自信满满的开始打包。。。。
报错了,说是什么描述缺失,icon没有设置啥的,就是打包的时候没有配置呗,去看下package.json文件,果然是少了build字段,package.json文件中的build字段就是有关打包的配置,一些必要的配置项还是要填的。
在package.json中增加build字段:
"build": {
"asar": true,
"productName": "Electron+vue+webpack模板",
"appId": "com.electron.template",
"copyright": "Copyright © template",
"directories": {
"output": "pack"
},
"files": [
"app/**"
],
"mac": {
"identity": "com.electron.templat",
"target": [
"dmg"
],
"artifactName": "${productName}.${ext}",
"icon": "main/favicon/favicon.icns"
},
"dmg": {
"title": "${productName}",
"artifactName": "${productName}.${ext}",
"icon": "main/favicon/favicon.icns"
},
"win": {
"legalTrademarks": "Copyright © template",
"publisherName": "electron",
"requestedExecutionLevel": "highestAvailable",
"target": [
{
"target": "nsis",
"arch": [
"ia32"
]
}
],
"artifactName": "${productName}.${ext}",
"icon": "main/favicon/favicon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"perMachine": true,
"allowElevation": true,
"artifactName": "${productName}-安装包-V${version}.${ext}",
"runAfterFinish": true,
"shortcutName": "Electron+vue+webpack-template"
}
},
现在我们来挨个解读一下各个配置项都是什么意思,当然还有很多其他配置,这里不再额外介绍了。
asar:是否打包为asar文件,设置为true的话,相当于给你的代码加密了一下,直接就是个.asar的文件,具体内容需要解密了之后才能看到;设置为false的话,不对你的代码进行加密处理,也就是用户安装你的程序之后,找到安装目录,就能直接看到源码,目录结构跟你开发的时候是一样的,不太安全,建议设置为true;
productName:你的应用名称,比如会显示在安装程序的标题处,以及安装完成后的应用程序目录里;
appId:你程序的唯一id,比如绑定到某第三方平台或应用市场,一般会需要这个,我是没有,随便填的;
copyright:按照网站的copyright来理解就好啦,如果你的程序不需要发不到各大市场的话,这个内容可以忽略;
directories:它下面还有其他属性,这里我们只填了ouptut选项,就是打包输出目录,我们这里填了pack文件夹;
files:需要打包哪些内容,就是你的源代码,我们这里填的"app/*",就是app目录下的所有内容;
上面都是一些基础的内容,下面介绍一下针对不同平台的配置:
mac:
identity:这个我不是特别清楚,看名字应该是表明开发者或者软件身份的东西;
target:你要打包成什么格式的安装包,这里填的是dmg,可以填多个;
artifactName:生成的可执行文件的名称;
icon:应用图标,显示在桌面快捷方式或者系统托盘;
针对dmg的单独配置这里就不说了,因为mac选项的target属性可以多填,我们填了dmg,就对dmg做了单独的配置,也可以忽略;
win:
legalTrademarks:合法商标。。。。。。
publisherName:发行商类似的意思;
requestedExecutionLevel:应用程序需要的权限,我们这里填的是highestAvailable,就是当前用户允许的最高权限,你如果是管理员用户在使用,那就是管理员权限,如果是普通用户在使用那就是普通管理员权限。设置为最高权限可以解决一些问题,比如对c盘的一些文件进行操作等。但是请注意一点,如果你的程序是以管理员身份运行的,但是你又想实现从桌面往应用程序中拖动文件的功能,这是不行的,因为文件管理器的权限是低于管理员的,windows上无法从低权限处往高权限处拖动文件,这点还是要注意一下;
target:目标平台,我们选32位,并且使用nsis打包;
artifactName:可执行文件名称;
icon:应用图标;
nsis:
因为electron-builder是基于nsis打包的(有兴趣的可以了解一下nsis),所以这里提供了一些基础配置:
oneClick:不是点击一次,也不是单例什么的,这里是一键安装的意思,设置为true的话,只要双击打开安装包,程序会自动安装并运行;建议设置为false,让用户点击下一步、下一步来安装;
allowToChangeInstallationDirectory:是否允许修改安装目录,默认为false;
perMachine:每台机器是否只允许安装一个程序,如果已安装,再次安装的时候,会要求用户先删除之前的程序;
allowElevation:允许请求提升(权限),如果设置为false,用户必须重启程序才能安装提升了权限的安装程序;
artifactName:安装包名称;
runAfterFinish:安装完成是否运行程序;
shortcutName:快捷方式名称
这是模版里用到的所有属性,解释的也不一定对。当然还有很多其他的配置项,感兴趣的可以搜一下了解了解,说不定某个小小的配置就能解决你一个大问题呢。
好了,说了这么多,现在接着运行打包命令吧,看看啥情况:
(node:96470) UnhandledPromiseRejectionWarning: Error: Application entry file "index.js" in the "/Volumes/SHARE/projects/github/electron-vue-template/pack/mac/Electron+vue+webpack模板.app/Contents/Resources/app.asar" does not exist. Seems like a wrong configuration.
还是有错啊,说的很详细,说是程序入口文件index.js不存在,我们看一下:
"name": "electron-vue-template",
"version": "1.0.0",
"description": "electron-vue-template",
"main": "index.js",
"scripts": {
"dev": "node ./builder/dev.js",
"build": "node ./builder/build.js"
},
main字段就是程序入口,我们写的是index.js,看下代码目录,我们的主进程入口是main.js,那就改一下吧,把index.js改为main.js,接着运行打包命令:
还是出错呦,入口文件找不到,这个问题还真想来好大一会儿,感觉没有错啊,名称也修改来,就是main.js啊,又瞅了眼代码目录才恍然大悟,这不阴沟里翻船嘛,通常情况下main.js是在工程根目录的,但是我们规划完工程目录之后,把main.js给打包到app目录下了,所以入口字段应该填"app/main.js",接着运行打包命令,这次终于成功了,看下pack文件夹中生成的文件:
第一个dmg文件就是mac的安装包,第二个yml文件记录了程序的一些基本信息,mac文件夹下是一个免安装的可执行程序,最后一个就是我们压缩出来的小版本,windows下跟这个目录不一样。
先不着急安装,打开mac文件夹下的可执行程序,可以直接打开我们的程序,打开之后懵了,一片空白啊,啥东西也没有,赶紧找找原因。
打开app目录发现,没有生成update.html,经排查发现,上次提交的代码有个地方写错了,拼错了个单词:
Promise.all([buildPreload(), buildRender()]).then(resolve => {
resolve.forEach(log => {
console.log('打包输出===>', log);
});
const outpath = path.join(__dirname, '../pack/');
try {
fs.mkdirSync(outpath);
} catch(e) {
console.log('已创建pack文件夹', e);
}
console.log('打包渲染进程完毕!压缩小版本!');
const zipPath = renderConfig.output.path;
const fileName = setup.versionType + '-' + setup.version.join('.');
const filePath = path.join(zipPath, `../pack/${fileName}.zip`);
compress(zipPath, filePath, 7 , (type,msg) => {
if (type === 'error'){
Promise.reject('压缩文件时出错:' + msg);
} else {
console.log(`压缩包大小为:${(msg / 1024 / 1024).toFixed(2)}MB`);
}
});
Promise.all([buildMain(), buildUpdate()]).then(resplve => {
resolve.forEach(log => {
console.log('打包输出===>', log)
});
builder.build().then(() => {
del(['./pack/*.yaml', './pack/*.blockmap']);
openFileManager();
});
}).catch(err => {
console.error('打包【main】-【update】错误输出===>', err);
process.exit(2);
});
}).catch(err => {
console.error('打包【preload】-【render】出错,输出===>', err);
process.exit(1);
});
看一下,第二个Promise.all.then中,参数写成了resplve,而在打印log的时候用的是resolve,偏偏上面有resovle,所以也没报错,但是第二次promise的log就全被吃了,赶紧改回来,再跑一下,果然有个错误:
打包输出===> ModuleNotFoundError: Module not found: Error: Can't resolve 'css-loader' in '/Volumes/SHARE/projects/github/electron-vue-template':undefined
没有css-loader,那就装一个:
打包输出===> ModuleNotFoundError: Module not found: Error: Can't resolve 'less-loader' in '/Volumes/SHARE/projects/github/electron-vue-template':undefined
又说没有less-loader,再装一个,运行命令,看到app目录下生成了update.html,这下应该没问题了吧。
打开mac文件夹下的免安装文件,程序启动后跟我们本地调试的效果是一样的,再使用安装包安装一下,安装完成打开后也是正常的。
好啦,打包客户端就说到这儿了,下面说一下怎么使用同一套代码打包web端。
二、打包web端
这里建议把打包web端的逻辑单独拆出来,网站代码是同一套,但是打包逻辑是两套
dev的逻辑就是起个devServer返回html文件就行了,不再多说。
而打包的话是针对单页面的,只会生成一个html文件,如果相针对每个路由都生成一个html文件,这里提供下思路:
引入路由文件,遍历路由,拿到路径,针对每个路径,实例化一个HtmlWebpackPlugin,即可生成一个html文件:
webpackDevConfig.plugins.push(new HtmlWebpackPlugin({
template: './src/index.ejs',
filename: `.${routerPah}`,
title: "加载中...",
inject: false,
hash: true,
minify: false,
cache: false
}))
在package.js中增加启动命令:
"scripts": {
"dev": "node ./buildClient/dev.js",
"devweb": "node ./buildWeb/dev.js",
"build": "node ./buildClient/build.js",
"buildweb": "node ./buildWeb/build.js"
}
分别调试和打包客户端、web端。
这篇文章端内容就到这里了,具体的逻辑还是要去看代码的。针对这套逻辑我们其实有已经上线了的产品的,很多细化的东西也有,但是不便拿出来说,也不好做成demo。模板中可能会有些冗余代码,就是之前的逻辑没有删除干净,自行优化就好了。
有什么问题欢迎留言讨论。项目完整代码:https://github.com/luohao8023/electron-vue-template。
作者:罗知晏 出处: https://www.cnblogs.com/kakayang/ 本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。如果觉得还有帮助的话,可以点一下右下角的【推荐】。
更多推荐
所有评论(0)