简介DBeditor 是面向开发者、技术写作者与前端学习者的跨平台 Markdown 编辑器项目强调在 Linux、macOS 与 Windows 上保持统一体验并以简洁扁平、大量留白的界面减少菜单干扰。它适合想了解桌面端编辑器如何组织界面、路由与前端模块的读者也可作为轻量工具的参考实现。压缩包共 61 个文件、约 499KB主要由 JavaScript 脚本、CSS 样式、Jade 模板、JSON 配置、PNG 素材和 Markdown 文档构成可梳理应用入口、帮助页、欢迎页及组件目录的协作关系。已有 927 人学习下载读者可获取项目结构、配置写法、模板组织与素材管理思路用于二次开发、课程实践或前端项目参考。1. 需求倒推跨平台 Markdown 编辑器到底该做成什么样写技术文档的人都遇到过这种尴尬左边源码改了一个字右边预览猛地跳回顶部你得重新滚回原位再花几秒找回落点。DBeditor 就是冲着这类细小却高频的摩擦去的——它是一款用 JavaScript 开发的跨平台 Markdown 编辑器同一套代码跑在 Windows、macOS、Linux 上左编辑右预览、滚动联动、图片粘贴即插入、一键导出 HTML 与 PDF。它不适合拿来跟成熟商业写作软件比功能丰度适合两类人想搞清楚桌面端 Markdown 编辑器内部链路怎么走的开发者以及手上已经有个 Electron 壳子、想照着一份可运行代码补全编辑、文件读写、导出这几块骨架的人。下面按“能跑起来、能看懂、能上手改”的顺序拆。2. 工程骨架主进程、preload 与渲染层怎么切2.1 为什么是 Electron 加原生 JavaScriptDBeditor 的技术底座没有太多悬念Electron 负责窗口和系统调用渲染层就是纯 Web 技术栈。选它的核心理由不是“流行”而是 Markdown 编辑器这个场景本身长得就像网页——左侧一个文本编辑区右侧一个 DOM 渲染区中间隔一层字符串转换。用 Web 技术做预览样式调起来就是改 CSS代码块高亮直接借 highlight.js导出 PDF 直接调 Chromium 自带的打印管线几乎不用自己造轮子。放弃“全前端框架”那套React/Vue 全家桶是另一个刻意取舍。DBeditor 的界面状态很浅一个文件路径、一个脏标记、一个主题变量、一个预览区。为这点状态引入路由、状态管理、构建链收益是负的。所以它用的是原生 DOM 加模块化 ES Moduledocument.querySelector直接拿节点事件用addEventListener挂打包工具只做最基础的一层合并与压缩。这套做法在页面动辄几十个组件的后台系统里不成立但放在一个单窗口桌面编辑器上恰恰是调试成本最低的。也不用 Tauri 或原生 GUI 方案理由很现实跨平台 WebView 版本差异带来的渲染不一致在 Markdown 预览这种重排版的场景里会被放大——表格边框、字体回退、滚动条宽度每个平台一个样。Electron 自带同一份 Chromium预览在我机器上什么样到别人机器上基本还是什么样这个确定性值钱。2.2 目录结构四个角色各自的边界工程按“主进程 / 桥 / 渲染层 / 构建产物”切成四块边界划得很死任何一个文件放错位置在 code review 里都会被拎出来。dbeditor/ ├── package.json ├── electron/ │ ├── main.js 主进程入口窗口、菜单、生命周期 │ ├── preload.js 唯一桥接层白名单暴露 API │ └── ipc/ │ ├── file.js file:open / file:save / file:save-as │ └── export.js export:html / export:pdf ├── src/ │ ├── index.html 单页结构编辑区 预览区两个容器 │ ├── renderer.js 渲染层入口串起编辑器与预览 │ ├── editor/ │ │ ├── createEditor.js CodeMirror 实例化 │ │ └── extensions.js 快捷键、主题、行号等扩展清单 │ ├── markdown/ │ │ ├── parser.js markdown-it 实例与配置 │ │ └── plugins.js 锚点、任务列表等自定义插件 │ ├── styles/ │ └── assets/ └── build/ 图标、安装包资源主进程只干三件事开窗口、注册 IPC、管菜单。它不认识 Markdown也不该认识——一旦有人在main.js里写字符串解析后面想换渲染库就得动两个进程改造成本翻倍。preload 是唯一的桥只暴露具体方法名不透传ipcRenderer。渲染层拿不到require也拿不到process所有系统能力都得走显式声明过的那几个通道。2.3 窗口创建与启动链路窗口创建这段代码短但每个参数都踩过坑注释里写清楚比写文档管用。// electron/main.js —— 主进程只做窗口和系统能力不碰任何 Markdown 逻辑 const { app, BrowserWindow, ipcMain, dialog } require(electron) const path require(node:path) const { registerFileHandlers } require(./ipc/file) const { registerExportHandlers } require(./ipc/export) let mainWindow null function createWindow() { mainWindow new BrowserWindow({ width: 1280, height: 800, minWidth: 720, show: false, // 先隐藏等 ready-to-show 再显示避免白屏闪烁 backgroundColor: #1e1e1e, // 与深色主题对齐减少首帧闪白 webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, // 渲染层的 window 与 preload 隔离 nodeIntegration: false, // 渲染层不允许直接 require sandbox: false, // preload 里需要少量 Node API按需放开 spellcheck: false // 关掉拼写检查中文场景下红线很吵 } }) mainWindow.loadFile(path.join(__dirname, ../src/index.html)) mainWindow.once(ready-to-show, () mainWindow.show()) } app.whenReady().then(() { registerFileHandlers(ipcMain, dialog) registerExportHandlers(ipcMain, dialog) createWindow() // macOS 点 Dock 图标时应重建窗口而不是重启进程 app.on(activate, () { if (BrowserWindow.getAllWindows().length 0) createWindow() }) }) app.on(window-all-closed, () { if (process.platform ! darwin) app.quit() })contextIsolation: true加nodeIntegration: false是这套架构的安全底线渲染层里的任何脚本包括用户粘贴进来的、预览区可能误执行的都摸不到 Node 的文件系统。show: false配ready-to-show是消除首屏白闪的标准做法少了这一步冷启动会闪一下纯白再变深色体验上很掉分。sandbox: false属于妥协项因为 preload 里要读一点环境信息如果项目后续能把这部分挪走建议改回true。preload 的写法决定了整个项目的攻击面大小。常见翻车是图省事写成contextBridge.exposeInMainWorld(api, { ipcRenderer })等于把整个 IPC 体系递给渲染层隔离白做了。// electron/preload.js —— 唯一的桥白名单式暴露不做通用透传 const { contextBridge, ipcRenderer } require(electron) contextBridge.exposeInMainWorld(dbeditor, { // 每个方法对应主进程里一个明确的 handler openFile: () ipcRenderer.invoke(file:open), saveFile: (payload) ipcRenderer.invoke(file:save, payload), saveAs: (payload) ipcRenderer.invoke(file:save-as, payload), exportHtml: (payload) ipcRenderer.invoke(export:html, payload), exportPdf: (payload) ipcRenderer.invoke(export:pdf, payload), // 菜单事件用订阅模式返回取消订阅函数避免页面重载后监听叠加 onMenu: (handler) { const listener (_event, action, arg) handler(action, arg) ipcRenderer.on(menu:action, listener) return () ipcRenderer.removeListener(menu:action, listener) } })参数层面值得说的是onMenu返回的那个取消订阅函数。渲染层如果在热更新或重新挂载时不调用它同一个菜单动作会被触发多次表现为“按一次 CtrlS 保存了三遍”日志里看像是文件系统抽风其实是监听器堆叠。这是我在别的 Electron 项目里吃过的亏所以这里直接从 API 设计上留好后悔药。3. 编辑核心CodeMirror 实例与 markdown-it 渲染管线3.1 初始化编辑器状态与扩展分开管编辑区选 CodeMirror 6不是因为它名气大而是它的“状态不可变 扩展组合”模型非常适合做主题切换和快捷键重绑——换个主题只是重配一个扩展数组不用销毁重建 DOM。// src/editor/createEditor.js import { EditorState } from codemirror/state import { EditorView, keymap, lineNumbers, highlightActiveLine } from codemirror/view import { defaultKeymap, history, historyKeymap } from codemirror/commands import { markdown, markdownLanguage } from codemirror/lang-markdown import { languages } from codemirror/language-data import { buildExtensions } from ./extensions.js export function createEditor({ parent, doc, onChange }) { const state EditorState.create({ doc, extensions: [ lineNumbers(), // 行号槽 highlightActiveLine(), // 当前行底色 history(), // 撤销栈默认 100 步 keymap.of([...defaultKeymap, ...historyKeymap]), markdown({ base: markdownLanguage, codeLanguages: languages }), // 围栏代码块内部按语言高亮依赖 language-data 按需加载 EditorView.lineWrapping, // 长行软换行写文档必须开 ...buildExtensions(), // 主题、自定义快捷键从这里注入 EditorView.updateListener.of((update) { if (update.docChanged) onChange(update.state.doc.toString()) }) ] }) return new EditorView({ state, parent }) }updateListener里那个docChanged判断不能省。CodeMirror 的 update 不只由输入触发光标移动、选区变化、视口滚动都会走一遍监听器如果不判断就直接调onChange等于每次点一下鼠标都整篇重渲染一次预览性能会莫名其妙地塌下来。lineWrapping也是必选项Markdown 里长段落很常见不开软换行就得横向滚动写作体验直接崩掉。buildExtensions()是留给二次开发的注入点主题、自定义快捷键、代码折叠都从这里进来。把扩展清单单独抽一个文件的好处是换主题时只改这一个文件不用动编辑器初始化的主流程。3.2 markdown-it 的配置与 XSS 防线渲染侧用 markdown-it配置项不多但每一项都影响安全与观感。// src/markdown/parser.js import MarkdownIt from markdown-it import hljs from highlight.js import { anchorPlugin, taskListPlugin } from ./plugins.js export const md new MarkdownIt({ html: false, // 关键不解析裸 HTML粘贴进来的 script 不会执行 linkify: true, // 裸链接自动转 a 标签 typographer: true, // 引号、省略号做排版替换 breaks: false, // 单换行不强制 br遵循 CommonMark highlight(code, lang) { if (lang hljs.getLanguage(lang)) { try { // 运行时调用此时 md 已完成初始化闭包引用安全 return pre classhljscode${hljs.highlight(code, { language: lang }).value}/code/pre } catch (_) { // 语言存在但高亮抛错时落到兜底不让整篇预览挂掉 } } return pre classhljscode${md.utils.escapeHtml(code)}/code/pre } }) md.use(anchorPlugin) // 标题生成 id支持页内跳转 md.use(taskListPlugin) // - [x] 渲染成复选框html: false是硬性要求。Markdown 编辑器天生要处理用户粘贴进来的任意文本只要开了裸 HTML别人发你一段带img onerror...的片段粘进编辑器预览就执行了。桌面端的渲染层虽然被contextIsolation挡了一层但仍能发起网络请求、读取页面内的敏感内容这个口子不能开。typographer会把直引号转成弯引号、--转成破折号。有人觉得这是“自作聪明”但在中文技术文档里它确实让排版好看一截。如果是要处理代码片段多的文档可以按文件类型动态关掉这个开关——这是后续可以加的改造点。高亮回调里md.utils.escapeHtml是防止语言不支持时把原始代码直接吐进 DOM。少了这行一段包含div的普通文本块会被浏览器当标签解析预览区样式直接乱套。3.3 输入防抖与同步滚动编辑区一变预览就得跟着变但“跟着变”不等于“每次按键都变”。文档上万字时一次全量md.render加上innerHTML替换主线程要卡几十毫秒连着输入就是明显的掉帧。// src/renderer.js —— 输入防抖 百分比同步滚动 const previewPane document.querySelector(#preview) const editorScroller document.querySelector(.cm-scroller) function debounce(fn, wait) { let timer null return (...args) { clearTimeout(timer) timer setTimeout(() fn(...args), wait) } } // 80ms 是个经验值小于 60ms 基本等于没防抖大于 150ms 会明显感觉预览慢半拍 const renderPreview debounce((text) { previewPane.innerHTML md.render(text) }, 80) // 滚动同步用百分比映射不做逐行对齐实现简单且不会因图片高度变化而错位 let syncing none editorScroller.addEventListener(scroll, () { if (syncing preview) return syncing editor const max editorScroller.scrollHeight - editorScroller.clientHeight || 1 const ratio editorScroller.scrollTop / max const target previewPane.scrollHeight - previewPane.clientHeight previewPane.scrollTop ratio * target // 下一帧再解锁避免反向滚动事件形成循环 requestAnimationFrame(() { syncing none }) })syncing这个标记是整段代码的核心。预览区被程序改scrollTop时也会触发自己的 scroll 事件如果不加锁两边就会互相推着滚表现为“滚一下页面自己往下窜一大截”很多人第一反应以为是渲染错乱其实是事件回声。百分比映射而不是逐行对齐是个明确的取舍逐行对齐要在 DOM 里给每个块级元素打行号标记改一处渲染逻辑就得重算一遍映射表维护成本高而且图片、表格加载后高度会变行号映射很快失效。百分比方案在长文档里会有轻微错位但永远不会崩属于“够用且不会翻车”的选择。4. 文件与导出IPC 通道、原子保存和 PDF 打印链路4.1 用 invoke/handle 替代 remote一条文件读写通道主进程里所有文件操作走ipcMain.handle渲染层用invoke调天然带上 Promise 语义异常能一层层抛回来。// electron/ipc/file.js const fs require(node:fs/promises) const path require(node:path) function registerFileHandlers(ipcMain, dialog) { ipcMain.handle(file:open, async () { const { canceled, filePaths } await dialog.showOpenDialog({ properties: [openFile], filters: [{ name: Markdown, extensions: [md, markdown, txt] }] }) if (canceled || filePaths.length 0) return null const filePath filePaths[0] const stat await fs.stat(filePath) // 大文件拦截超过 2MB 的纯文本编辑器渲染会非常吃力 if (stat.size 2 * 1024 * 1024) { return { filePath, content: , tooLarge: true } } return { filePath, content: await fs.readFile(filePath, utf8) } }) ipcMain.handle(file:save, async (_event, { filePath, content }) { if (!filePath) return { ok: false, reason: no-path } const dir path.dirname(filePath) const tmp path.join(dir, .${path.basename(filePath)}.dbedit-tmp) // 先写临时文件再 rename避免写到一半断电导致原文件被截断 await fs.writeFile(tmp, content, utf8) await fs.rename(tmp, filePath) return { ok: true } }) } module.exports { registerFileHandlers }file:open里那个 2MB 的判断不是拍脑袋。CodeMirror 处理几万行纯文本没问题但 markdown-it 每次全量渲染加上innerHTML替换几万行会直接把主线程按住好几秒用户体感就是“点了打开没反应”。与其让界面假死不如直接告诉用户“这文件太大了建议用别的工具看”。这条判断后来被我挪到了配置里做成可调阈值。file:save走的是临时文件加rename的老套路。原因很直白writeFile打开目标文件时会先清空如果这个时候进程被强杀或者机器断电原文件就变成一个 0 字节的空壳写了几小时的东西全没了。rename在同一个分区内是原子操作要么新内容整体生效要么原文件纹丝不动。临时文件命名前面加点是让 Unix 系的文件管理器默认隐藏它不至于用户目录里一堆垃圾文件。4.2 脏标记与自动保存的兜底保存逻辑本身不难难的是“什么时候该存”。DBeditor 用两个信号一个是编辑器内容与上次落盘内容不一致的脏标记另一个是窗口失去焦点。// src/renderer.js —— 自动保存策略 let currentPath null let lastSaved let autoSaveTimer null function markDirty(text) { const dirty text ! lastSaved document.title dirty ? ● ${basename(currentPath) || 未命名} : basename(currentPath) || 未命名 if (autoSaveTimer) clearTimeout(autoSaveTimer) // 有路径才自动存未命名文档必须等用户显式指定位置 if (dirty currentPath) { autoSaveTimer setTimeout(() persist(text), 2000) } } async function persist(text) { const res await window.dbeditor.saveFile({ filePath: currentPath, content: text }) if (res.ok) lastSaved text } // 切走窗口时立刻落盘一次不等 2 秒 window.addEventListener(blur, () { if (autoSaveTimer) clearTimeout(autoSaveTimer) const text view.state.doc.toString() if (text ! lastSaved currentPath) persist(text) })标题栏那个圆点标记是个小设计但很实用。用户在多窗口之间来回切的时候靠标题就能判断哪个文件还没存不用挨个点开看。blur事件里手动清一次定时器再立即保存是为了覆盖“用户敲完最后一个字马上切到浏览器”这个高频场景——只靠 2 秒防抖切走那一瞬间的改动可能还没落盘。未命名文档不做自动保存是刻意的限制。没有路径就没法写文件硬要自动存只能落到临时目录后续用户找不到文件反而更慌。这个取舍在文档里写明了“新建文档请先 CtrlS 指定位置”。4.3 导出 HTML 与 PDF 的三段式流程导出分两条路HTML 走字符串拼接PDF 走 Chromium 的离屏打印。// electron/ipc/export.js const { BrowserWindow } require(electron) const fs require(node:fs/promises) const path require(node:path) const os require(node:os) async function renderToPdf(html, outputPath) { // 大文档走临时文件data URL 有长度上限超了会静默失败 const tmpHtml path.join(os.tmpdir(), dbeditor-${Date.now()}.html) await fs.writeFile(tmpHtml, html, utf8) const win new BrowserWindow({ show: false, webPreferences: { offscreen: true, javascript: false } }) await win.loadFile(tmpHtml) const pdf await win.webContents.printToPDF({ printBackground: true, // 不开这个代码块底色全丢 pageSize: A4, margins: { top: 0.6, bottom: 0.6, left: 0.7, right: 0.7 } }) await fs.writeFile(outputPath, pdf) win.destroy() await fs.unlink(tmpHtml).catch(() {}) }printBackground: true是最容易漏的一项。默认值是false导出来的 PDF 里代码块、引用块、表格隔行底色全部变成白底看起来像样式丢了其实是打印设置的问题。javascript: false是为了防止待打印页面里有脚本在渲染过程中改 DOM导致内容跟预览对不上导出件和屏幕显示不一致是最难排查的一类问题。offscreen: true配合show: false让整个打印过程不出现在任务栏里。早期版本忘了设offscreen用户点导出会看到一个白窗口一闪而过虽然功能正常但观感像程序崩了一下。大文档必须落临时文件再用loadFile这是我用 data URL 踩出来的教训。Chromium 对 data URL 有长度限制一篇带大量图片 base64 的文档轻松超限表现是loadFile不报错但页面是空的导出的 PDF 只有一页白纸排查起来完全没有线索。落临时文件后长度不受限还能在调试时直接打开那个 HTML 看渲染结果。5. 避坑与排查打包、输入法、大文件和字体上的五个真实翻车点5.1 开发环境一切正常打包后白屏现象npm run dev里编辑器和预览都好用electron-builder打完包双击窗口出来了但一片白DevTools 里显示资源 404。原因开发时用相对路径加载src/index.html脚本引用写的是./renderer.js打包后文件被塞进app.asarloadFile的基准路径变了相对路径解析到了file:///根目录。渲染层的 ES Module 引用如果还是裸路径浏览器直接按模块解析失败处理静默不执行。解决loadFile用path.join(__dirname, ../src/index.html)拼绝对路径HTML 里的script标签统一改成相对当前文档的./renderer.js而不是/renderer.js。打包配置里把src、electron、node_modules显式列进files不要依赖默认排除规则。排查这类问题最快的办法是打包后按CtrlShiftI打开 DevTools 看 Network 面板404 的路径会直接暴露问题所在。5.2 中文输入法下光标乱跳、候选框错位现象用拼音输入法打字候选框出现在屏幕左上角选完词之后光标跳到段落开头输入的内容顺序被打乱。原因CodeMirror 6 默认依赖浏览器的compositionstart/compositionend事件来标记输入法组词状态。如果updateListener在组合输入过程中就调了onChange预览区会拿到一堆拼音中间态同时某些自定义快捷键在组合态下误触发导致光标定位失效。解决在updateListener里加组合态判断组词期间不触发预览渲染let composing false const editorDom view.dom editorDom.addEventListener(compositionstart, () { composing true }) editorDom.addEventListener(compositionend, () { composing false renderPreview(view.state.doc.toString()) // 组词结束补一次渲染 }) // updateListener 内部 if (update.docChanged !composing) onChange(update.state.doc.toString())另外自定义快捷键里凡是绑了字母键的比如CtrlB加粗要在 keymap 里返回false让组合态放行否则输入法正在组词时按字母会触发命令。5.3 打开几十万行的日志文件直接卡死现象用户拖进来一个 30MB 的.md界面转圈十几秒后无响应任务管理器显示渲染进程内存飙到 1.5G。原因三个地方同时爆掉。一是fs.readFile一次性把 30MB 读成字符串二是 CodeMirror 把整个文档建进状态树三是 markdown-it 全量渲染后innerHTML替换浏览器要为一堆代码块重排。三者叠加任何一项单独优化都不够。解决入口处先stat拦一道就是第 4.1 节那段代码超过阈值走“大文件只读模式”——关闭实时预览只保留纯文本编辑用户手动点“渲染预览”才执行一次并且渲染前截断到前 5000 行加一句提示。这个方案不优雅但比让用户看着假死屏强得多。血泪经验是不要指望通过虚拟滚动彻底解决预览区的 DOM 高度计算在超大文档上本身就是个坑。5.4 导出 PDF 里中文全变方块现象屏幕上预览汉字正常导出 PDF 后标题和正文全变成一个个空心方框英文和数字正常。原因loadFile加载的临时 HTML 是个独立文档它没有继承主窗口的字体样式。系统默认的打印字体在部分 Windows 环境下不含中文字形Chromium 打印时就回退到豆腐块。另一层原因是导出用的 CSS 里写的是font-family: -apple-system, sans-serif在 Windows 上-apple-system无效sans-serif又落到了不含中文的默认字体。解决导出模板里显式声明带中文回退的字体栈并且把字体文件随包一起发用font-face引绝对路径/* 导出专用样式不跟界面样式混用 */ font-face { font-family: DocSans; src: url(file:///path/to/assets/NotoSansSC-Regular.woff2) format(woff2); } body { font-family: DocSans, PingFang SC, Microsoft YaHei, sans-serif; }字体文件路径要在主进程里按__dirname动态替换成绝对路径再写进 HTML写死相对路径在打包后必然失效。5.5 三个平台的快捷键各打各的现象Windows 上CtrlS保存正常macOS 上按下没反应菜单里显示的快捷键提示在 Linux 上是Ctrl在 macOS 上还是Ctrl而不是Command。原因快捷键在渲染层用keydown手写监听时event.ctrlKey和event.metaKey是两套体系。菜单加速键accelerator如果写死CtrlOrCmdS之外的字符串Electron 在非对应平台会直接忽略。解决统一走 Electron 的 accelerator 语法把CtrlOrCmd作为修饰键别名交给框架处理菜单里同时配置roleconst template [ { label: 文件, submenu: [ { label: 新建, accelerator: CtrlOrCmdN, click: () sendMenu(new) }, { label: 保存, accelerator: CtrlOrCmdS, click: () sendMenu(save) }, { type: separator }, { role: quit, label: 退出 } ] } ]渲染层不再自己判断ctrlKey || metaKey所有快捷键从主进程菜单事件下发。这样快捷键提示文案、实际响应、平台差异三件事由 Electron 统一维护改一处三平台同步生效。翻车点在于有人图快在渲染层写死e.ctrlKeymacOS 用户按 Command 完全没反应反馈上来像是个“只在苹果电脑上出现的 bug”。6. 二次开发主题、插件和快捷键的改造入口6.1 换主题改 CSS 变量不要覆盖选择器DBeditor 的样式层用了两层结构一层是 CodeMirror 的主题扩展一层是预览区的 CSS 变量。想换一套配色正确做法是新建一个theme-xxx.js在里面同时定义编辑器扩展和根节点上的变量表而不是在现有样式后面追加覆盖规则。// src/editor/theme/theme-solarized.js import { EditorView } from codemirror/view export const solarizedVars { --bg: #fdf6e3, --fg: #657b83, --accent: #268bd2, --code-bg: #eee8d5 } export function solarizedEditor() { return EditorView.theme({ : { backgroundColor: var(--bg), color: var(--fg) }, .cm-activeLine: { backgroundColor: rgba(38,139,210,0.06) } }) }切主题时调view.dispatch({ effects: themeCompartment.reconfigure(solarizedEditor()) })同时把solarizedVars逐条style.setProperty挂到document.documentElement上。CodeMirror 的Compartment就是为这种动态重配设计的不用销毁重建编辑器。覆盖式写法的问题是优先级越堆越高改到第三套主题时!important满天飞后面谁都不敢动。6.2 加一条自定义 Markdown 语法想加类似高亮这样的语法入口在src/markdown/plugins.js用 markdown-it 的 inline ruler 插一条规则。核心是匹配文本、生成mark_open/mark_close两个 token再用 renderer 规则渲染成mark。注意要处理转义和代码块内的屏蔽否则代码块里写会被误替换这是最常见的实现缺陷。编辑器侧还要同步加一条 CodeMirror 的装饰规则否则源码区看不出高亮语法两边体验割裂。改完之后务必跑一篇包含行内代码、围栏代码、转义字符的测试文档逐项比对预览结果——我吃过一次亏正则写得贪心把代码块里的ab也包成了mark用户反馈说“代码被染色了”。6.3 改完怎么验证三个检查点固定走一遍一是拖入一个 1MB 以上的真实文档看输入延迟和滚动是否还跟得上二是三个平台各导一次 PDF重点看中文和代码块底色三是把渲染进程的 DevTools 打开控制台必须干净任何一条[Violation]长任务警告都值得追一下。最后留一句我自己的习惯从那以后我每次改parser.js都会先把那个包含转义、行内代码、链接、表格、任务列表的测试文档拖进去跑一遍再动手改代码。这类解析器的 bug 不会在简单示例里露头只会等着在用户的正式文档里给你一记闷棍。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?