最近在做文档工具链整理的时候,我发现自己一直缺一个足够“轻”的 Markdown 编辑器:启动要快,预览要即时,文件要保存在本地,还得免费、清爽。市面上的编辑器各有各的优势,但它们要么偏笔记库、要么偏 IDE、要么需要付费解锁完整功能。于是我把之前零散的编辑器原型重新整理,做了一个开源免费的 Markdown 编辑器,取名为 Nomo。
这篇文章就以 Nomo 为例,从需求分析、技术选型、环境搭建,到核心功能实现、打包发布,完整拆解一个 Markdown 编辑器的开发过程。文章中有大量可直接复用的代码,适合想了解 Electron 与 Markdown 渲染链路、想自己动手做工具软件的同学。读完你不仅能理解 Markdown 编辑器的工作原理,还能照着搭建出一个可运行的桌面版 Markdown 编辑器。
1. 背景:Markdown 编辑器到底解决了什么问题
1.1 Markdown 是什么
Markdown 是一种轻量级标记语言,用纯文本的写法表达结构。比如用 # 表示标题,用 ** 表示加粗,用 - 表示列表。写完一份 Markdown 文档后,通过解析器把它转换成结构化的 HTML,再配合样式,就能渲染成排版清晰的文档。
它的核心优势有三点:
- 纯文本存储,不依赖特定软件,任何编辑器都能打开。
- 版本管理友好,Git diff 能看到真正的改动内容。
- 迁移成本低,从一个 Markdown 工具换到另一个,文件本身不需要改动。
正因如此,Markdown 成了 README、技术文档、博客、笔记、项目 Wiki 的事实标准。很多开发者日常的工作流都离不开 Markdown,这也让编辑器的体验变得非常关键。
1.2 现有编辑器有什么问题
最常用的几类方案,用起来其实都有取舍:
- 在线编辑器:打开浏览器就能用,免配置,但文档放在别人服务器上,隐私和可用性都受制于人。
- IDE 插件:功能强,但为了 Markdown 启动一个完整的 IDE,对轻量写作场景来说太重了。
- 商业软件:体验好,但部分功能需要付费,或者默认绑定云同步,不符合每个人对“本地优先”的定义。
- 笔记库型工具:把 Markdown 文件管理得很完整,但有自己的数据格式和同步体系,容易让人产生“被绑住”的感觉。
于是 Nomo 的定位就很明确了:一个本地优先、免费开源、界面克制、双栏实时预览的桌面 Markdown 编辑器。它不追求大而全,而是把写作最核心的体验做好。
1.3 Nomo 的核心设计目标
设计 Nomo 时,我给自己定了四个目标:
- 本地文件直接读写,不引入私有格式。
- 编辑区和预览区实时联动,响应速度要快。
- 支持代码高亮、数学公式、表格等常见技术写作能力。
- 整个过程离线可用,开源免费,用户可以查看和修改每一行代码。
后面的所有技术选型,其实都是围绕这四点展开的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:为什么用 Electron 而不是 Tauri
2.1 客户端框架选型
Nomo 的形态是桌面应用,所以第一件事是选择客户端技术栈。我最终选择了 Electron,主要原因是:
- 用 HTML、CSS、JavaScript 开发界面,和 Web 技术栈完全一致,周边生态丰富。
- Electron 在跨平台方面非常成熟,一套代码可以打包成 Windows、macOS、Linux 三个平台的应用。
- 本地文件读写能力通过 Node.js 提供,处理大文件、编码转换都比纯 Web 方案灵活。
Tauri 是另一个优秀的方案,它的打包体积更小、内存占用更低,但它要求开发者熟悉 Rust,而且系统 WebView 在不同平台上存在一定的行为差异。对于 Markdown 编辑器这种大量依赖 DOM 操作和文本渲染的工具来说,Electron 的开发效率和调试便利性更占优势。
客观地说,Electron 应用的内存占用确实高于原生应用,这在后续开发中需要通过性能优化来弥补,比如按需渲染、防抖函数、减少大对象频繁重建等。
Nomo 的安全基线是这样设置的:contextIsolation: true,nodeIntegration: false,渲染进程不能直接访问 Node.js 能力,所有文件操作通过 preload 脚本暴露的接口完成。这符合 Electron 官方推荐的安全实践,也能防止 Markdown 文档中嵌入的 HTML 恶意访问本地系统。
2.2 编辑器内核选型
编辑器内核是 Markdown 编辑器最关键的部分。我对比了两个主流方案:
- CodeMirror:轻量、可定制性强,专注代码编辑,启动速度快。
- Monaco Editor:功能强大,拥有完整的 IDE 级体验,但体积较大,初始化成本也更高。
对于 Markdown 写作场景,CodeMirror 的轻量特性更合适。写作过程涉及的语法高亮、行号、自动缩进、列表延续等能力,CodeMirror 都能很好地支持。这里需要说明的是,Nomo 主版本兼容 CodeMirror 6 的 API,但为了让本文示例更容易阅读和复现,下面的代码基于 CodeMirror 5 的经典 API 编写,这套 API 更加直观,适合学习编辑器原理。
2.3 Markdown 解析链路
Markdown 文档从文本变成最终预览,需要经过一条完整的解析链路:
text复制Markdown 文本
-> markdown-it 解析为 HTML
-> highlight.js 处理代码块高亮
-> KaTeX 渲染数学公式
-> 注入预览 DOM
选择 markdown-it 的原因,一是解析速度快,二是插件机制灵活。代码高亮使用 highlight.js,数学公式使用 KaTeX。KaTeX 的优势是渲染速度快,支持常见 LaTeX 语法,包括多行公式和分段函数等复杂场景。
2.4 整体架构设计
Nomo 采用经典的三层进程模型:
| 模块 | 职责 |
|---|---|
| 主进程 main.js | 创建窗口、文件对话框、文件读写、导出 PDF |
| 预加载脚本 preload.js | 通过 contextBridge 暴露安全 API 给渲染进程 |
| 渲染进程 renderer/ | 编辑器实例、Markdown 解析、预览渲染、界面交互 |
通过这个模型,界面层和系统层被明确区分开。界面层不感知文件系统的细节,系统层也不关心用户当前输入了什么内容。
3. 环境准备与项目初始化
3.1 开发环境
开发 Nomo 需要的环境如下:
- Node.js 16 或更高版本,建议使用 LTS 版本。
- npm 或 yarn 包管理器。
- 操作系统:Windows / macOS / Linux 均可。
Electron 安装包较大,如果网络下载缓慢,可以配置镜像源加速,例如设置 ELECTRON_MIRROR 环境变量指向国内镜像地址。这不是唯一解决方案,只是工程实践中比较常用的手段。
3.2 初始化项目
先创建项目目录,并初始化 package.json:
bash复制mkdir nomo-editor
cd nomo-editor
npm init -y
然后安装开发依赖和运行时依赖:
bash复制npm install -D electron electron-builder esbuild
npm install codemirror@5 markdown-it highlight.js katex markdown-it-texmath
这里说明一下:codemirror@5 是指定 CodeMirror 5 的大版本。如果你的环境安装时已经有更新的版本,建议优先使用 npm 推荐的最新稳定版,但 API 差异需要以你实际安装的版本为准。
3.3 项目目录结构
Nomo 的目录结构设计如下:
text复制nomo-editor/
├── main.js # Electron 主进程
├── preload.js # 预加载脚本,暴露安全 API
├── package.json
├── renderer/
│ ├── index.html # 编辑器页面
│ ├── app.js # 渲染进程业务代码,由 esbuild 打包
│ └── dist/ # esbuild 打包后的输出目录
└── README.md
renderer/app.js 使用 ES Module 语法编写,通过 esbuild 打包成浏览器环境可执行的脚本。这样可以自由使用 npm 包提供的模块能力,又不需要在渲染进程中开启 nodeIntegration。
4. 最小可用版本:Hello Nomo
我们先做一个最小版本,让 Electron 窗口能正常打开,并显示一个简单的文本编辑区域。这一节理解之后,再逐步加入 Markdown 解析和预览能力。
4.1 配置 package.json 脚本
在 package.json 中配置启动和构建脚本:
json复制{
"name": "nomo-editor",
"version": "0.1.0",
"description": "A free and open source Markdown editor",
"main": "main.js",
"scripts": {
"build:renderer": "esbuild renderer/app.js --bundle --outfile=renderer/dist/app.js",
"start": "npm run build:renderer && electron .",
"dist": "npm run build:renderer && electron-builder"
},
"devDependencies": {
"electron": "^30.0.0",
"electron-builder": "^24.13.3",
"esbuild": "^0.21.0"
},
"dependencies": {
"codemirror": "^5.65.16",
"highlight.js": "^11.9.0",
"katex": "^0.16.9",
"markdown-it": "^13.0.2",
"markdown-it-texmath": "^1.0.0"
}
}
4.2 主进程 main.js
主进程负责创建窗口和监听系统级事件。完整代码如下:
js复制const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
let mainWindow = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 900,
minHeight: 600,
title: 'Nomo Markdown Editor',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
mainWindow.loadFile('renderer/index.html');
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
// macOS 上点击 Dock 图标时,如果没有窗口则重新创建
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
// 打开文件对话框并读取内容
ipcMain.handle('open-file', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
title: '打开 Markdown 文件',
filters: [
{ name: 'Markdown', extensions: ['md', 'markdown', 'txt'] },
{ name: '所有文件', extensions: ['*'] }
],
properties: ['openFile']
});
if (result.canceled || result.filePaths.length === 0) return null;
const filePath = result.filePaths[0];
const content = fs.readFileSync(filePath, 'utf-8');
return { filePath, content };
});
// 保存文件:如果传入 filePath 则直接写入,否则弹出另存为对话框
ipcMain.handle('save-file', async (event, filePath, content) => {
if (!filePath) {
const result = await dialog.showSaveDialog(mainWindow, {
title: '保存 Markdown 文件',
defaultPath: 'untitled.md',
filters: [{ name: 'Markdown', extensions: ['md'] }]
});
if (result.canceled || !result.filePath) return null;
filePath = result.filePath;
}
fs.writeFileSync(filePath, content, 'utf-8');
return filePath;
});
这里有几个细节值得注意:
contextIsolation: true表示渲染进程和预加载脚本之间用隔离上下文通信,避免渲染进程直接获得 Node.js 能力。ipcMain.handle和渲染进程的ipcRenderer.invoke是一一对应的异步调用方式。- 文件读取使用
utf-8编码,这是 Markdown 文件最常见的编码格式。
4.3 预加载脚本 preload.js
渲染进程不能直接使用 require,所以需要通过 contextBridge 暴露文件操作接口:
js复制const { contextBridge, ipcRenderer } = require('electron');
const path = require('path');
const { pathToFileURL } = require('url');
contextBridge.exposeInMainWorld('nomoAPI', {
openFile: () => ipcRenderer.invoke('open-file'),
saveFile: (filePath, content) => ipcRenderer.invoke('save-file', filePath, content),
saveFileAs: (content) => ipcRenderer.invoke('save-file', null, content),
dirname: (filePath) => path.dirname(filePath),
fileUrl: (dir, name) => pathToFileURL(path.join(dir, name)).href
});
fileUrl 的作用是把本地图片路径转换为标准 file URL,供预览区域的 <img> 标签使用。这样能避免手工拼接路径时出现盘符、空格、中文编码等问题。
4.4 页面与渲染进程代码
renderer/index.html 是编辑器的主页面,这一版先做一个最简结构,后续功能都围绕它扩展:
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Nomo Markdown Editor</title>
</head>
<body>
<div id="toolbar">
<button id="openBtn">打开</button>
<button id="saveBtn">保存</button>
<span id="fileTitle">未命名文件</span>
</div>
<div id="container">
<div id="source-wrap">
<textarea id="source"></textarea>
</div>
<div id="preview-wrap">
<div id="preview"></div>
</div>
</div>
<script src="./dist/app.js"></script>
</body>
</html>
运行 npm start,Electron 窗口会正常打开。此时页面还是一个普通文本框,我们接下来逐步加入编辑器、预览、文件读写和 Markdown 渲染能力。
5. 核心功能实现
5.1 编辑器与预览联动
现在把 renderer/app.js 补充完整。首先引入依赖,并初始化 CodeMirror 和 markdown-it:
js复制import CodeMirror from 'codemirror';
import 'codemirror/mode/markdown/markdown.js';
import 'codemirror/addon/edit/continuelist.js';
import 'codemirror/addon/edit/closebrackets.js';
import 'codemirror/addon/selection/active-line.js';
import MarkdownIt from 'markdown-it';
import texmath from 'markdown-it-texmath';
import katex from 'katex';
import hljs from 'highlight.js';
const md = new MarkdownIt({
html: true,
linkify: true,
breaks: true
});
md.use(texmath, {
engine: katex,
delimiters: 'dollars',
katexOptions: { throwOnError: false }
});
这里解释几个关键配置:
html: true:允许 Markdown 中嵌入原生 HTML。好处是排版灵活,坏处是如果打开不可信的 Markdown 文件,可能触发直接渲染 HTML。因此在实际项目中,如果不是自己编写的文档,建议引入 DOMPurify 之类的 HTML 清洗库,对 markdown-it 的输出再做一次过滤。linkify: true:自动把链接文本识别为可点击链接。breaks: true:把单个换行渲染为<br>。这是很多桌面 Markdown 编辑器的默认行为,更符合中文用户“换行即换行”的写作习惯。标准 Markdown 语法要求两个空格加换行才产生软换行,但桌面场景下breaks: true的体验更好。
接着初始化编辑器:
js复制let editor = null;
let currentFilePath = null;
const preview = document.getElementById('preview');
const fileTitle = document.getElementById('fileTitle');
const statusBar = document.getElementById('statusBar');
editor = CodeMirror.fromTextArea(document.getElementById('source'), {
mode: 'markdown',
theme: 'default',
lineNumbers: true,
lineWrapping: true,
styleActiveLine: true,
autoCloseBrackets: true
});
editor.getWrapperElement().style.height = '100%';
editor.getWrapperElement().style.border = 'none';
CodeMirror 的 fromTextArea 会把原有的 <textarea> 隐藏,并在其位置生成一个可编辑区域。lineWrapping: true 表示超长行自动换行,避免水平滚动条影响阅读体验。
预览渲染函数:
js复制function renderPreview() {
const value = editor.getValue();
const html = md.render(value);
preview.innerHTML = html;
// 代码块高亮
preview.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
// 本地相对路径图片转成 file URL
preview.querySelectorAll('img').forEach((img) => {
const rawSrc = img.getAttribute('src') || '';
img.setAttribute('src', resolveSrc(rawSrc));
});
const charCount = value.replace(/\s/g, '').length;
statusBar.textContent = `行数: ${editor.lineCount()} 字数: ${charCount}`;
}
代码块高亮这里有一个细节:每次 renderPreview 都会重新设置 preview.innerHTML,所以每次渲染后需要重新执行 hljs.highlightElement。这也是为什么主题切换按钮改完高亮样式后,需要立即重新渲染预览区域。
图片路径处理函数:
js复制function resolveSrc(src) {
if (!src) return src;
if (/^(https?:|data:)/.test(src)) return src;
if (!currentFilePath) return src;
const dir = window.nomoAPI.dirname(currentFilePath);
return window.nomoAPI.fileUrl(dir, src);
}
这样做的好处是,文档目录下的相对路径图片可以直接显示,比如 。
输入防抖与事件绑定:
js复制function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
editor.on('change', debounce(renderPreview, 300));
renderPreview();
注意这里没有使用 input 事件,因为 CodeMirror 有自己的事件系统,change 事件会在内容变化时触发。300ms 的防抖可以避免输入过快时频繁渲染整个文档,对性能有明显提升。
工具栏事件绑定:
js复制document.getElementById('openBtn').addEventListener('click', async () => {
const data = await window.nomoAPI.openFile();
if (!data) return;
editor.setValue(data.content);
currentFilePath = data.filePath;
fileTitle.textContent = data.filePath;
document.title = data.filePath + ' - Nomo';
renderPreview();
});
document.getElementById('saveBtn').addEventListener('click', async () => {
const filePath = await window.nomoAPI.saveFile(currentFilePath, editor.getValue());
if (filePath) {
currentFilePath = filePath;
fileTitle.textContent = filePath;
}
});
到这里,一个具备“编辑 + 实时预览 + 文件读写”基础能力的 Markdown 编辑器就已经可以跑了。你可以打开一个已有的 Markdown 文件,右侧预览区会立即渲染标题、列表、表格、代码块和公式。
5.2 Markdown 换行、表格、图片路径处理
Markdown 语法本身很简单,但实际使用中有几个高频困惑点,Nomo 在实现时都做了针对性处理。
第一个是换行。标准 Markdown 规定段落内的换行不生效,必须用两个空格加回车。Nomo 设置了 breaks: true,所以单回车也会显示为换行。这个行为更符合印象笔记、Typora 等软件的使用习惯。需要手动控制段落间距时,仍然可以使用空行来分段。
markdown复制这是第一行。
这是第二行。
这是新段落。
第二个是表格。markdown-it 默认支持 GFM 表格语法,不需要额外插件:
markdown复制| 功能 | 快捷键 | 说明 |
| --- | --- | --- |
| 加粗 | Ctrl + B | 加粗选中文字 |
| 保存 | Ctrl + S | 保存当前文档 |
预览区域需要额外配置表格边框样式。Nomo 在页面样式中为 #preview table 添加了边框、内边距和表头背景,这样表格看起来才清晰。
第三个是图片路径。Markdown 语法  中的相对路径,默认在渲染时无法直接找到文件。Nomo 通过前面写的 resolveSrc 函数,把相对路径拼接到当前文件所在目录,再转成 file URL。这样写文档时,只要图片和文档的相对位置不变,预览就能正常显示。
5.3 代码块高亮与主题切换
代码块是技术文档的重要组成。Nomo 对代码块的处理逻辑是:
- markdown-it 把代码块渲染为
<pre><code>结构。 - 渲染完成后,用 highlight.js 对代码块执行高亮。
- 高亮主题可以通过 CSS 动态切换。
markdown-it 默认会为代码块添加语言 class,比如 language-javascript。highlight.js 会自动检测并高亮。如果有明确的语言标记,也会优先使用指定语言。
主题切换逻辑如下:
js复制let darkMode = false;
document.getElementById('themeBtn').addEventListener('click', () => {
darkMode = !darkMode;
editor.setOption('theme', darkMode ? 'dracula' : 'default');
const hljsTheme = document.getElementById('hljsTheme');
hljsTheme.href = darkMode
? '../node_modules/highlight.js/styles/atom-one-dark.css'
: '../node_modules/highlight.js/styles/github.css';
document.body.classList.toggle('dark', darkMode);
renderPreview();
});
注意这里的 renderPreview() 不只是刷新内容,它同时会重新渲染所有代码块。因为 preview.innerHTML 已经被重新赋值,highlight.js 需要对新生成的 <pre><code> 节点再执行一次高亮,主题样式才会生效。
5.4 数学公式渲染与多行公式
Nomo 使用 markdown-it-texmath 集成 KaTeX,所以文档中可以直接写 LaTeX 风格公式。
行内公式用单个美元符号包裹:
markdown复制质能方程 $E = mc^2$ 是物理学中最著名的公式之一。
独立公式用两个美元符号包裹:
markdown复制$$
\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
$$
分段函数是多行公式最常见的场景,Nomo 也能正常渲染:
markdown复制$$
f(x) =
\begin{cases}
x + 1 & x > 0 \\
0 & x = 0 \\
-x + 1 & x < 0
\end{cases}
$$
KaTeX 的渲染速度优于 MathJax,尤其适合输入过程中频繁重渲染的场景。如果你在预览中看到公式源码而不是公式图形,优先检查三点:
- 是否成功安装了
katex和markdown-it-texmath。 - 是否在
md.use(texmath, ...)时传入了engine: katex。 - 是否在 HTML 中引入了
katex.min.css。
5.5 导出 HTML
把 Markdown 导出为 HTML 是常见的发布需求。Nomo 的实现思路是:用同一个 markdown-it 实例生成 HTML,再组装成一个完整页面,通过 Blob 下载。
js复制document.getElementById('exportBtn').addEventListener('click', () => {
const value = editor.getValue();
const bodyHtml = md.render(value);
const fullHtml = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>${fileTitle.textContent}</title>
<style>
body { max-width: 860px; margin: 0 auto; padding: 40px 24px; font-family: sans-serif; line-height: 1.7; }
table { border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 6px 12px; }
pre { background: #f6f8fa; padding: 12px; overflow: auto; }
img { max-width: 100%; }
</style>
</head>
<body>
${bodyHtml}
</body>
</html>`;
const blob = new Blob([fullHtml], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'export.html';
a.click();
URL.revokeObjectURL(url);
});
这里有一个工程上需要说明的坑:如果 Markdown 文档中包含本地相对路径图片,导出到其他目录时图片路径会失效。后续版本可以考虑在导出时把本地图片转成 base64 内联,或者复制图片到导出目录。对于公开发布的场景,更推荐先把图片上传到图床并改用网络链接。
PDF 导出的思路类似,可以在主进程中使用 webContents.printToPDF 方法,传入导出的 HTML 内容来生成 PDF。这里不再展开,核心代码都在 Electron 的 API 文档中。
5.6 滚动同步
双栏编辑器的体验很大程度上取决于滚动同步。Nomo 实现了从编辑区到预览区的基础滚动联动:
js复制const editorWrap = editor.getWrapperElement();
const previewWrap = document.getElementById('preview-wrap');
editorWrap.addEventListener('scroll', () => {
const info = editor.getScrollInfo();
const maxScroll = info.height - info.clientHeight;
if (maxScroll <= 0) return;
const rate = info.top / maxScroll;
const previewMax = previewWrap.scrollHeight - previewWrap.clientHeight;
previewWrap.scrollTop = previewMax * rate;
});
这个实现是单向的,从编辑区滚动带动预览区。预览区反向滚动带动编辑区需要额外处理防抖和方向判断,因为两个区域的滚动事件可能会互相触发,造成抖动。Nomo 在后续迭代中会继续优化双向滚动和锚点定位。
6. 常见问题与排查思路
6.1 高频问题对照表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| Electron 安装失败 | 网络原因导致二进制下载失败 | 配置 ELECTRON_MIRROR 镜像源后重新安装 |
| 打开文档后预览空白 | markdown-it 未正确引入,或 Markdown 内容为空 | 打开 DevTools 查看报错信息 |
| 图片预览不显示 | 相对路径解析失败,或路径包含中文和空格 | 使用 pathToFileURL 转换路径 |
| 公式源码没有被渲染 | texmath 插件未注册,或 KaTeX CSS 缺失 | 检查 md.use 配置和 katex.min.css |
| 打包后 CSS / 高亮主题丢失 | electron-builder 没有包含 node_modules 中的静态资源 | 在 build.files 中显式配置需要打包的资源 |
| 中文内容乱码 | 文件不是 UTF-8 编码 | 统一使用 utf-8 读写,历史文件可先用文本编辑器转码 |
6.2 预览刷新是核心排错入口
Markdown 编辑器出问题时,第一个要看的是渲染链路。预览区显示源码,说明 markdown-it 没有执行;代码块没有颜色,说明 highlight.js 没有处理;公式显示源码,说明 KaTeX 插件链断了。
推荐每次改动后打开 DevTools 的 Console 面板,Electron 窗口内按 Ctrl + Shift + I 即可打开。渲染进程的报错会直接显示在 Console 中,这是定位问题最高效的手段。
6.3 资源打包与路径问题
开发模式下,HTML 直接引用 ../node_modules/... 是没问题的。但使用 electron-builder 打包时,默认情况下只会打包代码依赖,不一定包含 CSS 和字体等非代码资源。因此需要在 electron-builder 的配置中显式声明需要打包的资源路径。
json复制{
"build": {
"files": [
"main.js",
"preload.js",
"renderer/**/*",
"node_modules/codemirror/lib/**/*",
"node_modules/codemirror/theme/**/*",
"node_modules/katex/dist/**/*",
"node_modules/highlight.js/styles/**/*"
]
}
}
这里有两个经验:
- 如果使用动态切换主题,保证所有可能使用到的主题 CSS 都包含在 files 配置中。
- 打包后先做一次“直接双击应用”的冒烟测试,重点检查公式、代码高亮、图片路径三个最容易出问题的点。
7. 打包与开源发布
7.1 打包配置
electron-builder 支持三个平台的安装包生成。在 package.json 中加入 build 配置:
json复制{
"build": {
"appId": "com.nomo.editor",
"productName": "Nomo",
"directories": {
"output": "release"
},
"win": {
"target": "nsis"
},
"mac": {
"target": "dmg"
},
"linux": {
"target": "AppImage"
}
}
}
执行打包命令:
bash复制npm run dist
生成的安装包位于 release/ 目录。不同平台的打包需要在对应平台完成,比如 macOS 的 dmg 最好在 macOS 上构建。
7.2 开源仓库规范
Nomo 的开源仓库采用 MIT License。如果你也想把一个项目开源出去,建议至少包含这些文件:
- README.md:项目简介、截图、安装方式、使用方法、开发指南。
- LICENSE:开源协议,MIT 是常见选择。
- CHANGELOG.md:版本变更记录。
- CONTRIBUTING.md:贡献指南,说明如何提交 Issue 和 PR。
- .gitignore:排除 node_modules、release、dist 等目录。
开源不是把代码丢到 GitHub 就结束了。清晰的 README 能降低使用门槛,规范的问题模板能帮你更快定位 bug,Contributing 文档则能吸引更多人参与维护。
7.3 发布版本
发布第一个版本时,建议给仓库打上 tag,例如 v0.1.0。之后把 electron-builder生成的安装包上传到 GitHub Release 页面。版本号遵循语义化版本规范:主版本号在接口不兼容时递增,次版本号在新增功能时递增,修订号在修复 bug 时递增。
8. 总结与后续规划
Nomo 目前的形态已经覆盖了 Markdown 编辑器的核心链路:本地文件读写、实时预览、代码高亮、数学公式、主题切换、HTML 导出。这套代码本身也是一个很好的 Electron 学习案例,你可以把它当作脚手架,在此基础上改造出自己的工具。
后续我计划继续补上这几个能力:
- 独立文章的分享:把导出 HTML 的样式继续打磨,做成可直接发布的博客模板。
- 自动保存与崩溃恢复:基于 localStorage 或本地临时文件,降低文档丢失风险。
- 目录大纲面板:从 Markdown 标题结构自动生成文档大纲,方便长文档导航。
- 更精细的滚动同步:双向滚动联动,以及单击预览区域反向定位到编辑位置。
如果你刚接触 Electron,建议从第 4 节的代码开始,先跑起来,再逐步加功能。Markdown 解析的部分相对独立,即使不做桌面端,单独放在浏览器项目里也可以复用。做工具类软件最大的乐趣,就是按自己需要去裁剪每一个细节,希望 Nomo 能给你一些参考。
如果有任何问题,欢迎在评论区一起交流。
