使用vue3 + pina + mavon-editor + electron開發(fā)的桌面應用
如果不想看下面的簡介,可以直接下載項目運行看效果
項目地址: markdown-electron-vue - 公開倉庫 (coding.net)
應用截圖:

運行環(huán)境:
node 20.14.0
npm 10.7.0
已完成功能列表
- 集成應用程序菜單——左上角菜單
- 菜單與頁面通信
- 頁面與菜單通信
- 菜單和快捷鍵-CommandOrControl+S保存文件到本地
- 菜單和快捷鍵-CommandOrControl+O打開本地文件
- 文件拖拽到編輯器內(nèi)打開,并可以保存到原來的文件中
- renderer自己下載,應用圖標展示進度條
- electron 打包,路由要使用hash模式,不然打包出來是白屏的
scripts
npm install
運行vue項目開發(fā)環(huán)境代碼
npm run dev
運行到electron開發(fā)環(huán)境
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引入菜單,并設(shè)置菜單
import { ..., Menu } from 'electron'
import { myMenu } from './menu.js'
...
Menu.setApplicationMenu(myMenu)
app.whenReady().then(() => {
...
})
菜單與頁面通信思路
- 在src/preload.js全局注冊ipcRenderer,用ipcRenderer.on監(jiān)聽菜單出發(fā)的事件
src/preload.js核心代碼如下, 全局注冊ipcRenderer
這里send的validChannels定義的是頁面調(diào)用主進程的事件
receive的validChannelsd定義的是主進程調(diào)用頁面的事件
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中發(fā)出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監(jiān)聽的事件,調(diào)用頁面方法
window.ipcRenderer && window.ipcRenderer.receive('insert-text', (arg) => {
document.getElementById('communicationText').innerHTML = arg
setTimeout(() => {
document.getElementById('communicationText').innerHTML = ''
}, 5000)
})
頁面與主進程通信思路
- 在src/preload.js全局注冊ipcRenderer,上面菜單與頁面通信思路中第1點有介紹
- 在頁面中調(diào)用通信的方法
function sendMessage () {
window.ipcRenderer.send('set-title', `你好`)
}
- 在主進程src/background.js用ipcMain監(jiān)聽頁面發(fā)送的方法,調(diào)用系統(tǒng)方法
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回調(diào)方法中調(diào)用
webContents.send('save')發(fā)送消息給頁面
{
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'事件,調(diào)用頁面定義的send事件
ipcRenderer.send('save', {editor: editorValue.value, fileData})把數(shù)據(jù)傳給主進程
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主進程中監(jiān)聽頁面發(fā)送的save事件,然后做保存
如果傳遞過來的數(shù)據(jù)有文件路徑,就保存到該路徑的文件內(nèi),否則調(diào)用選擇文件路徑的彈窗,保存為新文件
// 保存到本地
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數(shù)組里都要添加'save'
菜單和快捷鍵-CommandOrControl+O打開本地文件思路
- src/menu.js中添加File菜單,定義Open File子菜單
- accelerator定義快捷鍵
- click回調(diào)方法中,dialog.showOpenDialog選中文件后會返回文件的路徑,
通過fs.readFileSync獲取到文件的內(nèi)容,調(diào)用主進程定義的'load'事件webContents.send('load', content)把內(nèi)容傳遞給頁面(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'事件的監(jiān)聽,把傳遞過來的參數(shù)賦值給編輯器
window.ipcRenderer && window.ipcRenderer.receive('load', (arg) => {
// 賦值編輯器要顯示的內(nèi)容
editorValue.value = arg
})
文件拖拽到編輯器內(nèi)打開
- app.vue根結(jié)點定義drop
<template>
<div @drop.prevent="dropHandler" @dragover.prevent>
...
</div>
</template>
- app.vue script定義dropHandler,dropHandler的參數(shù)中能拿到文件的路徑,F(xiàn)ileReader能讀取到打開的文件的內(nèi)容,分別都保存到store的openedFile、editorText狀態(tài)中
文件的路徑是保存的時候會用到
function dropHandler (event) {
event.preventDefault()
if (event.dataTransfer.items) {
if (event.dataTransfer.items[0].kind === 'file') {
// file是一個File對象,直接傳給足進程會變成空對象,所以要轉(zhuǎn)換為一般的對象,用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中監(jiān)聽editorText,把傳遞過來的參數(shù)賦值給編輯器
watch(editorText, (val) => {
if (val) {
editorValue.value = editorText.value
}
})
頁面下載文件,應用圖標展示進度條(僅在開發(fā)環(huán)境下看效果,打包后會報錯就未做處理了,這里就是給個思路)
- 頁面中定義好方法并調(diào)用,通過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)
})
}
- 主進程監(jiān)聽'download-progress'事件,調(diào)用系統(tǒng)事件
// 控制展示進度條
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文件,自行設(shè)置打包位置為builder文件夾,files根據(jù)自己的文件路徑按需要填進去
{
"files": ["./vue-dist", "./src/background.js", "./src/menu.js", "./src/preload.js", "package.json"],
"directories": {
"output": "builder" // 設(shè)置出口文件
}
}
- 打包mac平臺運行
npm run electron_mac - 打包windows平臺運行
npm run electron_win - 打包Linux平臺運行
npm run electron_lin
控制臺出現(xiàn)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上,頁面內(nèi)通過window.ipcRenderer發(fā)送方法