使用 Electron 编写一个简单的本地 Markdown 编辑器
目前有很多免费开源的 Markdown 编辑器,这些编辑器很多也都是使用 Electron 编写。
Electron 因为是使用 HTML + CSS + JavaScript 来开发桌面程序,所以有很多的开源 Markdown 解析库和编辑器可以用,CSS 编写样式也比较方便。
如果你希望快速完成,可以直接使用开源的前端编辑器,只需要编写少量的样式,其它就是文件操作和菜单。如果你想要自己编写样式,也可以使用 JS 的 Markdown 解析库把 Markdown 转换为 HTML,然后编写 CSS 样式。
我这里会直接使用开源的编辑器,使用的编辑器是 TOAST UI Editor 。
这篇文章编写的 Markdown 编辑器可以在 https://github.com/changbin1997/MNote 查看,在 Releases 中有打包完成的版本。
项目初始化
创建一个项目目录,进入项目目录,输入:
npm init -y初始化项目。
安装 electron 和 electron-builder:
npm install electron electron-builder --save-dev打开项目目录下的 package.json,在 scripts 中加入三条命令:
{
"scripts": {
"start": "electron .",
"pack": "electron-builder --dir",
"dist": "electron-builder"
}
}start 用来启动正在开发的 Electron 程序,pack 把程序打包为目录,dist 把程序打包为安装包。
可以在项目目录下创建一个 renderer 目录,把前端渲染进程的 HTML CSS JS 放到 renderer 目录。
完成基本的窗口
在项目根目录创建一个 index.js 文件,在 index.js 初始化窗口:
const {app, BrowserWindow} = require('electron');
const path = require('path');
let mainWindow = null;
app.on('ready', async () => {
// 创建窗口
mainWindow = new BrowserWindow({
width: 800,
height: 500,
webPreferences: {
webSecurity: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
});
// 加载html
await mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
// 窗口关闭
mainWindow.on('close', () => {
mainWindow = null;
});
});上面通过 mainWindow.loadFile 加载了项目目录中 renderer 目录里的 index.html 。
现在的 Electron 已经无法在前端渲染进程直接使用 Node 和 Electron API,只能通过 preload 把要使用的 API 暴露给渲染进程,上面创建窗口时配置的 preload 是 path.join(__dirname, 'preload.js') ,也就是入口文件目录下的 preload.js 。
下面在项目目录下创建一个 preload.js ,把 ipc 通信相关的 API 暴露给渲染进程:
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
onResponse: (channel, listener) => {
ipcRenderer.on(channel, listener);
},
'ipc-invoke': (channel, listener) => {
ipcRenderer.invoke(channel, listener);
}
});上面暴露了两个 API,·onResponse· 用来监听主进程发来的 ipc 请求,ipc-invoke 用来主动向主进程发送 ipc 请求。
renderer 目录主要用来存放前端渲染进程的文件,在 renderer 目录下创建一个 index.html 文件,引入 TOAST UI Editor 的 CSS 和 JS。
CSS:
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" type="text/css">通过 script 引入 JS:
<script type="text/javascript" src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<script type="text/javascript" src="https://uicdn.toast.com/editor/latest/i18n/zh-cn.js"></script>可以直接把 CSS 和 JS 文件下载到本地引入,不建议直接使用在线地址引入,其中的 zh-cn.js 是中文语言。
在 HTML 的 body 中放一个 div 作为编辑器容器:
<div id="editor"></div>给程序加入基本的 CSS 样式:
html, body {
width: 100%;
height: 100%;
overflow: hidden;
padding: 0;
margin: 0;
}
#editor {
width: 100vw;
height: 100vh;
box-sizing: border-box;
}上面的样式可以让编辑器的尺寸和窗口的尺寸保持一致,窗口缩放编辑器也会缩放。
下面来初始化前端的编辑器,创建一个 JS 文件在 index.html 中引入,初始化编辑器:
const editorContainer = document.querySelector('#editor');
const Editor = toastui.Editor;
// 创建编辑器实例
const editor = new Editor({
el: editorContainer,
height: '100%',
initialEditType: 'markdown',
previewStyle: 'vertical',
useCommandShortcut: false,
language: 'zh-CN'
});现在已经完成了基本的窗口和外观样式,可以在项目目录打开命令行,输入:
npm run start如果代码没有错误的话,已经可以看到一个 Markdown 编辑器了。

编写菜单栏
这里在菜单栏添加两个菜单,一个是文件操作,一个是编辑。
关于菜单栏的详细说明可以看我之前写的 Electron 编写菜单栏 。
为了方便后期维护,可以把菜单栏放到单独的文件,下面在项目目录的 app 目录创建一个 menu-bar.js ,在这个文件里编写菜单栏:
const { Menu } = require('electron');
module.exports = (mainWindow) => {
// 菜单模板
const menuTemplate = [
{
label: '文件',
submenu: [
{
label: '打开',
click() {
// 打开文件点击
},
accelerator: 'ctrl+o'
},
{
label: '保存',
click() {
// 保存文件点击
},
accelerator: 'ctrl+s'
},
{
label: '另存为',
click() {
// 另存为点击
},
accelerator: 'ctrl+shift+s'
},
{
label: '退出',
click() {
// 退出点击,直接退出
mainWindow.close();
}
}
]
},
{
label: '编辑',
submenu: [
{
label: '全选',
role: 'selectAll'
},
{
label: '剪切',
role: 'cut'
},
{
label: '复制',
role: 'copy'
},
{
label: '粘贴',
role: 'paste'
}
]
}
];
const menuBar = Menu.buildFromTemplate(menuTemplate);
Menu.setApplicationMenu(menuBar);
};菜单项的 accelerator: 'ctrl+o' 之类的就是快捷键,可以直接通过快捷键来实现点击菜单,这也是大多数文件操作菜单的快捷键。
我的这个菜单栏函数还会接收一个 mainWindow ,点击退出就通过 mainWindow.close() 来退出。
这个 menu-bar.js 菜单模块需要在入口文件 index.js 引入,然后在窗口创建完成后调用。
下面在 index.js 引入调用:
const {app, BrowserWindow} = require('electron');
const path = require('path');
const menuBar = require('./app/menu-bar');
let mainWindow = null;
app.on('ready', async () => {
// 创建窗口
mainWindow = new BrowserWindow({
width: 800,
height: 500,
webPreferences: {
webSecurity: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
});
// 加载html
await mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
// 菜单栏初始化
menuBar(mainWindow);
// 窗口关闭
mainWindow.on('close', () => {
mainWindow = null;
});
});我上面的菜单栏模块需要接收一个 mainWindow 用于退出,这里调用 menuBar 初始化菜单的时候就传入了 mainWindow 主窗口。
现在可以输入:
npm run start启动程序来查看菜单栏。
菜单栏实现效果如下:

文件操作
可以在项目目录下的 app 目录中创建一个 File.js ,在这个 File.js 中创建一个 File 文件操作类,把文件操作相关的方法放到这个类中,方便管理和维护。
下面是 File 类,包含打开文件、保存文件、另存为文件:
const fs = require('fs');
const path = require('path');
const { dialog } = require('electron');
module.exports = class File {
mainWindow = null; // 主窗口
openFilePath = ''; // 当前打开的文件路径
// 打开文件
openFile() {
// 显示打开文件对话框
const filePath = dialog.showOpenDialogSync(this.mainWindow, {
title: '打开 Markdown 文件',
properties: ['openFile'],
filters: [
{ name: 'Markdown 文件', extensions: ['md', 'markdown'] },
{ name: '所有文件', extensions: ['*'] }
]
});
// 未选择文件
if (filePath === undefined || filePath.length < 1) return false;
// 读取文件
fs.readFile(filePath[0], 'utf-8', (error, content) => {
if (error) {
// 读取文件出错就显示一个提示对话框
dialog.showMessageBoxSync(this.mainWindow, {
title: '读取文件出错',
message: `无法读取 ${filePath[0]}`,
detail: error.message,
buttons: ['关闭'],
defaultId: 0,
noLink: true
});
return false;
}
// 把文件传给渲染进程
this.mainWindow.webContents.send('open-file', content);
// 设置当前打开的文件名
this.openFilePath = filePath[0];
});
}
// 另存为文件
saveAs(content) {
// 显示保存文件对话框
const fileName = dialog.showSaveDialogSync(this.mainWindow, {
title: '另存为',
buttonLabel: '保存',
defaultPath: path.join(process.cwd(), 'markdown-file.md')
});
if (fileName === undefined) return false;
fs.writeFile(fileName, content, 'utf-8', (error) => {
if (error) {
dialog.showMessageBoxSync(this.mainWindow, {
title: '保存文件出错',
message: `无法保存文件到 ${fileName}`,
detail: error.message,
buttons: ['关闭'],
defaultId: 0,
noLink: true
});
return false;
}
// 把打开的文件名设置为另存为的文件名
this.openFilePath = fileName;
});
}
// 保存文件
save(content) {
// 如果还没有打开过文件或文件不存在就直接调用另存为
if (this.openFilePath === '' || !fs.existsSync(this.openFilePath)) {
this.saveAs(content);
return false;
}
// 把内容保存到当前打开的文件
fs.writeFile(this.openFilePath, content, 'utf-8', (error) => {
if (error) {
dialog.showMessageBoxSync(this.mainWindow, {
title: '保存文件出错',
message: `无法保存文件到 ${this.openFilePath}`,
detail: error.message,
buttons: ['关闭'],
defaultId: 0,
noLink: true
});
return false;
}
});
}
};这个 File 类有两个属性,mainWindow 用来存储主窗口,发送内容到渲染进程需要用到 mainWindow,在主窗口内显示对话框也需要传入 mainWindow。openFilePath 用来存储打开的文件路径,保存文件的时候可以判断 openFilePath 是否有文件路径,如果有文件路径可以直接写入文件保存,如果没有文件路径可以弹出对话框另存为。
下面是详细的方法说明:
openFile 打开文件
这个方法用来打开文件,不需要接收参数。
- 通过
dialog.showOpenDialogSync显示一个文件选择对话框,传入的this.mainWindow可以让对话框在主窗口内显示,filters内的对象数组可以设置文件选择对话框内要显示的文件类型,我这里只显示.md后缀的 Markdown 文件。 - 如果取消了文件选择对话框就直接返回。
- 选择完文件就通过
fs.readFile读取文件,filePath[0]就是文件选择对话框返回的文件路径,utf-8是直接通过 UTF-8 打开。 - 如果打开文件出错就显示一个错误提示,然后直接返回
- 成功读取文件就通过
mainWindow.webContents.send把文件内容发送到渲染进程。 - 把文件路径传给
File的openFilePath属性,保存文件的时候可以直接通过这个路径保存。
我这里打开文件是直接通过 UTF-8 读取,如果文件不是 UTF-8,读取的文件可能是乱码。如果要读取一些不常用的编码,比如 GBK 之类的,打开文件的时候不需要传入第二个编码,直接把文件读取为 Buffer,然后通过一些相关的编码处理库来读取文件。
saveAs 另存为文件
这个方法用来另存为文件,需要接收文件内容,无论有没有打开文件,另存为都会弹出文件保存对话框。
- 通过
dialog.showSaveDialogSync显示文件保存对话框,defaultPath可以设置一个默认的路径和文件名,这里设置的默认文件路径path.join(process.cwd(), 'markdown-file.md')就是程序目录,默认的文件名是markdown-file.md。 - 如果文件保存对话框返回
undefined,也就是取消了对话框,就直接返回。 - 通过
fs.writeFile写入文件,第一个参数文件路径就使用文件保存对话框返回的路径,第二个参数content就是写入的内容,utf-8就是通过 UTF-8 编码写入。 - 写入文件出错就显示一个对话框提示。
- 成功写入文件就把保存文件对话框返回的文件路径传给
File的openFilePath属性,保存文件可以直接使用这个路径保存。
Node 的 fs.writeFile 如果传入的文件路径不存在就会创建文件,如果文件存在就会直接写入。
save 保存文件
这个方法用来保存文件,需要接收 content 文件内容,保存文件不会弹出文件保存对话框,除非是没有打开文件的情况下。
- 判断
File的openFilePath是否包含文件路径,如果没有文件路径或文件不存在,说明是没有打开文件,直接调用saveAs方法来另存为文件,然后直接返回。 - 如果已打开文件就还是使用
fs.writeFile写入文件,传入的文件路径就是File的openFilePath,也就是当前打开的文件。 - 写入文件出错就显示一个对话框提示。
上面就是 File 文件操作类的三个主要的方法。
调整文件菜单
上面已经在 menu-bar.js 中编写了菜单栏,但是还没有给文件操作菜单加入功能,下面就来添加菜单功能。
上面的菜单函数只接收了一个 mainWindow,这里再添加一个 file ,这个 file 就是文件操作模块,菜单项可以直接调用文件操作模块。
下面在主进程 index.js 入口文件引入上面的 File.js 文件操作模块,然后在菜单初始化时把 File 传给菜单:
const {app, BrowserWindow} = require('electron');
const path = require('path');
const menuBar = require('./app/menu-bar');
const File = require('./app/File');
// 文件操作
const file = new File();
let mainWindow = null;
app.on('ready', async () => {
// 创建窗口...
// 加载html
await mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
// 把主窗口传给文件操作模块,用于在窗口内显示对话框和 ipc 通信
file.mainWindow = mainWindow;
// 菜单栏初始化,把 file 传给菜单模块
menuBar(mainWindow, file);
// 窗口关闭
mainWindow.on('close', () => {
mainWindow = null;
});
});File 文件操作类有一个属性是 mainWindow ,这个属性用来存储主窗口,显示对话框和发送 ipc 会用到,index.js 入口创建完窗口后也需要把 mainWindow 传给 File 的 mainWindow 属性。
下面给菜单添加功能:
const { Menu } = require('electron');
module.exports = (mainWindow, file) => {
// 菜单模板
const menuTemplate = [
{
label: '文件',
submenu: [
{
label: '打开',
click() {
// 调用 File 文件操作类的 open 方法
file.openFile();
},
accelerator: 'ctrl+o'
},
{
label: '保存',
click() {
// 通过 ipc 从前端渲染进程请求获取 markdown 内容
mainWindow.webContents.send('get-markdown', 'save');
},
accelerator: 'ctrl+s'
},
{
label: '另存为',
click() {
// 通过 ipc 从前端渲染进程请求获取 markdown 内容
mainWindow.webContents.send('get-markdown', 'saveAs');
},
accelerator: 'ctrl+shift+s'
},
{
label: '退出',
click() {
mainWindow.close();
}
}
]
}
];
const menuBar = Menu.buildFromTemplate(menuTemplate);
Menu.setApplicationMenu(menuBar);
};因为前端渲染进程和主进程的代码是分离的,只能通过 ipc 通信,主进程菜单点击保存和另存为时,需要通过 mainWindow.webContents.send 从渲染进程请求 Markdown 用于保存。
这里请求 Markdown 内容时发送的 save 和 saveAs 用于区分保存和另存为,渲染进程发送 Markdown 到主进程时,也会带上接收到的 save 或 saveAs 。
处理前端渲染进程的 ipc
主进程读取 Markdown 文件后需要发送到前端渲染进程显示,主进程保存文件也需要从前端渲染进程获取 Markdown 内容。
显示 Markdown 内容
上面的 File 类成功读取文件后会通过 mainWindow.webContents.send('open-file', content) 把 Markdown 内容传给渲染进程。
下面在前端渲染进程处理和显示 Markdown 内容:
// 监听主进程发来的 markdown 内容
window.electronAPI.onResponse('open-file', (ev, args) => {
// 在编辑器显示 markdown
editor.setMarkdown(args);
});这里的 editor 就是上面使用 new Editor 初始化编辑器返回的编辑器对象。
保存 Markdown 内容
上面菜单选择保存或另存为时,会通过 mainWindow.webContents.send('get-markdown', 'save') 向渲染进程发送 ipc 请求来获取 Markdown 内容。
下面监听主进程发送的 ipc 请求,然后把 Markdown 内容通过 ipc 发送给主进程:
// 监听主进程发送的请求获取 Markdown 内容,用于保存文件
window.electronAPI.onResponse('get-markdown', (ev, args) => {
// 获取编辑器中的 markdown 内容
const markdownContent = editor.getMarkdown();
// 把 Markdown 内容和接收到的执行功能一起发送到主进程
window.electronAPI['ipc-invoke']('markdown-content', {
content: markdownContent,
exec: args
});
});前端在接受到请求后会通过 `editor.getMarkdown() 获取编辑器中的 Markdown 内容,然后通过 window.electronAPI['ipc-invoke']` 把 Markdown 内容和执行的功能发送到主进程。
前端渲染进程发送的是一个对象,content 就是 Markdown 内容,exec 是主进程菜单传过来的 save 或 saveAs 字符串,用来区分是保存还是另存为。
主进程菜单的保存和另存为点击后只会向渲染进程发送一个获取 Markdown 内容的请求,并不会立即保存,主进程还需要监听前端渲染进程发来的 Markdown 内容。
主进程接受到 Markdown 内容后就需要根据前端传过来的 save 和 saveAs 来选择 File 的保存或另存为。也就是说这个 save 和 saveAs 会从主进程菜单传到渲染进程,然后再从渲染进程传到主进程。
处理主进程的 ipc
下面在 index.js 入口文件处理主进程的 ipc:
// 监听前端传过来的 Markdown 内容,用于保存文件
ipcMain.handle('markdown-content', (ev, args) => {
// 调用保存或另存为
file[args.exec[0]](args.content);
});File 文件操作类有 saveAs 和 save 方法,用来另存为和保存文件。前端渲染进程船过来的 exec 就是用来区分选择保存或另存为,content 就是 Markdown 内容。
一些可选的功能
上面已经实现了一个简易的本地 Markdown 编辑器,包含读取文件和保存文件,还有基本的 Markdown 编辑和预览。
但是一个完整的 Markdown 编辑器肯定不只上面的功能,下面是一些可选的功能,因为内容较多,这里就不贴代码了,可以看我开发完成的编辑器 https://github.com/changbin1997/MNote 。
退出时询问和保存
在没有打开文件的情况下直接编写内容,如果内容没有保存,在退出的时候可以显示一个对话框询问是否保存,如果选择保存就弹出另存为,保存完成后退出。
如果打开的文件内容被修改,在退出时如果没有保存也可以弹出对话框询问保存,如果选择保存就把内容直接保存到当前打开的文件,然后退出。
打开文件时询问和保存
在没有打开文件的情况下选择打开文件,如果检测到编辑器里已经有内容就询问保存,选择保存后弹出另存为,保存完成后打开新文件。
打开新文件时,如果之前打开的文件被修改,但没有保存就弹框询问,选择保存就把内容直接保存到当前打开的文件,保存完成后打开新文件。
上下文菜单
Electron 的文本编辑区域不会和浏览器一样自带上下文菜单,虽然大多数人可能会使用键盘来实现 全选、剪切、复制、粘贴,但是还是会有人使用上下文菜单来完成这些操作。可以给 Markdown 编辑器区域加入一个上下文菜单,关于上下文菜单可以看 Electron 右键上下文菜单 。
关联文件启动
可以加入对 .md 文件的关联,在资源管理器打开 .md 文件就可以直接启动程序,然后读取文件显示。
关于关联文件可以看 Electron 在 Windows 关联文件启动 。
拖放打开文件
可以在前端的 Markdown 编辑器区域加入拖放事件,文件拖入后把文件路径发送到主进程读取文件显示。
打包
打开项目目录下的 package.json ,在 build 中配置打包信息:
{
"build": {
"appId": "markdown-edit",
"productName": "Markdown编辑器",
"icon": "renderer/favicon.ico",
"copyright": "Copyright © 2025 changbin",
"compression": "maximum",
"asar": true,
"win": {
"icon": "assets/logo.ico",
"target": "nsis",
"legalTrademarks": "changbin"
},
"nsis": {
"oneClick": false,
"perMachine": true,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": false
},
"directories": {
"output": "release"
},
"files": [
"index.js",
"preload.js",
"app/**/*",
"assets/**/*",
"renderer/**/*",
"package.json",
"!**/node_modules/*",
"!node_modules/**/node_modules/*",
"!package-lock.json",
"!screenshots/**/*",
"!**/.*",
"!**/*.map",
"!**/test?(s)/**/*",
"!**/*.{md,txt,log,o,hprof,orig,pyc,pyo,rbc,pdb,ilk,bak}",
"!**/._*",
"!README.md",
"!LICENSE"
]
}
}下面是一些配置说明:
appId: 程序IDproductName: 程序名称icon: 程序图标copyright: 版权信息compression: 打包的压缩级别win: Windows 相关的打包配置win.icon: Windows 上的程序图标win.legalTrademarks: 程序作者信息nsis: 安装包相关的配置nsis.oneClick: 是否使用一键安装nsis.perMachine: 这台电脑的所有用户都能使用nsis.allowToChangeInstallationDirectory: 允许更改安装目录nsis.createDesktopShortcut: 创建桌面快捷方式nsis.createStartMenuShortcut: 创建开始菜单快捷方式directories.output: 打包后的软件存放位置files: 配置需要打包的文件和需要过滤的文件
配置完成后在项目目录输入:
npm run dist可以把程序打包为安装包。
输入:
npm run pack可以把程序打包为一个目录,目录中有可以直接运行的 .exe 程序。
关联文件启动需要打包为安装包。
完整的 Markdown 编辑器可以看 https://github.com/changbin1997/MNote ,上面没有详细写的一些功能,在这个编辑器都有实现。
版权声明:本文为原创文章,版权归 Changbin's Blog 所有,转载请联系博主获得授权。
本文地址:https://www.misterma.com/archives/958/
如果对本文有什么问题或疑问都可以在评论区留言,我看到后会尽量解答。