导读
Hi 掘友们好,我是德莱厄斯,前段时间我的 milkup 编辑器经历了一次脱胎换骨的更新(已发布 v1 版本),从 milkdown 内核转为基于 prosemirror 的自研内核(milkup core),它目前有更优秀的编辑体验和更容易拓展的系统,接下来将为大家带来 milkup 内核的技术解读。
简介
milkup 是一款跨平台的、桌面端的、永久完全免费开源、即时渲染的 markdown 编辑器,体验堪比 Typora!
一、问题与设计目标
用过 Typora 的人大多都有一种感受:这才是写 Markdown 应该有的体验。光标移到哪里,语法符号就在哪里"冒出来";光标一离开,内容立刻变成渲染后的样子。这种体验被称为即时渲染(Immediate Rendering)。
注:Immediate Rendering(RI 即时渲染) 概念由 Vditor 提出,是对 Typora 渲染模式的总结,区别于 WYSIWYG(所见即所得)
但实现这种体验的难点在于:Markdown 编辑器天生就是"两张皮"——一边是源码,一边是渲染结果。大多数解决方案选择简单粗暴的路线:把编辑区和预览区分开,或者干脆把输入转换成富文本节点、彻底丢弃源码信息。
Milkup 选了一条更难走的路:语法符号不消失,而是作为真实的文本内容保留在文档里,只是在合适的时机被"藏起来"。
这个设计决策影响了整个系统的架构,本文就沿着这条主线,一层一层拆解 Milkup 的实现。
二、整体架构:五层协作
Milkup 构建于 ProseMirror 之上。ProseMirror 是一个高度可扩展的富文本编辑框架,提供了文档模型、事务系统、插件机制等基础能力。Milkup 在此之上搭建了五个核心层:
用户输入的 Markdown 文本
↓
┌─────────────┐
│ Parser │ 解析 Markdown,生成带标记的文档树
└──────┬──────┘
↓
┌─────────────┐
│ Schema │ 定义文档结构:节点类型、Mark 类型
└──────┬──────┘
↓
┌──────────────────────────────┐
│ Decoration + Plugin 系统 │ 控制显示逻辑(即时渲染的核心)
└──────┬───────────────────────┘
↓
┌─────────────┐
│ NodeView │ 特殊节点的自定义渲染(代码块、数学公式等)
└──────┬──────┘
↓
┌─────────────┐
│ Serializer │ 将文档树重新序列化为 Markdown 文本
└─────────────┘
数据在这五层之间单向流动:源码经 Parser 变成文档树,文档树在 ProseMirror 的 View 层渲染,Decoration 系统在渲染前插手控制显示,NodeView 接管特殊节点的 DOM 渲染,用户导出时 Serializer 逆向还原。
三、核心秘诀:syntax_marker 设计
这是整个系统最关键的设计决策,理解它是理解后续所有内容的前提。
3.1 语法符号"不消失"
以粗体为例。传统富文本编辑器解析 **粗体** 时,会创建一个带 bold 样式的文本节点,** 符号就被"消化"掉了。Milkup 不这样做。
Parser 解析 **粗体** 后,生成的文档结构是:
paragraph
├─ text "**" [mark: syntax_marker{syntaxType: "strong"}]
├─ text "粗体" [mark: strong]
└─ text "**" [mark: syntax_marker{syntaxType: "strong"}]
** 作为独立的文本节点存在,但被打上了 syntax_marker 这个特殊 Mark 的标签。
3.2 syntax_marker Mark 的定义
在 schema/index.ts 中,syntax_marker 被定义为一个携带 syntaxType 属性的 Mark:
// src/core/schema/index.ts
syntax_marker: {
attrs: { syntaxType: { default: "" } },
toDOM: () => ["span", { class: "milkup-syntax-marker" }, 0],
parseDOM: [{ tag: "span.milkup-syntax-marker" }],
}
syntaxType 记录这个标记属于哪种语法("strong"、"emphasis"、"link" 等),后续的 Decoration 系统需要根据它来判断该不该显示。
3.3 为什么这样设计
这个设计的优雅之处在于:
- 光标可以自由移动进入语法符号区域,这是实现 Typora 式交互的物理基础
- 文档结构完全保留语法信息,序列化时直接提取文本内容就能还原 Markdown
- 显示与存储解耦:显不显示
**是渲染层的事,文档层不关心
四、即时渲染的实现:Decoration 系统
这是 Milkup 最核心、也最复杂的部分。整个"光标靠近语法符号就显现,光标离开就隐藏"的效果,都由 decorations/index.ts 这一层实现。
4.1 什么是 ProseMirror Decoration
Decoration(装饰)是 ProseMirror 提供的一种机制:可以在不改变文档内容的前提下,修改特定位置的 DOM 表现。Milkup 利用 Decoration 给 syntax_marker 文本节点添加或移除隐藏样式,从而控制它的可见性。
4.2 核心数据结构
// src/core/decorations/index.ts
interface DecorationPluginState {
decorations: DecorationSet; // 当前的装饰集合
activeRegions: SyntaxMarkerRegion[]; // 当前光标激活的语法区域
sourceView: boolean; // 是否处于源码模式
cachedSyntaxRegions: SyntaxMarkerRegion[]; // 缓存的语法区域(性能优化)
cachedMathInlineRegions: MathInlineRegion[]; // 缓存的行内数学区域
}
interface SyntaxMarkerRegion {
from: number; // 语法标记区域的起始文档位置
to: number; // 语法标记区域的结束文档位置
syntaxType: string; // 语法类型
}
4.3 工作流程:step by step
每次文档内容变化或光标移动时,Decoration 插件都会重新计算装饰集合。流程分为两步:
第一步:扫描文档,建立 SyntaxMarkerRegion 列表
函数 findSyntaxMarkerRegions() 遍历整个文档,找出所有带 syntax_marker mark 的文本节点,并把同一语法单元的开始标记和结束标记合并为一个"区域"。
// 伪代码(简化自 src/core/decorations/index.ts)
function findSyntaxMarkerRegions(doc: Node): SyntaxMarkerRegion[] {
const regions: SyntaxMarkerRegion[] = [];
doc.descendants((node, pos) => {
if (!node.isText) return;
const markerMark = node.marks.find(m => m.type.name === "syntax_marker");
if (!markerMark) return;
// 将连续的 syntax_marker 文本节点合并为一个区域
const syntaxType = markerMark.attrs.syntaxType;
mergeOrCreate(regions, pos, pos + node.nodeSize, syntaxType);
});
return regions;
}
这一步的结果就像给文档打了一张"语法地图":哪个位置到哪个位置是一对 **...**,哪个位置是 `...`,一目了然。
第二步:根据光标位置,计算哪些标记该显示
函数 computeDecorations() 接收光标位置(selection.head)和上一步建立的区域列表,按以下规则决定每个 syntax_marker 节点的 CSS 类:
光标位置 (cursorPos)
│
▼
┌────────────────────────────────────────────────────┐
│ 是否处于源码模式? │
│ 是 → 所有 syntax_marker 显示(不加隐藏类) │
│ 否 → 继续判断 │
└───────────────────┬────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ 遍历每个 SyntaxMarkerRegion │
│ │
│ 情形 A:光标直接在该 syntax_marker 文本节点内 │
│ → 显示(让用户能看到并编辑原始符号) │
│ │
│ 情形 B:光标在该区域的"语义范围"内 │
│ (如光标在粗体文字中,则其两侧的 ** 都显示) │
│ → 显示 │
│ │
│ 情形 C:光标在该区域之外 │
│ → 隐藏(添加 CSS 类 "milkup-syntax-hidden") │
└────────────────────────────────────────────────────┘
实际代码的核心判断:
// src/core/decorations/index.ts(节选)
function computeDecorations(
doc: Node,
selection: Selection,
syntaxRegions: SyntaxMarkerRegion[],
sourceView: boolean
): DecorationSet {
if (sourceView) return DecorationSet.empty;
const { head } = selection;
const decorations: Decoration[] = [];
for (const region of syntaxRegions) {
const isActive = head >= region.from && head <= region.to;
if (!isActive) {
// 隐藏这个区域内的所有 syntax_marker 节点
decorations.push(
Decoration.inline(region.from, region.to, {
class: "milkup-syntax-hidden",
})
);
}
// 激活时不加任何装饰,syntax_marker 自然显示
}
// 行内数学公式:额外渲染 KaTeX Widget
for (const mathRegion of mathInlineRegions) {
if (!isActive(mathRegion, head)) {
decorations.push(
Decoration.widget(mathRegion.from, renderKaTeXWidget(mathRegion.content))
);
}
}
return DecorationSet.create(doc, decorations);
}
4.4 性能优化:两级缓存
每次按键都要重新扫描整个文档是不可接受的。Milkup 引入了两级缓存:
- 文档未变化时(只是光标移动):复用
cachedSyntaxRegions,只重新运行第二步 - 文档发生变化时:重新运行第一步扫描,更新缓存,再运行第二步
// 伪代码
apply(tr: Transaction, state: DecorationPluginState) {
let { cachedSyntaxRegions } = state;
if (tr.docChanged) {
// 文档变化,重新扫描
cachedSyntaxRegions = findSyntaxMarkerRegions(tr.doc);
}
// 光标变化或文档变化,都需要重新计算装饰
const decorations = computeDecorations(
tr.doc, tr.selection, cachedSyntaxRegions, state.sourceView
);
return { decorations, cachedSyntaxRegions, ... };
}
4.5 CSS 的配合
milkup-syntax-hidden 的 CSS 实现用的是 font-size: 0 而非 display: none,这样光标仍然可以"走进去",保证用户点击渲染后的文字时能精确定位到对应的源码位置。
.milkup-syntax-hidden {
font-size: 0;
line-height: 0;
}
五、源码模式切换:块级节点的形变
Milkup 支持在渲染模式和源码模式之间切换。两种模式下,文档的结构是完全不同的。
5.1 两种模式下的文档结构对比
渲染模式下,Markdown 的块级结构被忠实地表达为 ProseMirror 节点:
# 标题 → heading 节点 (level: 1)
> 引用内容 → blockquote 节点
```js → code_block 节点 (language: "js")
代码内容
```
源码模式下,这些块级节点全部被展平为 paragraph,每一行是一个段落,内容是原始的 Markdown 文本:
paragraph: "# 标题"
paragraph: "> 引用内容"
paragraph: "```js"
paragraph: "代码内容"
paragraph: "```"
5.2 切换函数
plugins/source-view-transform.ts 提供了两个核心函数:
convertBlocksToParagraphs():渲染 → 源码
遍历文档中的所有块级节点,把每个节点替换为若干个 paragraph,段落内容是该节点对应的 Markdown 源码。这里需要事先在节点 attrs 中存储源码信息(如 code_block 节点存储了 language 属性和原始代码内容)。
convertParagraphsToBlocks():源码 → 渲染
把当前文档的所有文本内容拼接成 Markdown,然后重新调用 Parser 解析,用解析结果替换整个文档。
// 伪代码(简化自 source-view-transform.ts)
function convertParagraphsToBlocks(state: EditorState, parser: MarkdownParser) {
// 提取所有段落文本,重组为 Markdown
const markdown = extractParagraphsAsMarkdown(state.doc);
// 重新解析
const newDoc = parser.parse(markdown);
// 用新文档替换旧文档
return state.tr.replaceWith(0, state.doc.content.size, newDoc.content);
}
5.3 状态总线:sourceViewManager
切换源码模式会影响多个子系统:Decoration 系统需要知道(决定是否隐藏标记),NodeView(代码块)需要知道(决定是否渲染 CodeMirror),行号插件需要知道(决定是否显示行号)。
Milkup 用一个简单的发布订阅对象 sourceViewManager 作为状态总线:
// src/core/decorations/index.ts
class SourceViewManager {
private listeners: SourceViewListener[] = [];
private state: boolean = false;
subscribe(listener: SourceViewListener): () => void {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
setState(sourceView: boolean): void {
this.state = sourceView;
this.listeners.forEach(l => l(sourceView));
}
getState(): boolean {
return this.state;
}
}
export const sourceViewManager = new SourceViewManager();
各插件在初始化时订阅它,源码模式切换时统一触发更新。
六、NodeView:特殊节点的渲染代理
并不是所有节点都能靠 ProseMirror 的默认渲染搞定。代码块、数学公式等需要嵌入第三方库。NodeView 是 ProseMirror 提供的逃生口:允许开发者接管特定节点的 DOM 渲染。
6.1 代码块:嵌入 CodeMirror 6
nodeviews/code-block.ts 为每个代码块节点创建一个独立的 CodeMirror 6 编辑器实例,并根据 language 属性加载对应的语法高亮扩展。
特别值得一提的是 Mermaid 图表的处理:
// 伪代码
class CodeBlockView {
update(node: Node) {
if (node.attrs.language === "mermaid") {
// 不显示 CodeMirror,改为渲染 Mermaid 图表
this.renderMermaid(node.textContent);
} else {
// 显示 CodeMirror 编辑器,按语言高亮
this.cm.dispatch({ changes: ... });
this.updateLanguage(node.attrs.language);
}
}
}
CodeMirror 和 ProseMirror 之间的光标同步是这里最棘手的问题:用户在代码块里按 Tab 时,光标应该在 CodeMirror 内移动,而不是跳到 ProseMirror 的下一个节点;按 Escape 或上下键到代码块边界时,才应该把光标控制权归还给 ProseMirror。
6.2 数学公式:KaTeX 即时渲染
nodeviews/math-block.ts 为块级数学公式($$...$$)创建一个实时渲染区域。用户编辑 LaTeX 源码时,预览区域同步更新。
行内数学公式($...$)则走的是 Decoration Widget 路线(见第四章),不需要 NodeView,由 Decoration 系统在对应位置插入一个渲染好的 span 元素。
七、Parser 与 Serializer:文档的进出口
7.1 Parser:如何处理嵌套语法
parser/index.ts 是整个项目最长的文件(超过 1200 行),承担将 Markdown 文本转换为 ProseMirror 文档树的职责。
解析分两个层次进行:
块级解析先按行识别结构,切分出标题、代码块、列表、表格、引用块等。这一层相对直观,大量依赖行首特征字符(#、```、>、-、| 等)。
行内解析更为复杂,需要在一段文字中同时处理多种可能嵌套的语法。Milkup 的策略是:对每一段文字跑多个正则,收集所有匹配位置,然后按出现位置排序,相同起点时长匹配优先,再逐段递归处理。
// 伪代码(简化自 parser/index.ts)
function parseInlineWithSyntax(text: string, schema: Schema): Node[] {
const matches: InlineMatch[] = [];
// 对每种行内语法类型运行正则
for (const syntax of INLINE_SYNTAXES) {
for (const match of text.matchAll(syntax.pattern)) {
matches.push({ start: match.index, end: match.index + match[0].length, syntax, match });
}
}
// 按起始位置排序,相同起点长匹配优先
matches.sort((a, b) => a.start - b.start || b.end - a.end);
// 逐段构建节点列表
return buildNodes(text, matches, schema);
}
7.2 Serializer:如何从文档树还原 Markdown
serializer/index.ts 的核心策略是:直接读取文本节点的内容。
由于 syntax_marker 文本节点里存的就是 **、*、` 等原始符号,序列化时按顺序拼接所有文本节点即可还原出原始的 Markdown。对于代码块、表格等结构复杂的块级节点,节点的 attrs 中存有完整的源码字符串,直接输出。
这个设计保证了往返稳定性(roundtrip stability):一段 Markdown 文本经过解析和序列化之后,应该得到完全相同的字符串(或者语义等价的字符串)。这是编辑器数据正确性的基本保证。
八、插件系统:把一切粘合在一起
Milkup 的功能通过 ProseMirror 插件系统组织。editor.ts 在初始化时按优先级顺序加载所有插件:
// src/core/editor.ts(节选,伪代码展示加载顺序)
const plugins = [
// 优先级最高:快捷键
searchKeymap(), // Mod-f / Mod-h
dynamicKeymap(config), // 用户可自定义的快捷键
blockEnterKeymap(schema), // 块级元素的 Enter 处理
baseKeymap, // ProseMirror 基础快捷键
// 核心功能
instantRenderPlugin(config), // 即时渲染(Decoration 系统)
inputRules(schema), // 输入规则(如 --- → 分隔线)
syntaxDetector(schema), // 语法检测
syntaxFixer(schema), // 语法修复
// 辅助功能
pastePlugin(config.pasteConfig),
mathBlockSync(),
lineNumbers(),
placeholder(config.placeholder),
];
插件顺序决定事务处理的优先级,尤其是快捷键插件必须在最前面,避免被其他插件拦截。
其中 syntax-detector 和 syntax-fixer 两个插件值得一提:用户直接在渲染模式下输入 ** 时,系统需要识别出这是一个新的语法标记并打上 syntax_marker,同时维护标记对的匹配关系(开始标记和结束标记必须配对)。这两个插件就是专门干这件事的。
九、总结
回顾整个系统,即时渲染和源码模式切换这两个核心特性,都建立在同一个基础设计之上:语法符号是真实内容,显示是渲染层的事。
这个思路拉通了整条链路:
- Parser 把
**粗体**拆成三个文本节点,**带syntax_marker标记 - Decoration 系统根据光标位置,决定
**是否应该被font-size: 0藏起来 - Serializer 拼接文本节点内容,自然得到原始 Markdown
- 源码模式切换时,只需改变 Decoration 系统的行为(不再隐藏任何标记)以及把块级节点展平为段落
每一层的职责都很清晰,修改其中任何一层都不需要动其他层。这大概就是这个设计最值得学习的地方:把"存什么"和"显示什么"彻底分开,前者由 Schema 保证正确,后者由 Decoration 按需计算。
十、你有能力支持 milkup 创作
如果你正在寻找一个 Typora 的免费替代品,或者你正在使用 milkup,别忘了给 milkup 一个小星星🌟。
其次,开发 milkup 花费了大量的 tokens 费用,但它本身不盈利,你可以扫描 readme 里面的二维码赞助我们。
另外,milkup core 我预计也将作为独立项目开源出来,以便于大家可以在浏览器端使用,再贴一次地址:
官网地址:milkup.dev