使用vue3 + pina + mavon-editor + electron开发的桌面应用
如果不想看下面的简介,可以直接下载项目运行看效果
项目地址: markdown-electron-vue - 公开仓库 (coding.net)
应用截图:
运行环境:
node 20.14.0
npm 10.7.0
已完成功能列表
- 集成应用程序菜单——左上角菜单
- 菜单与页面通信
- 页面与菜单通信
- 菜单和快捷键-CommandOrControl+S保存文件到本地
- 菜单和快捷键-CommandOrControl+O打开本地文件
- 文件拖拽到编辑器内打开,并可以保存到原来的文件中
- renderer自己下载,应用图标展示进度条
- electron 打包,路由要使用hash模式,不然打包出来是白屏的
scripts
npm install
运行vue项目开发环境代码
npm run dev
运行到electron开发环境
npm run start
electron打包
npm run electron_win
- 打包mac平台运行
npm run electron_mac
- 打包windows平台运行
npm run electron_win
- 打包Linux平台运行
npm run electron_lin
Lint with ESLint
npm run lint
初始化
vite 新建一个项目
引入 mavon-editor, npm install --save mavon-editor
引入electron,npm install --save-dev electron
src目录下新建background.js/preload.js
- package.json增加:
"scripts": {
...
"start": "DEBUG=true electron ."
},
"main": "src/background.js"
- __dirname编译报错处理
import { dirname } from "node:path"
import { fileURLToPath } from "node:url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
集成应用程序菜单——左上角菜单
- src目录下新增menu.js文件
import { Menu, MenuItem, shell } from 'electron'
const menu = new Menu()
// mac 左上角菜单
import { app, Menu, shell } from 'electron'
const isMac = process.platform === 'darwin'
const template = [
// { role: 'appMenu' }
...(isMac
? [{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' }
]
}]
: []),
// { role: 'fileMenu' }
{
label: 'File',
submenu: [
isMac ? { role: 'close' } : { role: 'quit' }
]
},
// { role: 'editMenu' }
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
...(isMac
? [
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Speech',
submenu: [
{ role: 'startSpeaking' },
{ role: 'stopSpeaking' }
]
}
]
: [
{ role: 'delete' },
{ type: 'separator' },
{ role: 'selectAll' }
])
]
},
// { role: 'viewMenu' }
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' }
]
},
// { role: 'windowMenu' }
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
...(isMac
? [
{ type: 'separator' },
{ role: 'front' },
{ type: 'separator' },
{ role: 'window' }
]
: [
{ role: 'close' }
])
]
},
{
role: 'help',
submenu: [
{
label: 'Learn More',
accelerator: process.platform === 'darwin' ? 'Alt+Cmd+I' : 'Alt+Shift+I',
click: async () => {
// const { shell } = require('electron')
await shell.openExternal('https://github.com/hinesboy/mavonEditor')
}
}
]
}
]
const menu = Menu.buildFromTemplate(template)
export const myMenu = menu
- src/background.js引入菜单,并设置菜单
import { ..., Menu } from 'electron'
import { myMenu } from './menu.js'
...
Menu.setApplicationMenu(myMenu)
app.whenReady().then(() => {
...
})
菜单与页面通信思路
- 在src/preload.js全局注册ipcRenderer,用ipcRenderer.on监听菜单出发的事件
src/preload.js核心代码如下, 全局注册ipcRenderer
这里send的validChannels定义的是页面调用主进程的事件
receive的validChannelsd定义的是主进程调用页面的事件
contextBridge.exposeInMainWorld('ipcRenderer', {
send: (channel, data) => {
// console.log(channel, data)
// Array of all ipcRenderer Channels used in the client
let validChannels = ['set-title', 'save', 'download-progress']
if (validChannels.includes(channel)) {
ipcRenderer.send(channel, data)
}
},
receive: (channel, func) => {
// Array of all ipcMain Channels used in the electron
let validChannels = ['insert-text', 'save', 'load']
if (validChannels.includes(channel)) {
// Deliberately strip event as it includes `sender`
ipcRenderer.on(channel, (event, ...args) => func(...args))
}
}
})
src/menu.js中发出insert-text事件给Renderer
...
{
label: 'Format',
submenu: [
{
label: 'Test Communication',
click: () => {
const window = BrowserWindow.getFocusedWindow()
window.webContents.send('insert-text', 'test communication with renderer process')
}
}
]
}
- demo.vue页面收到ipcRenderer.on监听的事件,调用页面方法
window.ipcRenderer && window.ipcRenderer.receive('insert-text', (arg) => {
document.getElementById('communicationText').innerHTML = arg
setTimeout(() => {
document.getElementById('communicationText').innerHTML = ''
}, 5000)
})
页面与主进程通信思路
- 在src/preload.js全局注册ipcRenderer,上面菜单与页面通信思路中第1点有介绍
- 在页面中调用通信的方法
function sendMessage () {
window.ipcRenderer.send('set-title', `你好`)
}
- 在主进程src/background.js用ipcMain监听页面发送的方法,调用系统方法
ipcMain.on('set-title', (event, title) => {
// console.log(`received ${title}`)
const webContents = event.sender
const win = BrowserWindow.fromWebContents(webContents)
win.setTitle(title)
})
菜单和快捷键-CommandOrControl+S保存文件到本地思路
- src/menu.js中添加File菜单,定义Save File子菜单
- accelerator定义快捷键
- click回调方法中调用
webContents.send('save')
发送消息给页面
{
label: 'File',
submenu: [
...
{
label: 'Save File',
accelerator: platform === 'darwin' ? 'Cmd+S' : 'Ctr+S',
click: () => {
const window = BrowserWindow.getFocusedWindow()
window.webContents.send('save')
}
}
]
},
- HomeView.vue页面收到主进程定义的'save'事件,调用页面定义的send事件
ipcRenderer.send('save', {editor: editorValue.value, fileData})
把数据传给主进程
function saveHandler () {
// console.log(openedFileData.value)
const fileData = {
path: openedFileData.value.path,
name: openedFileData.value.name
}
window.ipcRenderer.send('save', {editor: editorValue.value, fileData})
}
window.ipcRenderer && window.ipcRenderer.receive('save', () => {
saveHandler()
})
- src/background.js主进程中监听页面发送的save事件,然后做保存
如果传递过来的数据有文件路径,就保存到该路径的文件内,否则调用选择文件路径的弹窗,保存为新文件
// 保存到本地
ipcMain.on('save', (event, arg) => {
const fileDataPath = (arg.fileData && arg.fileData.path) || ''
const window = BrowserWindow.getFocusedWindow()
const options = {
title: 'Save markdown file',
filters: [{
name: 'MyFile',
extensions: ['md']
}]
}
// 本地有该文件就直接保存
if (fileDataPath) {
fs.exists(fileDataPath, (res) => {
if (res) {
fs.writeFileSync(fileDataPath, arg.editor)
} else {
// 文件打开后可能会被删除,删除后就走保存的逻辑
dialog.showSaveDialog(window, options).then(res => {
const { filePath } = res
if (filePath) {
// console.log(`Saving content to the file: ${filePath}`)
fs.writeFileSync(filePath, arg.editor)
}
})
}
})
return false
}
dialog.showSaveDialog(window, options).then(res => {
const { filePath } = res
if (filePath) {
// console.log(`Saving content to the file: ${filePath}`)
fs.writeFileSync(filePath, arg.editor)
}
})
})
- 注意这里的保存操作,页面定义了'save'事件,主进程也定义了'save'事件,
所以src/preload.js里两个validChannels数组里都要添加'save'
菜单和快捷键-CommandOrControl+O打开本地文件思路
- src/menu.js中添加File菜单,定义Open File子菜单
- accelerator定义快捷键
- click回调方法中,dialog.showOpenDialog选中文件后会返回文件的路径,
通过fs.readFileSync获取到文件的内容,调用主进程定义的'load'事件webContents.send('load', content)
把内容传递给页面(Renderer进程)
{
label: 'File',
submenu: [
{
label: 'Open File',
accelerator: platform === 'darwin' ? 'Cmd+O' : 'Ctr+O',
click: () => {
const window = BrowserWindow.getFocusedWindow()
const options = {
title: 'Pick a markdown file',
filters: [{
name: 'Markdown files',
extensions: ['md']
}, {
name: 'Text files',
extensions: ['txt']
}]
}
dialog.showOpenDialog(window, options).then((res) => {
const { filePaths } = res
if (filePaths && filePaths.length > 0) {
// console.log(`Saving content to the file: ${filePath}`)
const content = fs.readFileSync(filePaths[0]).toString()
window.webContents.send('load', content)
}
})
}
},
...
]
},
- HomeView.vue中进行'load'事件的监听,把传递过来的参数赋值给编辑器
window.ipcRenderer && window.ipcRenderer.receive('load', (arg) => {
// 赋值编辑器要显示的内容
editorValue.value = arg
})
文件拖拽到编辑器内打开
- app.vue根结点定义drop
<template>
<div @drop.prevent="dropHandler" @dragover.prevent>
...
</div>
</template>
- app.vue script定义dropHandler,dropHandler的参数中能拿到文件的路径,FileReader能读取到打开的文件的内容,分别都保存到store的openedFile、editorText状态中
文件的路径是保存的时候会用到
function dropHandler (event) {
event.preventDefault()
if (event.dataTransfer.items) {
if (event.dataTransfer.items[0].kind === 'file') {
// file是一个File对象,直接传给足进程会变成空对象,所以要转换为一般的对象,用fileData存一下
const file = event.dataTransfer.items[0].getAsFile()
const fileData = {
path: file.path,
name: file.name,
type: file.type,
size: file.size,
webkitRelativePath: file.webkitRelativePath,
lastModified: file.lastModified
}
store.changeOpenedFile(fileData)
// console.log(file)
if (file.type === 'text/markdown') {
var reader = new FileReader()
reader.onload = e => {
const content = e.target.result
store.changeEditorText(content)
}
reader.readAsText(file)
}
}
}
}
- HomeView.vue中监听editorText,把传递过来的参数赋值给编辑器
watch(editorText, (val) => {
if (val) {
editorValue.value = editorText.value
}
})
页面下载文件,应用图标展示进度条(仅在开发环境下看效果,打包后会报错就未做处理了,这里就是给个思路)
- 页面中定义好方法并调用,通过axios的onDownloadProgress把进度值传给主进程
window.ipcRenderer.send('download-progress', progressEvent.progress)
function downloadFile () {
axios({
method: 'GET',
// url: '/static/fiddle.zip',
url: '/static/template.xlsx',
responseType: 'blob',
onDownloadProgress: function (progressEvent) {
// 对原生进度事件的处理
window.ipcRenderer.send('download-progress', progressEvent.progress)
},
}).then(({data}) => {
var fileName = 'template.xlsx'
// var fileName = 'fiddle.zip'
fileSaver.saveAs(data, fileName)
setTimeout(() => {
// 让进度条消失
window.ipcRenderer.send('download-progress', -1)
}, 5000)
})
}
- 主进程监听'download-progress'事件,调用系统事件
// 控制展示进度条
ipcMain.on('download-progress', (event, arg) => {
mainWindow.setProgressBar(arg)
})
打包
引入electron-builder,npm install --save-dev electron-builder
package.json 的 scripts 里增加:
...
"scripts": {
...
"electron_lin": "vite build && electron-builder -l",
"electron_win": "vite build && electron-builder -w",
"electron_mac": "vite build && electron-builder -m"
}
...
默认打包是打包到根目录dist文件夹下,在根目录新增electron-builder.json文件,自行设置打包位置为builder文件夹,files根据自己的文件路径按需要填进去
{
"files": ["./vue-dist", "./src/background.js", "./src/menu.js", "./src/preload.js", "package.json"],
"directories": {
"output": "builder" // 设置出口文件
}
}
- 打包mac平台运行
npm run electron_mac
- 打包windows平台运行
npm run electron_win
- 打包Linux平台运行
npm run electron_lin
控制台出现downloading后,拿其后面的地址去下载,下载好后放到下面的地址中就可以打包了
各平台目录地址
Linux: $XDG_CACHE_HOME or ~/.cache/electron/
MacOS: ~/Library/Caches/electron/
Windows: %LOCALAPPDATA%/electron/Cache or ~/AppData/Local/electron/Cache/
wine-4.0.1-mac.7z该文件需要解压放到下面目录
MacOS: ~/Library/Caches/electron-builder/wine/
Linux: ~/.cache/electron-builder/wine/
windows: %LOCALAPPDATA%\electron-builder\cache\wine
nsis-resources-3.4.1.7z该文件需要解压放到下面目录,形如 /electron-builder/nsis/nsis-resources-3.4.1/
MacOS: ~/Library/Caches/electron-builder/nsis/
Linux: ~/.cache/electron-builder/nsis/
windows: %LOCALAPPDATA%\electron-builder\cache\nsis\
遇到的问题
vue3 页面引入ipcRenderer.send会报错
Uncaught ReferenceError: __dirname is not defined
at node_modules/electron/index.js (electron.js?v=b0246f31:36:30)
at __require (chunk-DZZM6G22.js?v=b0246f31:9:50)
at electron.js?v=b0246f31:54:16
解决方案: 不要在.vue页面直接引入ipcRenderer,通过在preload.js中暴露ipcRenderer到window上,页面内通过window.ipcRenderer发送方法