ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

自研Markdown编辑器Inker:中文排版与性能优化的实践之路

2026/9/16 5:17:37 拓冰建站 浏览量
自研Markdown编辑器Inker:中文排版与性能优化的实践之路 我大概是那种会被朋友拉黑的人任何场合只要提到写文档我都会条件反射地反问一句“你为什么不试试 Markdown”。从技术笔记、项目 README、万字长文到读书摘抄我几乎把所有文字工作都搬进了 Markdown。这些年我用过的 Markdown 编辑器两只手数不过来却始终没有找到一个真正让我舒服的有的长得好看但结构一复杂就卡有的功能彪悍但排版折腾人。后来我决定不再等了自己动手写一个。于是有了 Inker——一款面向 Markdown 重度用户设计的编辑器目标就俩字好看彪悍。先说清楚它能做什么它是一套比较完整的即时渲染 Markdown 编辑器支持中文排版优化、KaTeX 数学公式、智能表格编辑、图片路径自动管理、大文档虚拟渲染、自定义主题还内置了可选的轻量脚本插件机制。适合谁适合那些把 Markdown 当“第二母语”、天天要写长文档、对渲染样式有要求、又不想在不同工具之间来回切换的人。这篇文章不写软文只讲我在造这个轮子过程中最核心的设计决策和踩过的坑。1. 为什么这个重度用户决定自己造一个编辑器1.1 用了那么多年 Markdown我到底在嫌弃什么先说背景。我在大概七八年前开始用 Markdown。最初是 Sublime 里装一堆高亮插件后来换成 Typora又经历了 Obsidian、VS Code Markdown Preview Enhanced、Notion 等一堆工具的来回折腾。每换一次工具都要重新适应它的文件组织方式、渲染风格、快捷键逻辑来回几次之后我开始认真思考一个问题到底是我太挑剔还是这个领域真的缺少一个“刚刚好”的编辑器我把自己用过的编辑器列了一个对比表最能说明问题。编辑器优点我遇到的最大痛点Typora界面干净即时渲染体验好大文档会明显卡顿导出样式定制成本高VS Code MPE生态强可定制性极强预览与编辑分离配置繁琐换了环境要重调Obsidian文件库和双链体系很成熟打开单个临时 Markdown 文件反而偏重渲染细节一般Notion协作体验不错不是真正的 Markdown迁移成本极高这些痛点单独拎出来都还能忍合在一起就变成持续的消耗。最让我难以接受的是Markdown 的核心价值本来应该是“让作者专注于内容”但我在实际写作里大量精力被消耗在工具适配和格式调整上这已经完全背离了用它写作的初衷。1.2 从“换工具”到“写工具”的拐点真正让我下定决心动手的是一次写技术总结的经历。当时要交付一份 PDF 格式的季度总结给团队我在 Typora 里调了整整两个晚上的打印样式。代码块跨页被截断中英文间距忽大忽小页眉页脚的边距怎么都差一点。最后导出看着还是别扭。老实说那篇总结的内容本身我只花了半天就写完了剩下的时间全都耗在“让文档看起来体面”这件事上。这件事给我的冲击很大我是为了效率才用 Markdown结果效率恰恰被工具吃掉了。那一刻我想明白一件事——我要的不只是“能写”而是“写完就能体面地交付”。渲染要好看、导出要符合预期、编辑过程要流畅这三者在同一个软件里必须同时成立。1.3 “好看又彪悍”不是口号是需求清单既然决定自己写我就先把“好看”和“彪悍”拆成了可落地的功能需求清单。拆完才发现这个项目的边界一下子清晰了很多。需求优先级说明中文排版优雅P0首行缩进、标点悬挂、中西文混排间距接近即时渲染P0打字时看到渲染效果但源码错误要可见、可改大文档流畅P1几十万字不出现明显输入延迟数学公式支持P1使用 KaTeX 渲染行内和块级都要支持表格体验友好P1自动对齐、Tab 跳转单元格、复杂表格可编辑图片粘贴与路径管理P1避免图片丢失支持自动归档主题可定制P2至少做到 CSS 变量级别而不是换换颜色P0 是底线没有就不能发布。P1 是“彪悍”的证明P2 是长期体验分水岭。这个需求清单在后续开发中几乎没改过因为每个需求背后都对应一个我自己的真实写作场景。事实证明从自身的痛出发做产品方向不太容易跑偏。2. “好看”的具体实现排版与主题系统2.1 中文排版的细节远比英文复杂很多 Markdown 编辑器的渲染效果英文看着还行一旦切到中文就露怯。原因很简单中文排版的规则比英文多得多。首行缩进两字符、全角标点不能在行首、中西文之间要留出合适的空白、引号和书名号在不同语境下的间距处理……这些规则在 CSS 规范里是全新概念绝大多数编辑器默认样式根本没有考虑。Inker 的解决思路分两层。底层是字体栈和度量调整。我给文档区域设计了一套默认字体回退链优先使用系统中文字体同时保证英文和代码使用无衬线或等宽字体整体行高控制在 1.75 左右这个数值对中文阅读比较舒适。.ink-doc { font-family: Source Han Serif SC, Noto Serif CJK SC, PingFang SC, Microsoft YaHei, serif; line-height: 1.75; letter-spacing: 0.02em; } .ink-doc p[data-indenttrue] { text-indent: 2em; margin: 0.6em 0; }上层则是对标点悬挂和混排间距的特殊处理。英文和中文字符之间如果紧贴在一起会显得拥挤我参考了类似 pangu.js 的空格化思路在渲染层对拉丁字母、数字与汉字之间自动插入合理的间距而全角标点在行首时则通过微调text-indent和padding来模拟悬挂效果。这套逻辑不是纯 CSS 能解决的需要渲染引擎在分词阶段做标记。2.2 主题系统外观、渲染与导出分离很多编辑器的主题都做得“很肤浅”换个背景色、改个高亮配色就自称支持主题。但实际写作场景里需要定制的远不止编辑器外观你编辑时看到的样子、最终导出 PDF 的样子、别人在浏览器里阅读的样子往往是三种诉求。Inker 把主题拆成了三层外观主题管编辑器界面渲染主题管文档区的排版导出主题管 PDF 和打印样式。前两者通过 CSS 变量热切换导出主题则独立成一套只处理分页、页眉页脚、代码换行等打印规则的样式表。三层互不干扰用户改起来也不用担心“动了导出样式结果编辑区也跟着变”。:root { --ink-editor-bg: #f7f6f3; --ink-editor-fg: #24292f; --ink-doc-width: 760px; --ink-code-bg: #f6f8fa; --ink-selection: rgba(84, 174, 255, 0.35); }用户自定义主题不需要重新编译写一个 CSS 文件放到主题目录编辑器启动时会自动扫描在设置面板里就能切换。这个设计让我少维护了至少几十套内置主题把工作重心放回到了渲染核心上。2.3 所见即所得不等于堆砌特效“好看”很容易走偏成“花哨”。我给自己定的标准是编辑器的动效应该克制到几乎察觉不到但又能在关键时刻给用户反馈。段落插入和删除时渲染层只做极短的透明度过渡时间控制在 120 毫秒以内滚动和光标定位不做任何弹性动画因为写作是长时间专注的场景任何无关动画都是干扰。另外还有一个容易忽略的小交互Markdown 的换行规则非常反直觉很多人记不住“行尾两个空格才是软换行”。Inker 在渲染层做了特殊处理软换行会直接显示为视觉换行同时保留源码层的标记这样既符合所见即所得又不改变 Markdown 原生语法。这个细节很小但真的能降低新手的学习门槛。3. 编辑器内核选型与渲染管线这决定了“彪悍”的下限3.1 为什么没有直接用 ProseMirror / CodeMirror / Milkdown“自己写编辑器”这句话说出来容易内核选型第一步就能劝退很多人。业界的成熟方案不少CodeMirror 6、ProseMirror、Slate、Milkdown 都是好东西但用了一圈之后我还是决定放弃它们走一条更笨的路。方案优势不适合我的原因CodeMirror 6性能好、生态成熟它是代码编辑器思维对“中文文档排版”的诉求支持很弱ProseMirror富文本模型严谨它的数据模型是富文本节点树和 Markdown 源码之间需要维护复杂转换Milkdown开箱即用定制底层渲染管线时反而受框架约束自研轻量渲染层完全可控需要自己处理边界情况但符合我的最大诉求关键原因在于Markdown 编辑器要的“所见即所得”和富文本编辑器的“所见即所得”本质是两回事。富文本编辑器的核心是维护一棵内容树而 Markdown 的真相是源码本身。用户在 Inker 里看到的任何时候底层都保存着一份完整无歧义的 Markdown 源码渲染层只是一面“镜子”。所以我更需要一个能精细控制 DOM 更新和源码对应关系的渲染层而不是再套一层抽象。3.2 行阵列模型与增量解析Inker 的数据结构可以用一句话概括整篇文档就是一维行数组加上行级别的注解。interface Line { id: number; raw: string; tokens?: Token[]; blockType: paragraph | heading | code | table | list | quote; sourceLine: number; dom?: HTMLElement | null; }每次编辑只把受影响的行标记为“脏”解析器只重算这些行再根据块类型自动向上向下扩散——比如光标在一个代码块内部无论它有多少行整个代码块会被作为一个整体重渲染而不是逐行解析这样能避免在代码块里出现 Markdown 语法误判。增量解析是性能的地基。如果每次输入都全量重解析几万字以上的文档基本都会有可感知的卡顿。Inker 的做法是维护一个dirtyLines集合每次输入后按影响范围收集脏行解析完成后只更新对应 DOM 节点实现局部更新。const dirtyLines new Setnumber(); function onInput(range: EditRange) { collectDirtyLines(range, dirtyLines); for (const id of dirtyLines) { const line lineArray[id]; line.tokens tokenize(line.raw); renderLine(id); } dirtyLines.clear(); }这套模型写起来不算复杂但收益极大。它让“几十万字不卡”从目标变成了现实后面第五章会给出具体数据。3.3 一次键盘输入的完整旅程举个最小例子你在空行里敲下一个#编辑器内部发生了什么输入事件捕获原始按键先判断是否命中语法快捷键比如自动补全成#。标记当前行为脏行调用增量解析。分词器识别出这是标题语法生成 heading 类型的 token。渲染层把这一行对应的 DOM 节点切换为标题样式并实时更新大纲视图。状态管理器记录这次变更供撤销栈使用。整个链路必须在一个帧内完成。我给自己定的性能预算是单次按键在主线程上的耗时不超过 8 毫秒超过这个阈值再快的解析器用户也会感受到输入延迟。本着这个标准我后来砍掉了不少看似酷炫但代价过高的实时特效。3.4 语法高亮与代码块的内部实现代码块是 Markdown 渲染里的“保护区域”。Inker 对代码块的处理是一旦识别到围栏代码块就直接把整个块踢出常规 Markdown 分词流程交给独立的代码高亮器处理。这样代码块里的# 标题、**加粗**之类的内容不会被误渲染这是很多简单 Markdown 解析器会犯的错。代码高亮器的选型也做过对比。Prism 体积小、插件生态丰富highlight.js 开箱即用、语言覆盖广Shiki 渲染质量接近 VS Code但体积和耗时都更大。权衡之后Inker 选择了 highlight.js 作为基础高亮器但它只负责代码块内部的语言高亮渲染出来的 DOM 会被标记为“受保护节点”不参与文档的增量更新。这样既保住了高亮质量又不会拖累整体性能。4. “彪悍”的细节公式、表格、图片与文档管理4.1 数学公式从插件思维变成核心基础设施很多 Markdown 编辑器把数学公式当插件功能用户要自己去装扩展、改配置。Inker 从一开始就把 KaTeX 当成核心模块内置。为什么选 KaTeX 而不是 MathJax一是 KaTeX 渲染速度快滚动页面时公式不会闪二是体积小不拖累启动三是错误提示更清晰写错公式时能准确定位到哪一行。数学公式在 Inker 里的编辑体验做了很多细节打磨。行内公式直接写$...$就能实时渲染块级公式用$$...$$光标移入公式区域时会显示一个可编辑的输入框按CmdShiftM可以快速插入一个新的公式块。渲染结果带缓存同一个公式在文档里出现多次不会重复计算。4.2 表格Markdown 表格的真正痛点写过 Markdown 表格的人都知道这玩意儿是对耐心的极限考验。手写对齐是灾难复制过来列宽不一致单元格里一放反引号就可能把管道符|的转义搞乱。Inker 在表格上做了三个层面的优化。第一层是智能对齐。编辑表格的源码行时只要按下Tab跳出当前单元格编辑器会自动把整列的管道符和空格对齐生成规范且可读的 Markdown 表格源码。第二层是键盘导航。Tab跳下一个单元格ShiftTab跳上一个Enter新增一行表格编辑的手感接近在表格软件里操作。第三层是复杂表格支持。当单元格内容包含换行或较长代码段时可以一键把表格切换到“块级编辑模式”用类似 Grid 的交互编辑完成后再转换回 Markdown 语法。这条路线带来的额外好处是表格复制到 Excel 或从 Excel 复制过来也能保持结构完整很多用户提到的“markdown 表格转换 excel”需求在这套设计里基本是顺带解决的。4.3 图片粘贴与路径管理Markdown 的图片路径是最容易被忽视、又最容易搞崩文档结构的痛点。很多人本地写文档时插入图片保存的是绝对路径换一台电脑就打不开有些人把图片粘进去编辑器自动生成一串 base64文件瞬间膨胀几倍。Inker 的默认策略是项目化的相对路径管理。粘贴图片时自动把图片保存到当前文档所在目录的assets子目录并在源码中写入相对路径。用户可以在设置里指定图片根目录、是否开启图床自动上传等行为。{ imageInsertMode: asset, assetDir: ./assets, relativePath: true, uploader: { enabled: false, endpoint: https://your-domain.example.com/upload, tokenEnv: MD_UPLOAD_TOKEN } }默认采用相对路径而不是绝对路径是为了保证文档可迁移。你把整个目录打包发给自己、放到 GitHub 或同步盘里图片都能正常显示。图床上传功能默认关闭因为图床方案太依赖个人环境做成可选能力更合适。4.4 大纲、折叠与文档组织写长文档的人一定离不开大纲和折叠。Inker 的左侧栏会实时解析标题结构生成大纲点击任意标题可以精确滚动到对应位置同一级标题支持折叠折叠状态下该标题下的内容会全部隐藏只保留标题行这对长文档的导航效率提升非常明显。还有一个功能是许多人问过的“文档内快速跳转”。CmdP打开快速面板可以按标题名、文件内书签甚至链接目标进行检索体验类似 IDE 里的文件跳转。至于“双链笔记”这个需求我最终克制住了——没有做知识图谱只在 Markdown 链接基础上实现了可疑引用的跳转。因为大多数人的写作场景是“把一篇长文档写完”而不是“把一万篇笔记织成一张网”。5. 性能优化如何让几十万字的文档不卡顿5.1 大文档卡顿的根源很多编辑器在大文档下变卡不是因为机器不行而是因为它们每次按键都把整篇文档重新解析、重新渲染一遍。哪怕解析器再快几十万字的全量 tokenize 也会产生可感知的延迟。更隐蔽的问题在于即使你没有输入只是在长文档里滚动很多编辑器也会把所有行都挂在 DOM 上DOM 节点数量一上去滚动、重排、光标的定位全部会变慢。Inker 的应对思路是前面说的行阵列模型但它还需要解决另一个根本问题DOM 不能无限增长。5.2 可视区渲染与虚拟滚动Inker 的文档区域采用虚拟化渲染同时只渲染可视区上下各 200 行左右的缓冲内容滚动时动态补充新行、回收旧行。渲染层维护一棵轻量的行索引树滚动事件触发时通过二分查找快速定位到当前可视区的行范围再触发增量挂载。实测数据更能说明问题。我用一份 30 万字的纯文本 Markdown 文档做了基准测试指标非虚拟化渲染Inker 虚拟化渲染启动打开耗时2.1s0.4s打字输入延迟时有卡顿始终低于 16ms滚动流畅度掉帧明显基本稳定 60fps内存占用约 480MB约 120MB虚拟化最大的设计难点在于滚动定位要非常精确。用户拖动滚动条时如果估算位置和实际行高之间存在误差就会出现跳动。Inker 的做法是维护一个“累计行高表”每行渲染后的真实高度会被记录间隙和折叠产生的偏移通过二分查询纠正这样即使文档里夹杂着各种高度的代码块和公式滚动定位也能保持准确。5.3 启动速度与内存优化启动速度方面Inker 做了两件事。第一是不主动加载目录里的所有文件只扫描文件名和目录结构打开哪个文件才加载哪个文件避免把大量文件塞进内存。第二是懒加载所有重量级模块代码高亮的语言包、KaTeX 的扩展组件、导出命令的二进制工具全部在首次使用时才加载。这里也体现了我选择 Tauri 而非 Electron 的原因。Inker 的桌面端基于 Tauri 构建系统 WebView 加 Rust 后端安装包体积只有几 MB内存占用远小于 Electron 全家桶。当然 Tauri 也有代价不同系统的 WebView 渲染差异需要额外适配这块问题我放在下一章的坑里详细说。6. 三个典型坑的完整排查链路6.1 中文输入法导致光标漂移上线后第一个被高频反馈的 bug 就和中文输入法有关。症状是在拼音输入法的联想候选框出现后光标会莫名其妙跳到文档开头或结尾偶尔还会丢字。排查链路是这样的先让反馈用户提供输入法类型和复现步骤确认是“中文输入法状态下输入”才触发英文输入完全正常。接着我在本地反复输入发现一旦compositionstart事件触发Inker 的受控输入逻辑就会和操作系统的文字组合过程打架。问题几乎可以锁定在对输入法组合事件的处理上。根因是编辑层用了一个“受控组件”思维来处理内容变更——每次用户输入都立即更新底层源码然后强制用源码重建渲染层。但输入法组合期间的中间态是未确认文字如果我强行更新源码光标位置就会因为 DOM 重建而丢失。解决方案是把输入分为组合中和组合完成两个阶段组合期间只更新屏幕显示、不触碰底层数据模型等到compositionend事件触发后再一次性提交。修复后我额外加了一个自动化验证用脚本模拟输入法组合事件覆盖中文、日文、韩文输入法的常见行为防止这个问题在后续版本回潮。这段经历给我一个很重要的教训只要做面向中文用户的产品输入法兼容就是基础体验不能只在小范围测试里感觉“差不多行了”。6.2 表格单元格内的解析串行第二个坑来自 Markdown 表格和反引号的组合。用户在表格单元格里写了包含反引号的代码片段解析结果出现错位本来应该只高亮某一行里的code结果反引号影响了整列的对齐甚至让下一行被错误地识别成表头分隔符。按照常规排查流程我先构造了一个最小复现用例然后逐层剥离确认不是渲染层的问题而是分词阶段对表格行的特殊处理有缺陷。问题出在标准 CommonMark 对表格的“严格模式”要求单元格内不能出现某些字符但它并没有细化到处理反引号中的管道符。按照规范管道符在单元格里需要用\|转义但用户写的话直接在代码片段里用了一个|于是解析器把它当成了列分隔符。解决方案是给 Inker 的分词器增加一个“表格内代码片段优先”规则在识别表格单元格时先用一个轻量扫描器把反引号包裹的代码段标记为受保护区域再去切割管道符。这样即使代码片段里有管道符也不会影响表格结构。同时我在文档里加了一条工具提示建议用户遇到复杂内容时优先使用代码块而不是表格单元格。6.3 导出 PDF 时的“幽灵空白页”第三个坑是导出 PDF 偶发的空白页。表现是导出的文档在某个表格或代码块后面多出一整页空白删除内容后空白页依然存在。这个现象的隐蔽性在于源文档在编辑器里看起来完全正常问题只出现在打印样式下。排查从打印样式表开始。我逐个关闭样式规则做二分验证最后定位到一条规则上为了不让代码块和表格跨页被截断我给它们加了page-break-inside: avoid这在大多数浏览器里是正确的但目标 WebView 对这个属性的实现有差异当块元素的高度接近页面剩余空间时计算器会在分页处留出一个不符合预期的空隙。解决方案是针对 WebView 的情况加了一组补丁规则同时提供一个“紧凑模式”开关在导出时自动降低分页边距、压缩代码块行高。做完之后我对所有内置导出样式表做了一次回归确保没有其他元素因此受影响。这也让我对 Tauri 的跨平台适配有了更深的认识。顺带说一句如果你只是想快速把 Markdown 文件从 VS Code 导出成 PDF社区常规做法仍然是装 Markdown PDF 插件之类的方案再下载 princexml 这类排版引擎做打印转换操作链路比较繁琐。Inker 之所以内置导出模块就是为了把这段折腾过程彻底藏起来。7. 开放生态与后续规划让编辑器成为写作工作流的一环7.1 插件系统的最小设计我不打算把 Inker 做成一个“万物皆可插件”的庞然大物但完全封闭也不符合重度用户的需求。最终方案是一个最小化的脚本插件系统允许用户编写 JS 脚本来注册渲染钩子和快捷键比如自定义一个博客常用的提示块。export default { name: admonition, render(node) { if (node.type blockquote) { const firstLine node.firstLine.trim(); if (firstLine.startsWith([!tip])) { return div classtip-block${node.innerHTML}/div; } } } }安全方面插件默认不生效需要用户在设置里对每个脚本显式勾选“信任并启用”。编辑器核心运行在一个受限环境插件只能访问文档内容和渲染钩子不能直接读取本地文件或执行系统命令。这样可以在“可扩展”和“安全边界”之间找到一个务实的平衡点。7.2 导出与导入不止是 PDF聊到写作工作流就不能不提导入导出。Inker 目前内置了 HTML、PDF、图片导出以及从 HTML、纯文本 Markdown 的导入导出 PDF 这一步用的是内置排版引擎用户不需要额外安装任何工具。我理解很多用户真正想要的是 Markdown 转 Word、表格转 Excel 这类“跨格式工作流”所以后续版本会优先接入更完整的文档转换管线让整个流程在编辑器内部闭环而不是每次都要去外部拼装一堆工具。内测用户里已经有人在用 Inker 配合自动化脚本把 Markdown 文档批量转成 Word 提交给出版社。这套工作流目前在项目仓库里沉淀了不少真实案例也是我觉得这个项目最有价值的部分——它不是玩具而是真的能接住写作交付全流程。7.3 开源带来的反馈与后续方向Inker 选择把核心渲染引擎开源既是出于对 Markdown 社区的回馈也是希望长期维护压力能被共创分摊。开源之后收到的反馈里有几个非常有价值的方向第一是中文排版细节的进一步打磨有用户提交了粤语文本和古汉语文本的间距规则这是我完全想不到的场景第二是代码块高亮的性能优化有一份 PR 把高亮器的懒加载策略重写了一遍让打开速度又提升了约 30%第三是暗色主题的对比度修正这类细节很大程度上依靠真实用户的视野。接下来的规划我会优先做移动端适配和浏览器版。移动端目前最大的障碍是表格和公式的触摸编辑体验浏览器版则要解决文件系统访问的兼容问题。这两个方向做下来之后Inker 才算真正覆盖到“随时随地把想法写成 Markdown”的全部场景。最后分享一个我自己用得最多的技巧写长文的时候把左侧大纲折叠到只剩二级标题再开启“专注模式”隐藏侧边栏整个屏幕就剩下文档本身滚动和打字都极其跟手。工具这东西做到最后其实就是让人感觉不到工具的存在。Inker 离这个目标还有距离但至少我已经在路上了。